"""
kis_trader/network/ranking_manager.py — 거래량/체결강도 순위 기반 유니버스
============================================================================
전략별 유니버스를 KIS 랭킹 REST(volume-rank FHPST01710000) 로 갱신하는 매니저.

왜 조건검색 대신 랭킹인가:
  * 조건검색은 "구조적 필터" (20일 신고가·이평정배열 등 돌파용) 에 강하지만
    "지금 반등 중" 같은 동적 판단엔 약하다.
  * 실제 매매 시점 판정은 전략 코드에서 정밀 계산 (체결강도·분봉 반등 등) 하면
    충분. 유니버스 단계에선 **활발한 종목 풀** 만 뽑으면 됨.
  * 거래량/체결강도 상위는 KIS REST 한 방에 100~200건을 받아올 수 있고
    재현 가능(tick 단위 저장 가능) → 백테스트 친화.

동작:
  1. configs 리스트로 전략별 랭킹 소스 지정.
        configs = [
            {"strategy_id": "SCALP", "sort": "volume",   "limit": 100, "market": "J"},
            {"strategy_id": "SHORT", "sort": "volume",   "limit": 100, "market": "J"},
            # sort 후보: volume | trading_value | strength | fluct_up | fluct_down
        ]
  2. 동일 (sort, market, limit) 조합은 REST 1회만 호출 (캐시).
  3. 변동 감지 tick 마다 target_candidates_history 에 초단위 ``event_time`` 으로
     **즉시 INSERT** (ConditionSearchManager 와 동일 패턴).
     — 버퍼/배치 flush 는 단일 행 INSERT 대비 실익이 없고 crash 내구성만 낮추므로
       제거.  INSERT 1회 ≈ 1ms 미만이라 10초 폴링 루프에 영향 없음.

ConditionSearchManager 와의 공용 인터페이스 (전략 쪽에서는 구분 불필요):
  - start() / stop()
  - get_universe_for(strategy_id) -> Set[str]
  - get_candidates_for(strategy_id) -> List[Dict]
  - get_universe() -> Set[str]      (전체 합집합)
  - _configs 속성                    (heartbeat 용)
"""
from __future__ import annotations

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

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

logger = get_logger("kis_trader.rank")


_SORT_MAP = {
    # 공개 별칭 → KIS FID_BLNG_CLS_CODE 매핑
    "volume":          "0",
    "vol":             "0",
    "trading_value":   "3",
    "value":           "3",
    "strength":        "6",
    "cntr_str":        "6",
    "fluct_up":        "4",
    "up":              "4",
    "fluct_down":      "5",
    "down":            "5",
    "decline":         "5",
}


class VolumeRankManager:
    """거래량/체결강도 순위 기반 동적 유니버스 매니저.

    저장 정책: 변동 감지 tick 마다 초단위 ``event_time`` 으로 DB 에 **즉시 INSERT**.
    (과거엔 큐 + 기록원 스레드로 배치 flush 했으나, 단일 행 INSERT 대비 실익이
     없고 crash 시 미-flush tick 유실 위험만 낮추므로 제거했다.)
    """

    def __init__(
        self,
        *,
        client,
        configs: List[Dict],
        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.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("RANKING_POLL_INTERVAL_SEC", 10)
        )
        # 히스토리 저장 플래그 (ConditionSearchManager 와 env 공유 의도로 이름은 UNIVERSE_HISTORY_SAVE)
        self.history_enabled = get_env_bool("UNIVERSE_HISTORY_SAVE", True)

        # 설정 정규화
        self._configs: List[Dict] = []
        for c in configs or []:
            sid = str(c.get("strategy_id") or "").strip().upper()
            if not sid:
                continue
            sort_key = str(c.get("sort") or "volume").strip().lower()
            blng = _SORT_MAP.get(sort_key)
            if not blng:
                logger.warning(
                    "⚠️ 알 수 없는 sort=%s (strategy=%s) → 'volume' 사용",
                    sort_key, sid,
                )
                sort_key = "volume"
                blng = "0"
            self._configs.append({
                "strategy_id": sid,
                "sort": sort_key,
                "blng": blng,
                "market": str(c.get("market") or "J").strip() or "J",
                "limit": int(c.get("limit") or 100),
                "exclude_non_stock": bool(c.get("exclude_non_stock", True)),
            })

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

        self._current: Dict[str, Set[str]] = {}
        self._name_map: Dict[str, str] = {}
        self._initialized: Set[str] = set()
        self._lock = threading.Lock()

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------
    def start(self) -> bool:
        if not self._configs:
            logger.info("랭킹 매니저 configs 없음 → 비활성")
            return False

        for cfg in self._configs:
            logger.info(
                "🔗 랭킹 매핑: strategy=%s sort=%s market=%s limit=%d",
                cfg["strategy_id"], cfg["sort"], cfg["market"], cfg["limit"],
            )

        self._running = True

        self._thread = threading.Thread(
            target=self._loop, daemon=True, name="VolumeRank",
        )
        self._thread.start()
        logger.info(
            "✅ 랭킹 폴링 시작 (%d개, interval=%ds, history=%s)",
            len(self._configs), int(self.poll_interval),
            "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)
        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

    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"])

    # ------------------------------------------------------------------
    # 내부
    # ------------------------------------------------------------------
    def _loop(self) -> None:
        while self._running:
            try:
                self._tick_all()
            except Exception as e:
                logger.error("랭킹 루프 예외: %s", e)
            jitter = min(1.5, self.poll_interval * 0.1)
            sleep_sec = self.poll_interval + random.uniform(0, jitter)
            deadline = time.time() + sleep_sec
            while time.time() < deadline:
                if not self._running:
                    return
                time.sleep(0.5)

    def _tick_all(self) -> None:
        # (blng, market, limit) 조합이 같으면 REST 1회만 호출
        cache: Dict[Tuple[str, str, int, bool], List[Dict]] = {}
        for cfg in self._configs:
            key = (cfg["blng"], cfg["market"], cfg["limit"], cfg["exclude_non_stock"])
            if key in cache:
                rows = cache[key]
            else:
                try:
                    rows = self.client._fetch_volume_rank(
                        market=cfg["market"],
                        blng_cls_code=cfg["blng"],
                        limit=cfg["limit"],
                        exclude_non_stock=cfg["exclude_non_stock"],
                    ) or []
                except Exception as e:
                    logger.debug(
                        "랭킹 조회 실패 (blng=%s market=%s): %s",
                        cfg["blng"], cfg["market"], e,
                    )
                    rows = []
                cache[key] = rows
            self._apply_result(cfg["strategy_id"], rows)

    def _apply_result(self, strategy_id: str, rows: List[Dict]) -> None:
        new_set: Set[str] = set()
        new_names: Dict[str, str] = {}
        for r in rows:
            code = (
                r.get("mksc_shrn_iscd") or r.get("stk_cd")
                or r.get("code") or ""
            ).strip()
            if not code or len(code) != 6:
                continue
            name = (
                r.get("hts_kor_isnm") or r.get("stk_nm")
                or r.get("prst_name") or code
            ).strip() or code
            new_set.add(code)
            new_names[code] = name

        with self._lock:
            prev = self._current.get(strategy_id, set())
            first_tick = strategy_id not in self._initialized
            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)

        if changed:
            logger.info(
                "🔄 [%s] +%d / -%d  (현재 %d종목, rank)",
                strategy_id, len(enters), len(exits), len(new_set),
            )
            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 → 풀 스냅샷 저장 (condition 과 동일 테이블/포맷)
        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 마다 즉시 DB 에 풀 스냅샷 INSERT.

        * ``event_time`` 은 REST 응답 직후 찍은 초단위 시각 (YYYY-MM-DD HH:MM:SS).
        * 이 값이 백테스트 매칭 기준 시각이며, DB INSERT 가 1ms 지연되든 100ms
          지연되든 행에 박히는 event_time 은 불변이므로 정합성은 보존.
        * 폴링은 10초 주기라 DB 부하 매우 적음. 버퍼링으로 얻을 이득 없음.
        """
        if not (self.history_enabled and self.db is not None):
            return
        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/rank] %s @%s %d종목 INSERT",
                strategy_id, event_time, n,
            )
        except Exception as e:
            # INSERT 실패는 매매 흐름에 영향 없어야 함 → 경고만 남기고 계속.
            logger.warning("유니버스 히스토리 INSERT 실패 (%s): %s", strategy_id, e)
