#!/usr/bin/env python3
"""
SCALP reversal 시각순 포트폴리오 백테스트 — tail_engine.run_tail_backtest_portfolio 와 동일 구조.

모멘텀(MOMENTUM)은 ``momentum_portfolio_backtest.run_momentum_backtest_portfolio`` 로 위임.
"""
from __future__ import annotations

from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple

from kis_trader.backtest.backtest_portfolio_common import (
    min_invest_ratio_of_slot,
    portfolio_exposure_krw,
    target_qty_and_cost,
)
from kis_trader.engine.scalping_engine import (
    _apply_buy_state_filters,
    _eval_momentum_buy_at_index,
    _eval_scalp_buy_at_index,
    _macd_lines_from_params,
    _slot_key,
    _t2dt,
    _to_bool,
    check_sell_signal_backtest_bar,
    compute_rsi_series,
    effective_tp_pct_from_params,
)


def _buy_priority_key(
    code: str,
    slot_key: str,
    universe_by_slot: Optional[Dict[str, List[str]]],
) -> Tuple[int, str]:
    if universe_by_slot is None:
        return (0, code)
    lst = universe_by_slot.get(slot_key) or []
    try:
        return (lst.index(code), code)
    except ValueError:
        return (999999, code)


def _max_stocks_from_params(params: Dict[str, Any]) -> int:
    for key in ("max_stocks", "scalp_max_stocks", "short_max_stocks"):
        v = params.get(key)
        if v not in (None, "", 0):
            return max(1, int(v))
    try:
        from kis_trader.utils.env import get_env_int
        n = get_env_int("SCALP_MAX_STOCKS", 0) or get_env_int("MAX_STOCKS", 3)
        return max(1, int(n))
    except Exception:
        return 3


def _total_budget_from_params(params: Dict[str, Any]) -> float:
    for key in ("total_budget_krw", "scalp_total_budget_krw", "short_total_budget_krw"):
        v = params.get(key)
        if v not in (None, ""):
            try:
                return float(v)
            except (TypeError, ValueError):
                pass
    try:
        from kis_trader.utils.env import get_env_int
        cap = get_env_int("SCALP_TOTAL_BUDGET_KRW", 0)
        if cap > 0:
            return float(cap)
    except Exception:
        pass
    return 0.0


def _resolve_invest_cap_krw(params: Dict[str, Any], slot_money: float) -> float:
    """legacy loop 와 동일 — max_loss/sl_pct 로 1회 투입 상한."""
    sl_pct = abs(float(params.get("sl_pct", 0.015)))
    max_loss_krw = float(params.get("max_loss_krw", 200000.0))
    invest_amount = float(slot_money)
    if max_loss_krw > 0 and sl_pct > 0:
        invest_limit = max_loss_krw / sl_pct
        invest_amount = min(invest_limit, float(slot_money))
    return invest_amount


def run_scalping_backtest_portfolio(
    codes_candles: Dict[str, List[Dict]],
    params: Dict[str, Any],
    universe_by_slot: Optional[Dict[str, List[str]]] = None,
    mode: str = "reversal",
) -> List[Dict]:
    """
    시각순 포트폴리오 백테스트 — 실매 BaseStrategy 제약 근사.

    - ``mode='reversal'``: ``_eval_scalp_buy_at_index``
    - ``mode='momentum'``: ``_eval_momentum_buy_at_index``
    """
    mode = str(mode or "reversal").strip().lower()
    if mode == "momentum":
        from kis_trader.backtest.momentum_portfolio_backtest import run_momentum_backtest_portfolio
        return run_momentum_backtest_portfolio(
            codes_candles, params, universe_by_slot=universe_by_slot,
        )
    strategy = "SCALP"

    rsi_period = int(params.get("rsi_period", 3))
    min_bars = max(rsi_period + 5, 6) if mode == "momentum" else rsi_period + 5
    force_eod_exit = _to_bool(params.get("force_eod_exit"), False)
    sl_pct = abs(float(params.get("sl_pct", 0.015)))
    tp_pct = effective_tp_pct_from_params(params)
    max_stocks = _max_stocks_from_params(params)
    slot_money = float(params.get("slot_money", 300_000))
    total_budget = _total_budget_from_params(params)
    if total_budget <= 0:
        total_budget = float(max_stocks * slot_money)
    min_invest_ratio = min_invest_ratio_of_slot(params, strategy=strategy)
    invest_cap = _resolve_invest_cap_krw(params, slot_money)
    use_macd_cross = _to_bool(params.get("use_macd_cross", False), False)

    skipped_micro_buys = 0

    ctx_by_code: Dict[str, Dict[str, Any]] = {}
    all_times_set = set()
    for code, raw_rows in codes_candles.items():
        if len(raw_rows) < min_bars:
            continue
        candles = [dict(r) for r in raw_rows]
        macd_combined = (
            _macd_lines_from_params(candles, params) if use_macd_cross else None
        )
        ctx_by_code[code] = {
            "code": code,
            "candles": candles,
            "macd_combined": macd_combined,
            "time_index": {c["candle_time"]: idx for idx, c in enumerate(candles)},
            "last_exit_dt": {},
            "daily_cnt": {},
            "pending_entry": None,
        }
        for c in candles:
            all_times_set.add(c["candle_time"])

    all_times = sorted(all_times_set)
    portfolio: Dict[str, Dict[str, Any]] = {}
    all_trades: List[Dict] = []

    for t in all_times:
        slot_key = _slot_key(t, params.get("scan_interval_min", 1))

        # ── Phase 0: 예약 진입 (직전 봉 신호 → 이번 봉 시가) ──
        pending_codes = [
            code for code, ctx in ctx_by_code.items()
            if ctx.get("pending_entry") and ctx["pending_entry"].get("entry_time") == t
        ]
        pending_codes.sort(key=lambda c: _buy_priority_key(c, slot_key, universe_by_slot))
        for code in pending_codes:
            ctx = ctx_by_code[code]
            pe = ctx.pop("pending_entry", None)
            if not pe or code in portfolio:
                continue
            if len(portfolio) >= max_stocks:
                break
            entry_price = float(pe["entry_price"])
            if entry_price <= 0:
                continue
            exposure = portfolio_exposure_krw(portfolio)
            remaining = max(0.0, total_budget - exposure)
            target_qty, target_cost = target_qty_and_cost(entry_price, invest_cap)
            min_required = target_cost * min_invest_ratio
            if target_qty < 1 or remaining < min_required:
                skipped_micro_buys += 1
                continue
            invest = min(invest_cap, remaining, target_cost)
            qty = int(invest / entry_price)
            if qty < 1:
                skipped_micro_buys += 1
                continue
            cost = qty * entry_price
            if cost < min_required:
                skipped_micro_buys += 1
                continue
            if exposure + cost > total_budget + 1e-6:
                skipped_micro_buys += 1
                continue
            portfolio[code] = {
                "entry_price": entry_price,
                "entry_time": t,
                "qty": qty,
                "stop": pe["stop"],
                "target": pe["target"],
                "max_price": entry_price,
                "rsi": pe.get("rsi"),
            }
            break  # 1시각 1매수

        # ── Phase 1: 보유 종목 청산 ──
        for code in list(portfolio.keys()):
            ctx = ctx_by_code.get(code)
            if ctx is None:
                continue
            idx = ctx["time_index"].get(t)
            if idx is None:
                continue
            candles = ctx["candles"]
            c = candles[idx]
            day = t[:8]
            hi = float(c["high"])
            lo = float(c["low"])
            cl = float(c["close"])
            op = float(c["open"])

            is_eod_raw = (idx == len(candles) - 1) or (candles[idx + 1]["candle_time"][:8] != day)
            is_eod = is_eod_raw and force_eod_exit

            pos = portfolio[code]
            if t == pos["entry_time"]:
                continue

            max_p = max(float(pos.get("max_price", 0) or 0), hi)
            pos["max_price"] = max_p

            cur_c_info = {
                "open": op,
                "high": hi,
                "low": lo,
                "close": cl,
                "candle_time": t,
            }
            res = check_sell_signal_backtest_bar(pos, cur_c_info, params, is_eod=is_eod)
            if not res:
                continue
            reason, exit_price = res
            trade: Dict[str, Any] = {
                "code": code,
                "buy_time": pos["entry_time"],
                "sell_time": t,
                "buy_price": pos["entry_price"],
                "sell_price": round(exit_price, 2),
                "qty": pos.get("qty", 1),
                "pnl": 0,
                "sell_reason": reason,
                "hold_min": 0,
            }
            if pos.get("rsi") is not None:
                trade["rsi_entry"] = round(float(pos["rsi"]), 1)
            all_trades.append(trade)
            ctx["last_exit_dt"][day] = _t2dt(t)
            ctx["daily_cnt"][day] = ctx["daily_cnt"].get(day, 0) + 1
            del portfolio[code]

        # ── Phase 2: 신규 매수 신호 (다음 봉 시가 진입 예약) ──
        if len(portfolio) >= max_stocks:
            continue
        exposure = portfolio_exposure_krw(portfolio)
        if exposure >= total_budget - 1e-6:
            continue

        candidates: List[Tuple[Tuple[int, str], str, Dict[str, Any]]] = []
        for code, ctx in ctx_by_code.items():
            if code in portfolio or ctx.get("pending_entry"):
                continue
            idx = ctx["time_index"].get(t)
            if idx is None:
                continue
            candles = ctx["candles"]
            c = candles[idx]
            day = t[:8]
            cl = float(c["close"])

            if universe_by_slot is not None:
                if code not in universe_by_slot.get(slot_key, []):
                    continue
            if cl <= 0:
                continue

            eval_params = dict(params)
            if universe_by_slot is not None:
                eval_params.setdefault("skip_hts_scan_dupes", True)
            else:
                eval_params.setdefault("skip_hts_scan_dupes", False)
            state = {
                "daily_cnt": ctx["daily_cnt"].get(day, 0),
                "last_exit_dt": ctx["last_exit_dt"].get(day),
            }

            if mode == "momentum":
                if idx < 5:
                    continue
                reject, _msg, sig = _eval_momentum_buy_at_index(
                    candles, idx, eval_params, state,
                )
            else:
                st = _apply_buy_state_filters(candles, idx, eval_params, state)
                if st[2] is None:
                    continue
                reject, _msg, sig = _eval_scalp_buy_at_index(
                    candles, idx, eval_params, macd_combined=ctx.get("macd_combined"),
                )

            if reject or not sig:
                continue
            rsi = sig.get("rsi")
            if rsi is None and mode == "reversal":
                continue

            if idx + 1 >= len(candles):
                continue
            next_c = candles[idx + 1]
            if next_c["candle_time"][:8] != day:
                continue
            entry_price = float(next_c["open"])
            if entry_price <= 0:
                continue

            stop = entry_price * (1 - sl_pct)
            target = entry_price * (1 + tp_pct)
            pri = _buy_priority_key(code, slot_key, universe_by_slot)
            pe_data: Dict[str, Any] = {
                "entry_time": next_c["candle_time"],
                "entry_price": entry_price,
                "stop": stop,
                "target": target,
            }
            if rsi is not None:
                pe_data["rsi"] = rsi
            candidates.append((pri, code, pe_data))

        if not candidates:
            continue
        candidates.sort(key=lambda x: x[0])
        _pri, pick_code, pe = candidates[0]
        ctx_by_code[pick_code]["pending_entry"] = pe

    if skipped_micro_buys:
        params["_portfolio_skip_stats"] = {"skipped_micro_buys": skipped_micro_buys}
    all_trades.sort(key=lambda x: x["sell_time"])
    return all_trades
