"""
kis_trader/strategies/base.py — 전략 공통 기반 클래스
======================================================
각 전략은 **독립 쓰레드**로 돌아간다. 구조:
  start() → 백그라운드 thread 기동 → self._run_loop() (while self._running)
  stop()  → self._running = False + join

루프 안에서 하는 일:
  1. 장 시간 체크 (check_market_status)
  2. 보유 종목(active_trades where strategy=self.strategy_id) 로드 → 매도 시그널 체크
  3. target_candidates 중 본인 정책에 맞는 후보 순회 → 매수 시그널 체크
  4. 시그널 발생 시 OrderManager.place() 로 집중 (실제 주문은 OrderManager 내부에서 종목Lock+ODNO+실잔고검증)
"""
from __future__ import annotations

import random
import threading
import time
from abc import ABC, abstractmethod
from datetime import datetime as dt
from typing import Dict, List, Optional

from ..database.db_manager import TradeDBExt
from ..execution.kis_client import KISClient
from ..execution.order_manager import OrderManager
from ..network.ws_manager import WSManager
from ..utils.env import get_env_bool, get_env_from_db, get_env_int
from ..utils.logger import get_logger


# ETN/ETF/레버리지/인버스/스팩/우선주 자동 필터 키워드
# (KISClient._is_valid_stock_for_rank 와 동기화 — 한곳만 수정해도 양쪽 적용되도록 모듈 상수화)
_NON_STOCK_KEYWORDS = (
    "ETN", "ETF", "레버리지", "인버스", "2X", "3X", "선물",
    "KODEX", "TIGER", "KBSTAR", "ARIRANG", "HANARO", "SOL ",
    "KOSEF", "ACE ", "KINDEX", "RISE ", "PLUS ", "TIMEFOLIO",
    "파워", "히어로", "SMART", "TREX", "WON", "KOACT",
)


def _is_non_stock(name: str, code: str) -> bool:
    """ETN/ETF/스팩/우선주 등 비본주 여부.

    조건검색·랭킹 매니저 양쪽에서 들어오는 후보를 동일 규칙으로 거르기 위해
    BaseStrategy 단계에서 한번 더 차단한다.  False 면 매매 가능 종목.
    """
    if not name or not code:
        # 이름이 비어있으면 보수적으로 통과 (이후 KIS 응답으로 자연 거름)
        return False
    nm = name.upper()
    if any(k in nm for k in _NON_STOCK_KEYWORDS):
        return True
    if "스팩" in name or "SPAC" in nm:
        return True
    if name.endswith("우") or name.endswith("우B") or name.endswith("(전환)"):
        return True
    # KIS 코드 체계: 7로 시작하는 6자리는 ETN (예: 760006)
    code = code.strip()
    if len(code) == 6 and code[0] == "7":
        return True
    return False


class BaseStrategy(ABC, threading.Thread):
    """
    모든 전략의 공통 부모 클래스.  threading.Thread 상속 → start() 시 독립 쓰레드.

    서브클래스 구현 필수:
      - strategy_id (class attribute 또는 property, 고유 문자열)
      - check_buy(code, name) -> Optional[dict]  (매수 시그널 dict)
      - check_sell_signals() -> List[dict]        (매도 시그널 리스트)
      - _candidate_filter(c)  -> bool             (본인이 관심 있는 후보인지)
    """

    strategy_id: str = "BASE"
    loop_min_sleep: float = 1.0
    loop_max_sleep: float = 2.0

    # 전략별 유니버스 소스 기본값 (env 미설정 시)
    #   SCALP, SHORT  → 거래량 랭킹 REST (VolumeRankManager)
    #   BREAKOUT      → HTS 조건검색     (ConditionSearchManager)
    DEFAULT_UNIVERSE_SOURCES: Dict[str, str] = {
        "SCALP":    "ranking",
        "SHORT":    "ranking",
        "BREAKOUT": "condition",
    }

    def __init__(
        self,
        *,
        db: TradeDBExt,
        client: KISClient,
        ws: WSManager,
        order_mgr: OrderManager,
        condition_mgr=None,
        ranking_mgr=None,
        market_guard=None,
    ):
        super().__init__(daemon=True, name=f"Strat-{self.strategy_id}")
        self.db = db
        self.client = client
        self.ws = ws
        self.order_mgr = order_mgr
        self.condition_mgr = condition_mgr  # ConditionSearchManager (선택)
        self.ranking_mgr = ranking_mgr      # VolumeRankManager (선택)
        self.market_guard = market_guard    # MarketGuard (선택, None 이면 가드 없음)
        self.logger = get_logger(f"kis_trader.strategy.{self.strategy_id}")
        # MarketGuard PANIC 차단 로그 스팸 방지용 (분당 1회)
        self._panic_log_ts: float = 0.0

        # 유니버스 소스: "ranking" | "condition"
        # env: {STRATEGY_ID}_UNIVERSE_SOURCE — 런타임에 스위치 가능.
        key = f"{self.strategy_id}_UNIVERSE_SOURCE"
        default = self.DEFAULT_UNIVERSE_SOURCES.get(self.strategy_id, "ranking")
        self.universe_source = (
            (get_env_from_db(key, default) or default).strip().lower()
        )
        if self.universe_source not in ("ranking", "condition"):
            self.logger.warning(
                "알 수 없는 UNIVERSE_SOURCE=%s → 기본값 %s 사용",
                self.universe_source, default,
            )
            self.universe_source = default

        self._running = False
        # 보유 종목 (DB active_trades 로부터 로드 — 전략별 필터)
        self.holdings: Dict[str, dict] = {}
        # 최근 매도 쿨다운 (종목별 마지막 매도 타임스탬프)
        self.recently_sold: Dict[str, float] = {}
        # 당일 매매불가 종목 (다음 후보로 넘어감)
        self.untradable_skip: set = set()

        self._load_holdings_from_db()

    # ------------------------------------------------------------------
    # 외부 인터페이스
    # ------------------------------------------------------------------
    def stop_loop(self) -> None:
        """쓰레드 정지 요청 (daemon 이지만 정상 종료 시 호출)."""
        self._running = False

    def run(self) -> None:
        """threading.Thread.run() 오버라이드 — 전략 메인 루프."""
        self._running = True
        self.logger.info("🚀 전략 쓰레드 시작 [%s]", self.strategy_id)
        try:
            self._run_loop()
        except Exception as e:
            self.logger.exception("전략 루프 예외: %s", e)
        finally:
            self.logger.info("⏹ 전략 쓰레드 종료 [%s]", self.strategy_id)

    # ------------------------------------------------------------------
    # 메인 루프
    # ------------------------------------------------------------------
    def _run_loop(self) -> None:
        last_date = dt.now().strftime("%Y-%m-%d")
        last_closed_log = 0.0
        while self._running:
            try:
                now = dt.now()
                today = now.strftime("%Y-%m-%d")

                # 날짜 변경 처리 (당일 매매불가 리셋 등)
                if today != last_date:
                    last_date = today
                    self.untradable_skip.clear()
                    self.on_new_day()

                # 장 시간 체크 (서브클래스 오버라이드 가능)
                if not self.check_market_status():
                    # 장외 heartbeat: 봇 기동 직후 1회 + 이후 1시간마다 1회.
                    # (봇 살아있음·보유·유니버스 상태만 확인용 — 잦은 로그 노이즈 제거)
                    interval = get_env_int("OFF_HOURS_LOG_INTERVAL_SEC", 3600)
                    if time.time() - last_closed_log >= interval:
                        try:
                            universe = self._load_candidates()
                        except Exception:
                            universe = []
                        self.logger.info(
                            "🌙 [장외] holdings=%d universe=%d recently_sold=%d",
                            len(self.holdings), len(universe), len(self.recently_sold),
                        )
                        last_closed_log = time.time()
                    time.sleep(30)
                    continue

                # 설정 리로드 (DB env_config 실시간 반영)
                self.reload_config()

                # ── [1] 매도 먼저 ────────────────────────────────
                sell_signals = self.check_sell_signals()
                for sig in sell_signals:
                    self._submit_sell(sig)

                # ── [2] 후보 구독 동기화 (공유 WS) ────────────────
                candidates = self._load_candidates()
                codes = [c.get("code") for c in candidates if c.get("code")]
                # 보유 종목도 구독 유지
                codes += list(self.holdings.keys())
                self.ws.sync_targets(self.strategy_id, codes)

                # ── [3] 매수 체크 ────────────────────────────────
                max_stocks = self._max_stocks()
                active_cnt = len(self.holdings)
                if candidates and active_cnt < max_stocks:
                    self._scan_and_buy(candidates, max_stocks, active_cnt)

                time.sleep(random.uniform(self.loop_min_sleep, self.loop_max_sleep))

            except KeyboardInterrupt:
                self._running = False
                break
            except Exception as e:
                self.logger.error("루프 예외: %s", e)
                time.sleep(5)

    def _scan_and_buy(self, candidates: List[Dict], max_stocks: int, active_cnt: int) -> None:
        # ── 시장 급락 서킷브레이커 가드 ────────────────────────────────
        # MarketGuard 가 PANIC 모드면 신규 매수 전면 차단.
        # 매도는 평소처럼 동작 (포지션 정리·손실 확대 방지).
        if self.market_guard is not None and self.market_guard.is_panic():
            now_ts = time.time()
            if now_ts - self._panic_log_ts >= 60:  # 분당 1회만 로그
                self._panic_log_ts = now_ts
                self.logger.warning(
                    "⛔ [매수차단] MarketGuard PANIC: %s (보유 %d, 후보 %d)",
                    self.market_guard.panic_reason(), active_cnt, len(candidates),
                )
            return

        self.logger.info(
            "🔍 [매수체크] 후보 %d (보유 %d/%d)",
            len(candidates), active_cnt, max_stocks,
        )
        for c in candidates:
            if not self._running:
                return
            code = c.get("code") or c.get("stk_cd", "")
            name = c.get("name") or c.get("stk_nm", code)
            if not code or code in self.holdings:
                continue
            if code in self.untradable_skip:
                continue
            if not self._candidate_filter(c):
                continue
            # 재진입 쿨다운
            cooldown_sec = self._reentry_cooldown_sec()
            elapsed = time.time() - self.recently_sold.get(code, 0)
            if elapsed < cooldown_sec:
                continue

            signal = self.check_buy(code, name)
            if not signal:
                time.sleep(random.uniform(0.2, 0.5))
                continue

            result = self._submit_buy(signal)
            if result and result.success:
                time.sleep(random.uniform(1.0, 2.0))
                return  # 1루프당 1매수 (포지션 과집중 방지)
            time.sleep(random.uniform(0.3, 0.8))

    # ------------------------------------------------------------------
    # OrderManager 호출 래퍼
    # ------------------------------------------------------------------
    def _submit_buy(self, signal: Dict):
        from ..execution.order_manager import OrderRequest

        req = OrderRequest(
            strategy_id=self.strategy_id,
            code=signal["code"],
            name=signal.get("name", signal["code"]),
            side="BUY",
            qty=int(signal.get("qty", 0)),
            price_ref=float(signal.get("price", 0)),
            stop_price=float(signal.get("stop_price", 0)),
            target_price=float(signal.get("target_price", 0)),
            atr_entry=float(signal.get("atr_entry", 0)),
            size_class=signal.get("size_class"),
            entry_features=signal.get("entry_features"),
        )
        result = self.order_mgr.place(req)
        if result.success:
            # 로컬 holdings 갱신 (DB 는 OrderManager 가 이미 upsert 함)
            self.holdings[req.code] = {
                "buy_price": result.filled_avg_price,
                "qty": result.filled_qty,
                "stop_price": req.stop_price,
                "target_price": req.target_price,
                "max_price": result.filled_avg_price,
                "atr_entry": req.atr_entry,
                "buy_time": dt.now().strftime("%Y-%m-%d %H:%M:%S"),
                "name": req.name,
                "size_class": req.size_class or "",
            }
        else:
            # 매매불가 종목은 당일 제외
            if result.reason and "order_reject" in result.reason:
                if any(k in (result.reason or "") for k in ("매매불가", "40070000")):
                    self.untradable_skip.add(req.code)
        return result

    def _submit_sell(self, signal: Dict):
        from ..execution.order_manager import OrderRequest

        req = OrderRequest(
            strategy_id=self.strategy_id,
            code=signal["code"],
            name=signal.get("name", signal["code"]),
            side="SELL",
            qty=int(signal.get("qty", 0)),
            price_ref=float(signal.get("current_price", signal.get("price", 0))),
            reason=signal.get("reason", ""),
            buy_price=float(signal.get("buy_price", 0)),
            profit_pct=float(signal.get("profit_pct", 0)),
        )
        result = self.order_mgr.place(req)
        if result.success:
            self.recently_sold[req.code] = time.time()
            self.holdings.pop(req.code, None)
        return result

    # ------------------------------------------------------------------
    # 서브클래스 공통 헬퍼
    # ------------------------------------------------------------------
    def check_market_status(self) -> bool:
        """전략별 매매 허용 시간대 체크.

        DB env 우선순위 (HHMM 정수 — 930=09:30, 1500=15:00):
            1) ``{STRATEGY_ID}_TIME_START`` / ``{STRATEGY_ID}_TIME_END``
                예: SCALP_TIME_START=900, SCALP_TIME_END=1530
            2) 미설정 시 글로벌 ``TIME_START`` / ``TIME_END``
            3) 글로벌도 미설정 시 기본 09:00~15:30

        ``FORCE_MARKET_OPEN=true`` 면 모든 시간 통과 (백테스트/디버그용).
        """
        if get_env_bool("FORCE_MARKET_OPEN", False):
            return True
        now = dt.now()
        if now.weekday() >= 5:  # 토/일
            return False
        hhmm_now = now.hour * 100 + now.minute

        sid = self.strategy_id.upper()
        start = get_env_int(f"{sid}_TIME_START", 0) or get_env_int("TIME_START", 0) or 900
        end = get_env_int(f"{sid}_TIME_END", 0) or get_env_int("TIME_END", 0) or 1530

        return start <= hhmm_now <= end

    def _max_stocks(self) -> int:
        """전략별 동시 보유 한도.

        우선순위:
          1. ``{STRATEGY_ID}_MAX_STOCKS`` (예: ``SCALP_MAX_STOCKS``)
          2. ``MAX_STOCKS`` (글로벌, 미설정/구버전 호환)
          3. 3 (최후 fallback)
        """
        sid = self.strategy_id.upper()
        per_strategy = get_env_int(f"{sid}_MAX_STOCKS", 0)
        if per_strategy > 0:
            return per_strategy
        return get_env_int("MAX_STOCKS", 3)

    def _reentry_cooldown_sec(self) -> int:
        return get_env_int("REENTRY_COOLDOWN_SEC", 300)

    def _load_holdings_from_db(self) -> None:
        """DB active_trades 에서 본 전략 소유 포지션 로드.

        ETN/ETF/스팩 등 비본주는 KIS·키움 API에서 가격 조회 자체가 막혀 매분
        ``[매도-가격없음]`` 로그를 무한 반복하므로 holdings 에서 자동 제외한다.
        (사용자가 한투 HTS 에서 직접 처분 — 봇은 매수/매도 시도 없음.)
        """
        try:
            prefix = self.strategy_id.split("_")[0] if "_" in self.strategy_id else self.strategy_id
            rows = self.db.get_active_trades(strategy_prefix=prefix)
            skipped_non_stock: list[str] = []
            for code, t in rows.items():
                # 같은 prefix 라도 정확한 strategy 매칭만 가져감 (SCALP* 과 SHORT* 충돌 방지)
                if t.get("strategy") and t["strategy"] != self.strategy_id:
                    continue
                if get_env_bool("EXCLUDE_NON_STOCK", True):
                    name = (t.get("name") or "").strip()
                    if _is_non_stock(name, code):
                        skipped_non_stock.append(f"{code}({name})")
                        continue
                self.holdings[code] = {
                    "buy_price": t.get("avg_buy_price", 0),
                    "qty": t.get("current_qty", 0),
                    "stop_price": t.get("stop_price", 0),
                    "target_price": t.get("target_price", 0),
                    "max_price": t.get("max_price", 0),
                    "atr_entry": t.get("atr_at_entry", t.get("atr_entry", 0)),
                    "buy_time": t.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")),
                    "name": t.get("name", code),
                    "size_class": t.get("size_class", ""),
                }
            if self.holdings:
                self.logger.info("📂 [DB 복원] 보유 %d종목 (%s)",
                                 len(self.holdings), self.strategy_id)
            if skipped_non_stock:
                self.logger.warning(
                    "⚠️ ETN/ETF 보유 자동 제외(매수/매도 모두 봇이 안 건드림 — 한투 HTS에서 직접 처분 권장): %s",
                    ", ".join(skipped_non_stock),
                )
        except Exception as e:
            self.logger.warning("DB holdings 로드 실패: %s", e)

    def _load_candidates(self) -> List[Dict]:
        """
        후보 로드 우선순위:
          1) universe_source == "ranking"   → VolumeRankManager
          2) universe_source == "condition" → ConditionSearchManager
          3) 선택된 매니저에 설정 없으면 → 반대편 매니저로 폴백
          4) 둘 다 없으면 → DB target_candidates (레거시 경로)

        ⚡ 런타임 스위치 지원:
          매 호출마다 DB env_config 의 {SID}_UNIVERSE_SOURCE 를 재조회하여
          재시작 없이 ranking ↔ condition 전환이 가능하다.
          (단, 전환 대상 매니저에 본 전략이 '시작 시' 등록돼 있어야 함 —
           조건식 NAME/SEQ 자체 변경은 여전히 재시작 필요)
        """
        key = f"{self.strategy_id}_UNIVERSE_SOURCE"
        default = self.DEFAULT_UNIVERSE_SOURCES.get(self.strategy_id, "ranking")
        current_src = (get_env_from_db(key, default) or default).strip().lower()
        if current_src not in ("ranking", "condition"):
            current_src = default
        if current_src != self.universe_source:
            self.logger.info(
                "🔄 [유니버스 소스 전환] %s: %s → %s (재시작 없이 즉시 반영)",
                self.strategy_id, self.universe_source, current_src,
            )
            self.universe_source = current_src

        primary = self.ranking_mgr if self.universe_source == "ranking" else self.condition_mgr
        secondary = self.condition_mgr if self.universe_source == "ranking" else self.ranking_mgr

        candidates: List[Dict] = []
        for mgr, tag in ((primary, self.universe_source),
                         (secondary, "condition" if self.universe_source == "ranking" else "ranking")):
            if mgr is None:
                continue
            if not self._is_strategy_registered(mgr):
                continue
            try:
                universe = mgr.get_universe_for(self.strategy_id)
                # 매니저에 등록은 돼 있으나 아직 비어있음 → [] 이 정답 (폴백 금지).
                # (설정된 유니버스가 비어있을 수 있는 상태: 장 마감 후 등)
                if universe:
                    candidates = mgr.get_candidates_for(self.strategy_id) or []
                else:
                    candidates = []
                return self._post_filter_candidates(candidates)
            except Exception as e:
                self.logger.debug("%s 유니버스 로드 실패: %s", tag, e)

        # 두 매니저 모두 본 전략 설정 없음 → 레거시 DB 경로
        try:
            candidates = self.db.get_target_candidates() or []
        except Exception as e:
            self.logger.debug("target_candidates 조회 실패: %s", e)
            candidates = []
        return self._post_filter_candidates(candidates)

    def _post_filter_candidates(self, candidates: List[Dict]) -> List[Dict]:
        """매니저 결과 → ETN/비본주 자동 제외 + 전략별 후보 하드캡.

        - ``EXCLUDE_NON_STOCK=true`` (기본 true): ETN/ETF/스팩/우선주 등 비본주 자동 제외.
          KISClient.filter_rank_rows 와 동일 규칙 (`_is_non_stock`).
        - ``{STRATEGY_ID}_CAND_LIMIT`` (기본 0=무제한): 매니저 원본 순서 보존하며 상위 N개만 사용.
          WS 구독 41 한도 안전 + cond/ranking 폭주 시 매수 체크 분당 회전율 보장.
        """
        if not candidates:
            return []

        # 1) 비본주 필터
        if get_env_bool("EXCLUDE_NON_STOCK", True):
            filtered: List[Dict] = []
            dropped = 0
            for c in candidates:
                code = (c.get("code") or c.get("stk_cd") or "").strip()
                name = (c.get("name") or c.get("stk_nm") or "").strip()
                if _is_non_stock(name, code):
                    dropped += 1
                    continue
                filtered.append(c)
            if dropped:
                self.logger.debug("🛡 비본주 자동 제외 %d종목 (ETN/ETF/스팩/우선주)", dropped)
            candidates = filtered

        # 2) 전략별 후보 하드캡
        sid = self.strategy_id.upper()
        cap = get_env_int(f"{sid}_CAND_LIMIT", 0)
        if cap > 0 and len(candidates) > cap:
            self.logger.debug(
                "✂ 후보 하드캡 적용: %d → %d (%s_CAND_LIMIT=%d)",
                len(candidates), cap, sid, cap,
            )
            candidates = candidates[:cap]

        return candidates

    def _is_strategy_registered(self, mgr) -> bool:
        """매니저의 _configs 에 본 전략이 들어있는지."""
        if mgr is None:
            return False
        try:
            cfgs = getattr(mgr, "_configs", [])
            return any(c.get("strategy_id") == self.strategy_id for c in cfgs)
        except Exception:
            return False

    def on_new_day(self) -> None:
        """날짜 변경 훅 (서브클래스 오버라이드 가능)."""
        pass

    def reload_config(self) -> None:
        """설정 리로드 훅 (서브클래스 오버라이드)."""
        pass

    # ------------------------------------------------------------------
    # 서브클래스 구현 필수 메서드
    # ------------------------------------------------------------------
    @abstractmethod
    def check_buy(self, code: str, name: str) -> Optional[Dict]:
        """매수 시그널. 성공 시 dict(code/name/price/qty/stop_price/...)."""
        raise NotImplementedError

    @abstractmethod
    def check_sell_signals(self) -> List[Dict]:
        """보유 종목 순회 → 매도 시그널 리스트."""
        raise NotImplementedError

    def _candidate_filter(self, candidate: Dict) -> bool:
        """후보 중 본 전략이 관심 있는 것만 True. 기본 True."""
        return True
