#!/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 (
    flatten_remaining_portfolio_trades,
    min_invest_ratio_of_slot,
    portfolio_exposure_krw,
    target_qty_and_cost,
)
from kis_trader.engine.scalping_engine import (
    _apply_buy_state_filters,
    _eval_scalp_buy_at_index,
    _macd_lines_from_params,
    _slot_key,
    _t2dt,
    _to_bool,
    check_sell_signal_backtest_bar,
    check_sell_signal_live,
    compute_rsi_series,
    effective_tp_pct_from_params,
)
from kis_trader.engine.strategy_eod import (
    eod_bar_time_key,
    is_strategy_eod_bar,
    resolve_strategy_eod_params,
)
from kis_trader.engine.tick_exit_common import (
    backtest_sell_slip_pct,
    backtest_tick_poll_ms,
    collect_minute_ticks,
    resolve_backtest_sell,
    strategy_tick_fallback_ohlc,
    strategy_use_tick_exit,
)
from kis_trader.engine.tail_tick_replay import align_entry_price_from_ticks
from kis_trader.utils.env import get_env_bool


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 _scalp_use_tick_entry(params: Optional[Dict[str, Any]] = None) -> bool:
    """백테 진입: 예약 체결 시 해당 분 첫 틱 가격 (기본 ON — 실매 체결 정합)."""
    if params is not None and params.get("backtest_use_tick_entry") is not None:
        return _to_bool(params.get("backtest_use_tick_entry"), True)
    return get_env_bool("SCALP_BACKTEST_USE_TICK_ENTRY", True)


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",
    ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None,
) -> List[Dict]:
    """
    시각순 포트폴리오 백테스트 — 실매 BaseStrategy 제약 근사.

    - reversal 전용 (모멘텀은 ``momentum_portfolio_backtest``).
    - 매도: 틱 우선 ``resolve_backtest_sell`` → ``check_sell_signal_live``
    """
    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,
            ticks_by_code=ticks_by_code,
        )
    strategy = "SCALP"

    rsi_period = int(params.get("rsi_period", 3))
    min_bars = rsi_period + 5
    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
    use_tick_exit = bool(ticks_by_code) and strategy_use_tick_exit(
        params, "SCALP_BACKTEST_USE_TICK_EXIT", default=True,
    )
    tick_fallback_ohlc = strategy_tick_fallback_ohlc(
        params, "SCALP_BACKTEST_TICK_FALLBACK_OHLC", default=False,
    )
    tick_poll_ms = backtest_tick_poll_ms(params, strategy_env="SCALP_BACKTEST_POLL_MS")
    tick_sell_slip = backtest_sell_slip_pct(params, strategy_env="SCALP_BACKTEST_SELL_SLIP_PCT")
    use_tick_entry = bool(ticks_by_code) and _scalp_use_tick_entry(params)
    tick_exit_count = 0
    ohlc_exit_count = 0
    tick_entry_count = 0

    ctx_by_code: Dict[str, Dict[str, Any]] = {}
    all_times_set = set()
    period_start = str(params.get("_backtest_period_start_key") or "")[:12]
    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:
            ct = str(c.get("candle_time") or "")
            if period_start and ct < period_start:
                continue
            all_times_set.add(ct)

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

    from kis_trader.backtest.backtest_env_timeline import apply_env_timeline_at

    for t in all_times:
        if apply_env_timeline_at(params, t, "SCALP"):
            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)
            invest_cap = _resolve_invest_cap_krw(params, slot_money)
            sl_pct = abs(float(params.get("sl_pct", 0.015)))
            tp_pct = effective_tp_pct_from_params(params)
        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 use_tick_entry and entry_price > 0:
                minute_ticks = collect_minute_ticks(ticks_by_code, code, t)
                aligned, align_src = align_entry_price_from_ticks(minute_ticks, entry_price)
                if aligned > 0:
                    entry_price = float(aligned)
                    if align_src == "ws_ticks":
                        tick_entry_count += 1
                        # 진입가 변경 시 손절·익절 재계산 (비율 동일)
                        pe["stop"] = entry_price * (1 - sl_pct)
                        pe["target"] = entry_price * (1 + tp_pct)
            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: 보유 종목 청산 ──
        # 실매는 벽시계 EOD(15:25) — 해당 분봉이 없는 종목도 직전가로 장마감청산
        is_eod_t = is_strategy_eod_bar(t, params, "SCALP")
        for code in list(portfolio.keys()):
            ctx = ctx_by_code.get(code)
            if ctx is None:
                continue
            pos = portfolio[code]
            entry_t = str(pos.get("entry_time") or "")
            entry_key = entry_t[:12] if entry_t else ""
            t_key = str(t)[:12]
            if entry_key and t_key <= entry_key:
                continue

            idx = ctx["time_index"].get(t)
            candles = ctx["candles"]
            day = t_key[:8] if len(t_key) >= 8 else str(t)[:8]

            if idx is None:
                if not is_eod_t:
                    continue
                # 벽시계 EOD: 이 시각 봉 없음 → 진입 이후 마지막 확정봉 종가
                last = None
                for c in reversed(candles):
                    ct = str(c.get("candle_time") or "")
                    if not ct:
                        continue
                    if entry_key and ct[:12] < entry_key:
                        continue
                    if ct[:12] > t_key:
                        continue
                    last = c
                    break
                if last is None:
                    continue
                exit_price = float(last.get("close") or 0)
                if exit_price <= 0:
                    continue
                _eod_on, eod_hm = resolve_strategy_eod_params(params, "SCALP")
                sell_time = eod_bar_time_key(day, eod_hm, default_hm="15:25") or t_key
                trade = {
                    "code": code,
                    "buy_time": pos["entry_time"],
                    "sell_time": sell_time,
                    "buy_price": pos["entry_price"],
                    "sell_price": round(exit_price, 2),
                    "qty": pos.get("qty", 1),
                    "pnl": 0,
                    "sell_reason": "장마감청산",
                    "hold_min": 0,
                    "exit_source": "wallclock_eod",
                }
                if pos.get("rsi") is not None:
                    try:
                        trade["rsi_entry"] = round(float(pos["rsi"]), 1)
                    except (TypeError, ValueError):
                        pass
                all_trades.append(trade)
                ctx["last_exit_dt"][day] = _t2dt(sell_time)
                ctx["daily_cnt"][day] = ctx["daily_cnt"].get(day, 0) + 1
                del portfolio[code]
                continue

            c = candles[idx]
            hi = float(c["high"])
            lo = float(c["low"])
            cl = float(c["close"])
            op = float(c["open"])

            is_eod = is_eod_t

            cur_c_info = {
                "open": op,
                "high": hi,
                "low": lo,
                "close": cl,
                "candle_time": t,
            }
            if use_tick_exit:
                minute_ticks = collect_minute_ticks(ticks_by_code, code, t)
                res5 = resolve_backtest_sell(
                    pos,
                    cur_c_info,
                    params,
                    is_eod=is_eod,
                    sell_fn=check_sell_signal_live,
                    low_mode="current",
                    ticks=minute_ticks,
                    use_tick_exit=use_tick_exit,
                    tick_fallback_ohlc=tick_fallback_ohlc,
                    poll_ms=tick_poll_ms,
                    slip_pct=tick_sell_slip,
                )
                if not res5:
                    continue
                reason, exit_price, sell_time, _hold_min, exit_src = res5
                if exit_src == "ws_ticks":
                    tick_exit_count += 1
                else:
                    ohlc_exit_count += 1
            else:
                max_p = max(float(pos.get("max_price", 0) or 0), hi)
                pos["max_price"] = max_p
                res = check_sell_signal_backtest_bar(pos, cur_c_info, params, is_eod=is_eod)
                if not res:
                    continue
                reason, exit_price = res
                sell_time = t
                ohlc_exit_count += 1
            # EOD 봉이 15:30만 있어도 사유·시각은 실매 EOD(15:25)에 맞춤
            if reason == "장마감청산":
                eod_on, eod_hm = resolve_strategy_eod_params(params, "SCALP")
                eod_key = eod_bar_time_key(day, eod_hm, default_hm="15:25")
                if eod_key:
                    sell_time = eod_key
            trade: Dict[str, Any] = {
                "code": code,
                "buy_time": pos["entry_time"],
                "sell_time": sell_time or 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(sell_time or 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 "skip_hts_scan_dupes" not in eval_params:
                from kis_trader.engine.scalping_engine import resolve_scalp_skip_hts_scan_dupes
                eval_params["skip_hts_scan_dupes"] = resolve_scalp_skip_hts_scan_dupes()
            state = {
                "daily_cnt": ctx["daily_cnt"].get(day, 0),
                "last_exit_dt": ctx["last_exit_dt"].get(day),
            }

            st = _apply_buy_state_filters(candles, idx, eval_params, state)
            if st[2] is None:
                continue
            from kis_trader.backtest.trigger_snapshot_loader import (
                inject_trigger_snapshots_into_params,
            )
            inject_trigger_snapshots_into_params(
                eval_params,
                orderbook_by_code=params.get("_bt_orderbook_by_code"),
                program_by_code=params.get("_bt_program_by_code"),
                code=code,
                bar_candle_time=str(c.get("candle_time") or t),
            )
            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:
                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

    skip_stats: Dict[str, Any] = {}
    if skipped_micro_buys:
        skip_stats["skipped_micro_buys"] = skipped_micro_buys
    if tick_exit_count or ohlc_exit_count:
        skip_stats["tick_exit_count"] = tick_exit_count
        skip_stats["ohlc_exit_count"] = ohlc_exit_count
    if tick_entry_count:
        skip_stats["tick_entry_count"] = tick_entry_count
    flat_n = flatten_remaining_portfolio_trades(
        portfolio, ctx_by_code, all_trades,
        params=params, strategy=strategy,
    )
    if flat_n:
        skip_stats["bt_flatten_count"] = flat_n
    if skip_stats:
        params["_portfolio_skip_stats"] = skip_stats
    all_trades.sort(key=lambda x: x["sell_time"])
    return all_trades
