"""
kis_trader/network/condition_manager.py — KIS 조건검색 기반 동적 유니버스
===========================================================================
팩트 체크 먼저:
  * KIS 는 ``H0UPANC0`` 웹소켓으로 "조건검색 실시간" 을 주지 않는다.
    H0UPANC0 는 **업종별 예상체결** TR 이다. 인터넷 블로그/LLM 답변에
    자주 보이는 "조건검색 웹소켓" 은 대부분 키움 OpenAPI+ 쪽 이야기.
  * KIS 공식 경로는 REST 두 개뿐:
      /quotations/psearch-title   → 서버 저장 조건식 목록  (HHKST03900300)
      /quotations/psearch-result  → 특정 조건식 현재 결과   (HHKST03900400)
  * 따라서 REST 폴링이 유일한 방법. 기본 폴링 주기는 ``CONDITION_POLL_INTERVAL_SEC``
    (기본 10초). 10초면 종목당 하루 ~2,340 호출로 429 안전 여유 충분.

v2 변경점 (다중 조건식 지원):
  * 전략마다 다른 조건식을 쓸 수 있도록 ``configs`` 인자 추가:
        configs = [
            {"strategy_id": "SCALP",    "name": "체결강도급등", "seq": "0"},
            {"strategy_id": "SHORT",    "name": "꼬리달린봉",   "seq": ""},  # seq 는 name 으로 자동 해결
            {"strategy_id": "BREAKOUT", "name": "우상향돌파",   "seq": ""},
        ]
  * 같은 조건식을 여러 전략이 공유해도 OK (seq 가 같으면 REST 1번만 호출)
  * 전략별 get_universe_for / get_candidates_for 제공
  * **변동(ENTER/EXIT) 감지 tick 마다** ``target_candidates_history`` 에
    초단위 ``event_time`` (YYYY-MM-DD HH:MM:SS) 으로 풀 스냅샷을 INSERT.
    (strategy_id 컬럼은 ``TradeDBExt.insert_condition_universe_snapshot``
    이 자동 마이그레이션.)  백테스트는 ``TradeDBExt.get_universe_by_candle_time()``
    으로 "그 1분봉 시점에 봇이 보던 유니버스" 를 재현.

사용 (권장 — multi):
    cm = ConditionSearchManager(
        client=kis_client,
        user_id="HTSID",
        configs=[
            {"strategy_id": "SCALP", "name": "체결강도급등"},
            {"strategy_id": "BREAKOUT", "seq": "0"},
        ],
        db=db,
    )
    cm.start()
    codes: set = cm.get_universe_for("SCALP")

사용 (legacy — single, 기존 호출 호환):
    cm = ConditionSearchManager(
        client=kis_client, user_id="HTSID", condition_name="우상향돌파",
    )
"""
from __future__ import annotations

import random
import threading
import time
from datetime import datetime as dt
from typing import Callable, Dict, List, Optional, Set

from ..utils.env import get_env_bool, get_env_int
from ..utils.logger import get_logger

logger = get_logger("kis_trader.cond")


class ConditionSearchManager:
    """KIS 조건검색 폴링 매니저.  여러 조건식을 동시에 병렬 관리."""

    def __init__(
        self,
        *,
        client,
        user_id: str,
        configs: Optional[List[Dict]] = None,
        condition_name: Optional[str] = None,
        condition_seq: Optional[str] = None,
        on_change: Optional[Callable[[str, Set[str], Set[str], Set[str]], None]] = None,
        poll_interval_sec: Optional[float] = None,
        db=None,
    ):
        self.client = client
        self.user_id = (user_id or "").strip()
        self.on_change = on_change
        self.db = db  # 히스토리 저장용 (선택)
        self.poll_interval = float(
            poll_interval_sec
            if poll_interval_sec is not None
            else get_env_int("CONDITION_POLL_INTERVAL_SEC", 10)
        )
        self.history_enabled = get_env_bool("CONDITION_HISTORY_SAVE", True)
        # EXIT grace period: 한 번 빠진 종목을 N초간 universe 에 keep.
        # 단발성 EXIT/RE-ENTER 회전을 흡수해 WS 구독 해제 → 캐시·갭보정 리셋
        # → resubscribe 후 데이터 부족으로 매수 시그널 못내는 사이클을 차단.
        # 기본 0 = 비활성 (기존 동작과 동일). 권장 60.
        self._exit_grace_sec = float(get_env_int("CONDITION_EXIT_GRACE_SEC", 0))
        # strategy_id → {code: first_missing_at_epoch}
        self._pending_exit: Dict[str, Dict[str, float]] = {}

        # 설정 정규화: legacy(single) → multi 형식으로 흡수
        self._configs: List[Dict] = []
        if configs:
            for c in configs:
                sid = str(c.get("strategy_id") or "").strip().upper()
                nm = (c.get("name") or "").strip() or None
                sq = (c.get("seq") or "").strip() or None
                if not sid or (not nm and not sq):
                    continue
                self._configs.append({"strategy_id": sid, "name": nm, "seq": sq})
        elif condition_name or condition_seq:
            self._configs.append({
                "strategy_id": "DEFAULT",
                "name": (condition_name or "").strip() or None,
                "seq": (condition_seq or "").strip() or None,
            })

        self._thread: Optional[threading.Thread] = None
        self._running = False

        # 상태
        self._current: Dict[str, Set[str]] = {}  # strategy_id → code set
        self._name_map: Dict[str, str] = {}       # code → name (전역)
        # 초기 tick 에서 "빈 set → 첫 결과" 를 변동으로 간주해 1회는 저장
        self._initialized: Set[str] = set()
        self._lock = threading.Lock()

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------
    def start(self) -> bool:
        """조건식 seq 를 해결하고 폴링 쓰레드 기동. 유효 조건식 0개면 False."""
        if not self.user_id:
            logger.warning("조건검색 user_id 누락 → 비활성")
            return False
        if not self._configs:
            logger.info("조건검색 configs 비어 있음 → 매니저 비활성")
            return False

        # seq 해결 (이름 → seq). 1번만 전체 목록 호출해서 캐시.
        name_to_seq = self._fetch_seq_map()
        valid = []
        for cfg in self._configs:
            if not cfg.get("seq") and cfg.get("name"):
                sq = name_to_seq.get(cfg["name"])
                if sq:
                    cfg["seq"] = sq
            if cfg.get("seq"):
                valid.append(cfg)
                logger.info(
                    "🔗 조건식 매핑: strategy=%s seq=%s name=%s",
                    cfg["strategy_id"], cfg["seq"], cfg.get("name") or "?",
                )
            else:
                logger.warning(
                    "⚠️ 조건식 seq 해결 실패 (strategy=%s name=%s) → 이 전략은 폴백",
                    cfg["strategy_id"], cfg.get("name"),
                )
        self._configs = valid

        if not self._configs:
            logger.warning("유효 조건식 0개 → 조건검색 매니저 비활성")
            return False

        self._running = True
        self._thread = threading.Thread(
            target=self._loop, daemon=True, name="CondSearch"
        )
        self._thread.start()
        logger.info(
            "✅ 조건검색 폴링 시작 (%d개, interval=%ds, exit_grace=%ds, history=%s)",
            len(self._configs), int(self.poll_interval), int(self._exit_grace_sec),
            "ON" if (self.history_enabled and self.db is not None) else "OFF",
        )
        return True

    def stop(self) -> None:
        self._running = False

    # ── 전략별 조회 ────────────────────────────────────────────
    def get_universe_for(self, strategy_id: str) -> Set[str]:
        sid = (strategy_id or "").upper()
        with self._lock:
            return set(self._current.get(sid, set()))

    def get_candidates_for(self, strategy_id: str) -> List[Dict]:
        """BaseStrategy._load_candidates 와 호환되는 dict 리스트 반환."""
        sid = (strategy_id or "").upper()
        with self._lock:
            codes = list(self._current.get(sid, set()))
            nm = dict(self._name_map)
        # 전략별 기본 필터 플래그는 True 로 열어둔다 (전략 쪽 _candidate_filter 가 판단)
        out = []
        for c in codes:
            out.append({
                "code": c,
                "name": nm.get(c, c),
                "scalp_on": True,
                "tail_on": True,
                "score": 0.0,
                "price": 0.0,
            })
        return out

    # ── 하위 호환 (Breakout 단일 매니저용) ───────────────────
    def get_universe(self) -> Set[str]:
        """모든 전략 유니버스의 합집합 (heartbeat/총량 로그용)."""
        with self._lock:
            out: Set[str] = set()
            for s in self._current.values():
                out |= s
            return out

    def get_candidates(self) -> List[Dict]:
        """기본 호출 시 첫 번째 설정된 전략 유니버스 반환 (하위호환)."""
        if not self._configs:
            return []
        return self.get_candidates_for(self._configs[0]["strategy_id"])

    # ------------------------------------------------------------------
    # 내부
    # ------------------------------------------------------------------
    @staticmethod
    def _row_name(row: Dict) -> str:
        """
        KIS psearch-title 응답의 '조건식 이름' 추출.
        실제 응답 키는 ``condition_nm`` (예: {"seq":"0","condition_nm":"돌파_초반강세",...}).
        과거/문서상 변형 키도 모두 허용해 안전하게 폴백.
        """
        for k in ("condition_nm", "condition_name", "cond_nm", "user_cnd_nm"):
            v = row.get(k)
            if v is not None and str(v).strip():
                return str(v).strip()
        return ""

    def _fetch_seq_map(self) -> Dict[str, str]:
        """서버 저장 조건식 목록 1회 호출 → name→seq 맵."""
        try:
            lst = self.client.get_condition_list(self.user_id) or []
        except Exception as e:
            logger.error("조건식 목록 조회 예외: %s", e)
            return {}
        if not lst:
            logger.warning("조건식 목록이 비어있음 (user_id=%s)", self.user_id)
            return {}
        logger.info(
            "저장된 조건식 %d개: %s",
            len(lst),
            ", ".join(f"{x.get('seq')}:{self._row_name(x) or '?'}" for x in lst),
        )
        return {
            self._row_name(row): str(row.get("seq") or "").strip()
            for row in lst
            if self._row_name(row)
        }

    def _loop(self) -> None:
        while self._running:
            try:
                self._tick_all()
            except Exception as e:
                logger.error("조건검색 루프 예외: %s", e)
            # 서버 부하 방지 지터 — 주기 대비 10% 수준
            jitter = min(1.0, self.poll_interval * 0.1)
            sleep_sec = self.poll_interval + random.uniform(0, jitter)
            # 중단 감지 해상도 0.5s (10초 주기에 1초 해상도는 과함)
            deadline = time.time() + sleep_sec
            while time.time() < deadline:
                if not self._running:
                    return
                time.sleep(0.5)

    def _tick_all(self) -> None:
        # 같은 seq 를 공유하는 전략이 있으면 REST 1회만 호출 (캐시)
        seq_cache: Dict[str, List[Dict]] = {}
        for cfg in self._configs:
            sid = cfg["strategy_id"]
            seq = cfg["seq"]
            if seq in seq_cache:
                rows = seq_cache[seq]
            else:
                try:
                    rows = self.client.get_condition_result(self.user_id, seq) or []
                except Exception as e:
                    logger.debug("조건검색 결과 조회 실패 (%s/%s): %s", sid, seq, e)
                    rows = []
                seq_cache[seq] = rows
            self._apply_result(sid, rows)

    def _apply_result(self, strategy_id: str, rows: List[Dict]) -> None:
        """
        조건검색 raw 결과를 universe 에 반영.

        EXIT grace 정책 (``CONDITION_EXIT_GRACE_SEC`` > 0 일 때):
          - raw 결과에서 빠진 종목을 즉시 EXIT 처리하지 않고 ``_pending_exit`` 에
            ``first_missing_at`` 시각과 함께 등록.
          - grace 초가 지나야 진짜 EXIT (universe 에서 제거) → WS 구독 해제 → 갭보정 리셋.
          - grace 중 다시 raw 에 등장하면 pending 에서 빼고 universe 에 그대로 keep
            (구독·캐시·봉 데이터 보존).

        효과: 단발성 EXIT/RE-ENTER 폭주 흡수. 시장 노이즈로 1~2 tick 빠지는 케이스를
              걸러 매수 시그널 평가용 RAM 데이터 (회복률·낙폭 등) 가 휘발 안 됨.
        """
        raw_set = {r["code"] for r in rows if r.get("code")}
        new_names = {
            r["code"]: r.get("name", r["code"]) for r in rows if r.get("code")
        }
        now = time.time()
        grace = self._exit_grace_sec

        with self._lock:
            prev = self._current.get(strategy_id, set())
            first_tick = strategy_id not in self._initialized
            pending = self._pending_exit.setdefault(strategy_id, {})

            # ── grace 적용 ────────────────────────────────────────
            kept_in_grace: Set[str] = set()
            if grace > 0:
                # 1) raw 에 다시 등장 → grace 해제 (회생)
                for c in raw_set & set(pending.keys()):
                    pending.pop(c, None)
                # 2) raw 에서 빠진 prev 종목 → pending 등록 (처음 사라진 시각)
                for c in prev - raw_set:
                    if c not in pending:
                        pending[c] = now
                # 3) grace 미경과 종목 → universe 에 keep / 경과 → pending 제거
                expired: Set[str] = set()
                for c, first_at in list(pending.items()):
                    if now - first_at >= grace:
                        expired.add(c)
                for c in expired:
                    pending.pop(c, None)
                kept_in_grace = set(pending.keys())
            # grace=0 (기존 동작) 이면 kept_in_grace = ∅, pending 안 쓰임.

            # 봇이 실제로 보는 effective universe (raw + grace keep)
            new_set = raw_set | kept_in_grace

            # ── 변동 계산 (effective 기준) ───────────────────────
            enters = new_set - prev
            exits = prev - new_set
            self._current[strategy_id] = new_set
            self._initialized.add(strategy_id)
            for c, n in new_names.items():
                self._name_map[c] = n

        changed = bool(enters or exits)
        n_grace = len(kept_in_grace)

        if changed:
            grace_tag = f", grace={n_grace}" if n_grace else ""
            logger.info(
                "🔄 [%s] +%d / -%d  (현재 %d종목%s)",
                strategy_id, len(enters), len(exits), len(new_set), grace_tag,
            )
            if enters:
                preview = ", ".join(
                    f"{c}({new_names.get(c, c)})" for c in list(sorted(enters))[:5]
                )
                logger.info("   ENTER: %s%s",
                            preview, " …" if len(enters) > 5 else "")
            if exits:
                preview = ", ".join(sorted(exits)[:5])
                logger.info("   EXIT : %s%s",
                            preview, " …" if len(exits) > 5 else "")

        if self.on_change and changed:
            try:
                self.on_change(strategy_id, new_set, enters, exits)
            except Exception as e:
                logger.warning("on_change 콜백 예외: %s", e)

        # 백테스트 재현성 보장: 첫 tick 또는 변동 발생 tick 마다 풀 스냅샷 저장
        # (effective universe 기준 — 봇이 실제로 보던 universe 가 그대로 기록됨)
        # 변동 없는 tick 은 공간 절약 위해 skip
        if changed or first_tick:
            self._save_snapshot(strategy_id, new_set, new_names)

    def _save_snapshot(
        self,
        strategy_id: str,
        codes: Set[str],
        names: Dict[str, str],
    ) -> None:
        """변동이 감지된 tick 의 풀 유니버스 스냅샷 저장."""
        if not (self.history_enabled and self.db is not None):
            return
        # 유니버스가 비어있어도 "비었다" 는 사실을 기록해야 백테스트에서 재현 가능.
        # 단, 한 번도 결과를 못 받은 상태(첫 호출 실패 등)는 저장 X.
        event_time = dt.now().strftime("%Y-%m-%d %H:%M:%S")
        items = [{"code": c, "name": names.get(c, c)} for c in sorted(codes)]
        try:
            n = self.db.insert_condition_universe_snapshot(
                strategy_id=strategy_id,
                event_time=event_time,
                items=items,
            )
            logger.debug(
                "📼 [history] %s @%s %d종목 저장",
                strategy_id, event_time, n,
            )
        except Exception as e:
            logger.debug("history 저장 예외: %s", e)
