"""
kis_trader/network/market_guard.py — 시장 급락 서킷브레이커
==============================================================
KOSPI/KOSDAQ 종합지수를 주기적으로 폴링해, 다음 조건 충족 시 PANIC 모드 진입:

  1) 5분 누적 -N% 하락  (기본 -2%)
  2) 일중 누적 -N% 하락 (전일 종가 대비, 기본 -3%)

PANIC 모드 시:
  - ``BaseStrategy._scan_and_buy()`` 가 신규 매수를 즉시 차단
  - 기존 보유 종목 매도는 정상 동작 (포지션 정리·손실 확대 방지)

해제:
  - 5분 누적 +N% 반등 시 자동 해제 (기본 +1%)
  - 또는 운영자가 DB ``MARKET_GUARD_ENABLED=false`` 로 수동 해제

설계 철학
---------
- 거래소 공식 서킷브레이커 (KOSPI -8%) 보다 훨씬 빨리 반응 → 봇 보호 우선.
- 매수만 차단, 매도는 평소처럼 진행 → 봇이 살아있어야 손절·익절 가능.
- 백테스트 X (지수 데이터 누적 안 되어 있음) → 실거래로 임계치 튜닝.
- ``MARKET_GUARD_ENABLED=false`` 가 기본값. 운영 1~2주 모니터링 후 활성화.

환경변수 (DB env_config, 매 tick 재조회 → 운영 중 즉시 반영)
------------------------------------------------------------
    MARKET_GUARD_ENABLED         (true/false, 기본 false)
    MARKET_GUARD_5MIN_DROP_PCT   (실수, 기본 2.0)
    MARKET_GUARD_DAILY_DROP_PCT  (실수, 기본 3.0)
    MARKET_GUARD_RECOVERY_PCT    (실수, 기본 1.0)
    MARKET_GUARD_INDEX_CODE      ("0001"|"1001"|"both", 기본 "both")
    MARKET_GUARD_POLL_SEC        (정수, 기본 30)
"""
from __future__ import annotations

import threading
import time
from collections import deque
from datetime import datetime as dt
from typing import Deque, Dict, List, Optional, Tuple

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

logger = get_logger("kis_trader.market_guard")


class MarketGuard:
    """KOSPI/KOSDAQ 지수 폴링 + PANIC 판정 매니저. 독립 백그라운드 쓰레드."""

    # 지수 코드 → 사람용 이름
    _INDEX_NAMES = {
        "0001": "KOSPI",
        "1001": "KOSDAQ",
        "2001": "KOSPI200",
    }

    def __init__(self, *, client, db=None):
        self.client = client
        self.db = db

        self._thread: Optional[threading.Thread] = None
        self._running = False
        self._lock = threading.Lock()

        # 지수별 최근 가격 deque [(epoch_sec, value), ...]  — 5분 비교용
        # 보수적으로 10분치 (deque maxlen=120 ≈ poll 5초 × 120 = 10분)
        self._history: Dict[str, Deque[Tuple[float, float]]] = {}
        # 지수별 전일 종가 (일중 누적 판정용)
        self._prev_close: Dict[str, float] = {}

        # PANIC 상태 (전역 — 어떤 지수가 트리거했든 매수 전체 차단)
        self._panic = False
        self._panic_reason: str = ""
        self._panic_since: float = 0.0
        self._panic_index: str = ""

        self._reload_config()

    # ------------------------------------------------------------------
    def _reload_config(self) -> None:
        """매 tick DB 재조회 (운영 중 임계치 튜닝 즉시 반영)."""
        self.enabled = get_env_bool("MARKET_GUARD_ENABLED", False)
        self.drop_5min = get_env_float("MARKET_GUARD_5MIN_DROP_PCT", 2.0)
        self.drop_daily = get_env_float("MARKET_GUARD_DAILY_DROP_PCT", 3.0)
        self.recovery_pct = get_env_float("MARKET_GUARD_RECOVERY_PCT", 1.0)
        self.poll_sec = max(5, get_env_int("MARKET_GUARD_POLL_SEC", 30))

        # 감시 지수: "0001", "1001", "both", 또는 콤마구분 ("0001,1001")
        idx_raw = (get_env_from_db("MARKET_GUARD_INDEX_CODE", "both") or "both") \
            .strip().lower()
        if idx_raw == "both":
            self.index_codes = ["0001", "1001"]
        else:
            codes = [c.strip() for c in idx_raw.split(",") if c.strip()]
            # 알 수 없는 코드 들어오면 안전하게 KOSPI 만 감시
            self.index_codes = codes if codes else ["0001"]

    # ------------------------------------------------------------------
    def start(self) -> bool:
        """백그라운드 쓰레드 기동. 비활성 상태로 시작해도 thread 자체는 살아있음
        → DB 토글로 즉시 활성화 가능."""
        if self._thread and self._thread.is_alive():
            return True
        self._running = True
        self._thread = threading.Thread(
            target=self._loop, daemon=True, name="MarketGuard"
        )
        self._thread.start()
        logger.info(
            "✅ MarketGuard 시작 (enabled=%s, indexes=%s, "
            "5min=-%.1f%%, daily=-%.1f%%, recovery=+%.1f%%, poll=%ds)",
            self.enabled, self.index_codes, self.drop_5min, self.drop_daily,
            self.recovery_pct, self.poll_sec,
        )
        return True

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

    # ------------------------------------------------------------------
    # 외부 API — 전략에서 호출
    # ------------------------------------------------------------------
    def is_panic(self) -> bool:
        """매수 차단 여부. BaseStrategy._scan_and_buy() 가 매 후보마다 호출."""
        with self._lock:
            return self.enabled and self._panic

    def panic_reason(self) -> str:
        """현재 PANIC 사유 (로그용)."""
        with self._lock:
            return self._panic_reason

    def status(self) -> Dict:
        """heartbeat 로그 등에서 노출용 — 현재 상태 요약."""
        with self._lock:
            now = time.time()
            indexes_status = []
            for code in self.index_codes:
                name = self._INDEX_NAMES.get(code, code)
                hist = self._history.get(code)
                prdy = self._prev_close.get(code, 0)
                if not hist or prdy <= 0:
                    indexes_status.append({"name": name, "ready": False})
                    continue
                cur = hist[-1][1]
                daily_pct = (cur - prdy) / prdy * 100.0
                five = self._lookup_price_at(hist, now - 300)
                five_pct = ((cur - five) / five * 100.0) if five else 0.0
                indexes_status.append({
                    "name": name, "ready": True,
                    "daily_pct": round(daily_pct, 2),
                    "five_min_pct": round(five_pct, 2),
                })
            return {
                "enabled": self.enabled,
                "panic": self._panic,
                "reason": self._panic_reason,
                "since_min": round((now - self._panic_since) / 60.0, 1) if self._panic else 0.0,
                "indexes": indexes_status,
            }

    # ------------------------------------------------------------------
    # 메인 루프
    # ------------------------------------------------------------------
    def _loop(self) -> None:
        last_log_ts = 0.0
        while self._running:
            try:
                self._reload_config()

                # 비활성 시 천천히 대기 (DB 폴링 부하 ↓, 토글 즉시 반응)
                if not self.enabled:
                    time.sleep(max(10, self.poll_sec))
                    continue

                # 장중에만 감시 (장외에는 지수 데이터 정적 → 폴링 무의미)
                if not self._is_market_hours():
                    time.sleep(60)
                    continue

                self._tick()

                # 60초에 1번 상태 로그 (정상 동작 확인용)
                now = time.time()
                if now - last_log_ts >= 60:
                    last_log_ts = now
                    self._log_status()

            except Exception as e:
                logger.error("MarketGuard 루프 예외: %s", e)

            time.sleep(self.poll_sec)

    @staticmethod
    def _is_market_hours() -> bool:
        """장 시간 체크 (BaseStrategy.check_market_status 와 동일 정책)."""
        if get_env_bool("FORCE_MARKET_OPEN", False):
            return True
        now = dt.now()
        h, m = now.hour, now.minute
        return (9 <= h < 15) or (h == 15 and m <= 30)

    # ------------------------------------------------------------------
    def _tick(self) -> None:
        """모든 감시 지수 1회 폴링 + PANIC 판정."""
        now = time.time()
        for code in self.index_codes:
            try:
                out = self.client.inquire_index_price(code)
            except Exception as e:
                logger.debug("지수 조회 실패 (%s): %s", code, e)
                continue
            if not out:
                continue

            try:
                cur = float(str(out.get("bstp_nmix_prpr", 0)).replace(",", ""))
                prdy_close = float(
                    str(out.get("bstp_nmix_prdy_clpr", 0)).replace(",", "")
                )
            except (ValueError, AttributeError):
                continue
            if cur <= 0 or prdy_close <= 0:
                continue

            with self._lock:
                # 전일 종가 저장 (일중 누적 판정용 — 매번 갱신 OK, 같은 값)
                self._prev_close[code] = prdy_close

                # 가격 deque 업데이트 (10분 이상 된 데이터 자동 삭제)
                hist = self._history.setdefault(code, deque(maxlen=120))
                hist.append((now, cur))
                cutoff = now - 600
                while hist and hist[0][0] < cutoff:
                    hist.popleft()

                # ── PANIC 판정 ────────────────────────────────
                self._evaluate_panic(code, cur, prdy_close, now)

    def _evaluate_panic(
        self, code: str, cur: float, prdy_close: float, now: float,
    ) -> None:
        """단일 지수 기준 PANIC 진입/해제 판정. _lock 보유 상태 가정."""
        name = self._INDEX_NAMES.get(code, code)

        # 일중 누적 등락률 (전일 종가 대비)
        daily_chg = (cur - prdy_close) / prdy_close * 100.0
        # 5분 누적 등락률 (5분 전 가격 vs 현재가)
        hist = self._history.get(code, deque())
        five_min_ago_price = self._lookup_price_at(hist, now - 300)
        if five_min_ago_price:
            five_min_chg = (cur - five_min_ago_price) / five_min_ago_price * 100.0
        else:
            five_min_chg = 0.0  # 5분치 데이터 아직 부족 (시작 직후)

        if not self._panic:
            # PANIC 진입 조건
            reasons: List[str] = []
            if daily_chg <= -abs(self.drop_daily):
                reasons.append(f"일중 {daily_chg:+.2f}% (한계 -{self.drop_daily:.1f}%)")
            if five_min_chg <= -abs(self.drop_5min):
                reasons.append(f"5분 {five_min_chg:+.2f}% (한계 -{self.drop_5min:.1f}%)")
            if reasons:
                self._panic = True
                self._panic_index = code
                self._panic_reason = f"{name} " + " / ".join(reasons)
                self._panic_since = now
                logger.warning(
                    "🚨 [MarketGuard] PANIC 진입: %s — 모든 전략 신규 매수 차단",
                    self._panic_reason,
                )
        else:
            # PANIC 해제 조건: 트리거된 지수의 5분 누적 +N% 반등
            # (다른 지수 반등은 무시 — 같은 지수가 회복해야 진짜 회복)
            if code != self._panic_index:
                return
            if five_min_chg >= abs(self.recovery_pct):
                duration_min = (now - self._panic_since) / 60.0
                logger.warning(
                    "✅ [MarketGuard] PANIC 해제: %s 5분 %+.2f%% 반등 "
                    "(지속 %.1f분, 매수 재개)",
                    name, five_min_chg, duration_min,
                )
                self._panic = False
                self._panic_reason = ""
                self._panic_index = ""
                self._panic_since = 0.0

    @staticmethod
    def _lookup_price_at(
        hist: Deque[Tuple[float, float]], target_ts: float,
    ) -> Optional[float]:
        """deque 에서 target_ts 이전(≤) 시각 중 가장 최근 가격 반환.
        없으면 None (= 데이터 부족, 5분 비교 스킵)."""
        best = None
        for ts, val in hist:
            if ts <= target_ts:
                best = val
            else:
                break
        return best

    def _log_status(self) -> None:
        """60초에 한 번씩 평상시/PANIC 상태 로그."""
        with self._lock:
            if self._panic:
                duration_min = (time.time() - self._panic_since) / 60.0
                logger.warning(
                    "⛔ [MarketGuard] PANIC 모드 진행 중 — %s (지속 %.1f분)",
                    self._panic_reason, duration_min,
                )
                return

            # 평상시 한 줄 요약 (감시 지수별 일중/5분 등락률)
            parts = []
            now = time.time()
            for code in self.index_codes:
                name = self._INDEX_NAMES.get(code, code)
                hist = self._history.get(code)
                prdy = self._prev_close.get(code, 0)
                if not hist or prdy <= 0:
                    parts.append(f"{name}=대기")
                    continue
                cur = hist[-1][1]
                daily = (cur - prdy) / prdy * 100.0
                five = self._lookup_price_at(hist, now - 300)
                five_chg = ((cur - five) / five * 100.0) if five else 0.0
                parts.append(f"{name} 일중{daily:+.2f}% 5분{five_chg:+.2f}%")
            if parts:
                logger.info("📊 [MarketGuard] 정상 — %s", " | ".join(parts))
