"""TGTC single-day backtest simulator.

Uses 5-min bars from IntradayCache and a synthetic gainer reconstruction
(no Yahoo API) to simulate the TGTC VWAP pullback reclaim strategy on
any historical trading day.
"""
from __future__ import annotations

import datetime as dt
import logging
import math
from dataclasses import dataclass, field
from typing import Any

log = logging.getLogger(__name__)

from zoneinfo import ZoneInfo as _ZoneInfo

_ET_ZONE = _ZoneInfo("America/New_York")


def _et_to_utc_naive(date: dt.date, hour: int, minute: int) -> dt.datetime:
    """Convert an ET time on a given date to naive UTC (DST-aware)."""
    et_aware = dt.datetime(date.year, date.month, date.day, hour, minute,
                           tzinfo=_ET_ZONE)
    return et_aware.astimezone(dt.timezone.utc).replace(tzinfo=None)


def _bar_idx_at(bars: list[dict], cutoff_naive_utc: dt.datetime) -> int:
    """Return index of the last bar whose timestamp <= cutoff (or -1)."""
    from libs.tgtc.gainers_reconstruct import _bar_ts_naive_utc
    last = -1
    for i, b in enumerate(bars):
        if _bar_ts_naive_utc(b) <= cutoff_naive_utc:
            last = i
        else:
            break
    return last


@dataclass
class TGTCTrade:
    symbol: str
    entry_price: float
    stop_price: float
    shares: int
    entry_bar_idx: int
    exit_price: float = 0.0
    exit_bar_idx: int = -1
    exit_reason: str = ""
    pnl: float = 0.0
    r_multiple: float = 0.0
    partial_taken: bool = False
    peak_price: float = 0.0
    current_stop: float = 0.0
    be_stop_active: bool = False
    status: str = "open"   # open | closed
    entry_dt_utc: dt.datetime | None = None    # set at entry for time-stop calculations
    partial_levels_taken: set = field(default_factory=set)  # tracks which partial_levels fired
    tp_collision: bool = False  # True if quick_tp fired AND stop was also touched on the same bar
    side: str = "long"          # "long" or "short"
    # Candidate metadata (attached at entry for segmentation analysis)
    score: float = 0.0
    rank_persistence: float = 0.0
    rank_velocity: float = 0.0
    price_structure: float = 0.0
    volume_quality: float = 0.0
    relative_strength: float = 0.0
    pct_change_at_10: float = 0.0
    dollar_volume_20d: float = 0.0


@dataclass
class SimResult:
    date: str
    trades: list[TGTCTrade] = field(default_factory=list)
    candidates: list[dict[str, Any]] = field(default_factory=list)
    equity_curve: list[dict[str, Any]] = field(default_factory=list)
    initial_equity: float = 10000.0
    final_equity: float = 10000.0

    @property
    def total_pnl(self) -> float:
        return sum(t.pnl for t in self.trades if t.status == "closed")

    @property
    def total_return_pct(self) -> float:
        return self.total_pnl / self.initial_equity if self.initial_equity > 0 else 0.0

    @property
    def win_rate(self) -> float:
        closed = [t for t in self.trades if t.status == "closed"]
        if not closed:
            return 0.0
        return sum(1 for t in closed if t.pnl > 0) / len(closed)

    @property
    def n_trades(self) -> int:
        return len([t for t in self.trades if t.status == "closed"])


def run_tgtc_simulation(
    date: dt.date,
    bars_by_symbol: dict[str, list[dict]],
    prev_closes: dict[str, float],
    enrichment: dict[str, dict],  # {symbol: {"atr_14": ..., "avg_dollar_vol_30d": ...}}
    qqq_pct_change_at_10: float | None = None,
    cfg: Any = None,  # TGTCConfig
) -> SimResult:
    """Run a single-day TGTC simulation.

    Args:
        date:              Trading date.
        bars_by_symbol:    {symbol: [5m bar dicts]} filtered to this date's market hours.
        prev_closes:       {symbol: float} prior-day close.
        enrichment:        {symbol: {atr_14, avg_dollar_vol_30d, ...}} from enrich_daily_bars.
        qqq_pct_change_at_10: QQQ percent change at 10:00 ET (for RS score).
        cfg:               TGTCConfig instance.

    Returns:
        SimResult with trade list, equity curve, candidates.
    """
    raise NotImplementedError(
        "V1 simulator is deprecated in the TGTC V2 transition. "
        "V2 candidate selection and labeling is in scripts/build_tgtc_v2_top_gainer_events.py. "
        "V1 backtest results are archived in docs/tgtc_v1_development_report.md."
    )
    # ── code below is V1 reference (not executed) ──────────────────────────────
    from libs.tgtc.domain import TGTCConfig
    from libs.tgtc.gainers_reconstruct import reconstruct_gainer_snapshots, _bar_ts_naive_utc
    from libs.tgtc.v2_features import compute_rank_features

    if cfg is None:
        cfg = TGTCConfig()

    params = cfg.tgtc_strategy
    flt = params.filters
    sw = params.score_weights
    ent = params.entry
    ex = params.exit
    rsk = params.risk

    date_str = date.isoformat()
    result = SimResult(date=date_str, initial_equity=rsk.initial_equity)
    equity = rsk.initial_equity
    daily_loss = 0.0

    # QQQ regime gate
    if flt.min_qqq_pct_change_at_10 is not None and qqq_pct_change_at_10 is not None:
        if qqq_pct_change_at_10 < flt.min_qqq_pct_change_at_10:
            log.debug("TGTC %s: skip — QQQ %.2f%% < floor %.2f%%",
                      date_str, qqq_pct_change_at_10*100, flt.min_qqq_pct_change_at_10*100)
            result.final_equity = equity
            return result

    # ── Step 1: Synthetic gainer snapshots (09:30–09:55) ──────────────────────
    snapshots = reconstruct_gainer_snapshots(
        bars_by_symbol=bars_by_symbol,
        prev_closes=prev_closes,
        date_str=date_str,
        min_pct_change=0.03,
        top_n=100,
    )
    result.candidates = []  # will be filled after candidate selection

    if not snapshots:
        log.debug("TGTC %s: no synthetic snapshots (empty universe?)", date_str)
        result.final_equity = equity
        return result

    # ── Step 2: Rank features from collected snapshots ────────────────────────
    rank_features = compute_rank_features(snapshots)

    # ── Step 3: At 10:00 ET, select candidates ─────────────────────────────────
    cutoff_10 = _et_to_utc_naive(date, 10, 0)

    candidates_scored: list[dict[str, Any]] = []
    for sym, rf in rank_features.items():
        bars = bars_by_symbol.get(sym, [])
        if not bars:
            continue

        bar_idx_10 = _bar_idx_at(bars, cutoff_10)
        if bar_idx_10 < 0:
            continue

        bar_10 = bars[bar_idx_10]
        prev_close = prev_closes.get(sym, 0.0)
        if prev_close <= 0:
            continue

        price_at_10 = float(bar_10["close"])
        pct_change_at_10 = (price_at_10 - prev_close) / prev_close

        # Hard filters
        if price_at_10 < flt.min_price:
            continue
        if pct_change_at_10 < flt.min_day_change_at_10:
            continue
        if pct_change_at_10 > flt.max_day_change_at_10:
            continue

        # Dollar volume surrogate for market_cap filter
        enr = enrichment.get(sym, {})
        avg_dv = enr.get("avg_dollar_vol_30d") or enr.get("avg_dollar_vol_20d")
        if avg_dv and avg_dv < flt.min_avg_dollar_volume_20d:
            continue

        # VWAP filter
        vwap_at_10 = get_bar_vwap(bars, bar_idx_10)
        if flt.must_be_above_vwap and (not vwap_at_10 or price_at_10 < vwap_at_10):
            continue

        # HOD pullback filter
        local_highs = [float(b["high"]) for b in bars[:bar_idx_10 + 1]]
        hod = max(local_highs) if local_highs else price_at_10
        if hod > 0 and (hod - price_at_10) / hod > flt.max_pullback_from_hod:
            continue

        # Scores
        ps = compute_price_structure_score(bars, bar_idx_10)
        vq = compute_volume_quality(bars, bar_idx_10, avg_dv)
        rs = compute_relative_strength(pct_change_at_10, qqq_pct_change_at_10)

        tgtc_score = compute_tgtc_score(
            rank_persistence=rf["rank_persistence"],
            rank_velocity=max(0.0, rf["rank_velocity"]),
            price_structure=ps,
            volume_quality=vq,
            relative_strength=rs,
            weights=sw,
        )

        atr_intraday = enr.get("atr_14")  # use daily ATR as proxy for intraday

        candidates_scored.append({
            "symbol": sym,
            "score": tgtc_score,
            "rank_persistence": rf["rank_persistence"],
            "rank_velocity": rf["rank_velocity"],
            "price_structure": ps,
            "volume_quality": vq,
            "relative_strength": rs,
            "pct_change_at_10": pct_change_at_10,
            "price_at_10": price_at_10,
            "vwap_at_10": vwap_at_10,
            "atr_intraday": atr_intraday,
            "avg_dv": avg_dv,
            "bar_idx_10": bar_idx_10,
        })

    candidates_scored.sort(key=lambda c: c["score"], reverse=True)
    result.candidates = candidates_scored

    # ── Step 4: Entry scan (10:00–15:30), up to max_positions ─────────────────
    open_positions: list[TGTCTrade] = []
    closed_positions: list[TGTCTrade] = []

    # Define stop-check bar times: every 5 minutes from 10:00 to 15:50
    stop_bars_utc: list[dt.datetime] = []
    cur = dt.datetime(date.year, date.month, date.day, 10, 0) + dt.timedelta(hours=_ET_OFFSET)
    eod_utc = dt.datetime(date.year, date.month, date.day, 15, 55) + dt.timedelta(hours=_ET_OFFSET)
    while cur <= eod_utc:
        stop_bars_utc.append(cur)
        cur += dt.timedelta(minutes=5)

    entry_cutoff_utc = _et_to_utc_naive(date, 15, 30)
    # D3: time-of-day entry filter — cap entry cutoff if no_entry_after_et is set
    if ent.no_entry_after_et:
        h, m = ent.no_entry_after_et.split(":")
        tod_cutoff = _et_to_utc_naive(date, int(h), int(m))
        entry_cutoff_utc = min(entry_cutoff_utc, tod_cutoff)

    limit = ent.max_candidates_to_scan if ent.max_candidates_to_scan is not None else rsk.max_positions * 3
    candidates_to_scan = candidates_scored[:limit]

    equity_snapshots: list[dict[str, Any]] = []

    for bar_dt_utc in stop_bars_utc:
        is_eod = bar_dt_utc >= eod_utc

        # EOD: close all
        if is_eod:
            for pos in list(open_positions):
                sym = pos.symbol
                bars = bars_by_symbol.get(sym, [])
                eod_idx = _bar_idx_at(bars, bar_dt_utc)
                exit_price = float(bars[eod_idx]["close"]) if eod_idx >= 0 else pos.entry_price
                is_short_eod = pos.side == "short"
                if is_short_eod:
                    risk_per_share = pos.stop_price - pos.entry_price
                    pnl = (pos.entry_price - exit_price) * pos.shares
                else:
                    risk_per_share = pos.entry_price - pos.stop_price
                    pnl = (exit_price - pos.entry_price) * pos.shares
                pos.exit_price = exit_price
                pos.exit_bar_idx = eod_idx
                pos.exit_reason = "eod_exit"
                pos.pnl = round(pnl, 2)
                pos.r_multiple = round(pnl / (risk_per_share * pos.shares), 2) if risk_per_share * pos.shares > 0 else 0.0
                pos.status = "closed"
                equity += pnl
                daily_loss = min(daily_loss, pnl)
                closed_positions.append(pos)
            open_positions = []
            break

        # Entry: look for new setups if below max_positions
        if bar_dt_utc <= entry_cutoff_utc:
            # no_entry_before_et: skip this bar if it's before the floor
            entry_before_floor = False
            if ent.no_entry_before_et:
                h_b, m_b = ent.no_entry_before_et.split(":")
                before_floor_utc = _et_to_utc_naive(date, int(h_b), int(m_b))
                if bar_dt_utc < before_floor_utc:
                    entry_before_floor = True

            if not entry_before_floor:
                for cand in candidates_to_scan:
                    if len(open_positions) >= rsk.max_positions:
                        break
                    sym = cand["symbol"]
                    # Skip already in position
                    if any(p.symbol == sym for p in open_positions + closed_positions):
                        continue
                    bars = bars_by_symbol.get(sym, [])
                    if not bars:
                        continue

                    as_of_idx = _bar_idx_at(bars, bar_dt_utc)
                    if as_of_idx < 3:
                        continue

                    if ent.type == "hod_breakout":
                        setup_fn = detect_hod_breakout
                    elif ent.type == "fade_short":
                        setup_fn = detect_fade_short
                    else:
                        setup_fn = detect_vwap_pullback_reclaim
                    setup = setup_fn(
                        bars=bars,
                        start_bar_idx=cand["bar_idx_10"],
                        as_of_bar_idx=as_of_idx,
                        params=ent,
                        prev_close=prev_closes.get(sym, 0.0),
                        atr_intraday=cand.get("atr_intraday"),
                    )
                    if setup is None:
                        continue

                    entry_price = setup["entry_price"]
                    stop_price = setup["stop_price"]
                    trade_side = setup.get("side", "long")
                    if trade_side == "short":
                        risk_per_share = stop_price - entry_price  # stop above entry for shorts
                    else:
                        risk_per_share = entry_price - stop_price
                    if risk_per_share <= 0:
                        continue

                    # Size by risk_per_trade_pct
                    risk_dollars = equity * (rsk.risk_per_trade_pct / 100.0)
                    shares = max(1, int(risk_dollars / risk_per_share))
                    cost = entry_price * shares

                    # Daily loss limit
                    if -daily_loss >= equity * (rsk.daily_loss_limit_pct / 100.0):
                        break

                    trade = TGTCTrade(
                        symbol=sym,
                        entry_price=entry_price,
                        stop_price=stop_price,
                        shares=shares,
                        entry_bar_idx=setup["setup_bar_idx"],
                        peak_price=entry_price,
                        current_stop=stop_price,
                        entry_dt_utc=bar_dt_utc,
                        side=trade_side,
                        # Candidate metadata for segmentation analysis
                        score=cand.get("score") or 0.0,
                        rank_persistence=cand.get("rank_persistence") or 0.0,
                        rank_velocity=cand.get("rank_velocity") or 0.0,
                        price_structure=cand.get("price_structure") or 0.0,
                        volume_quality=cand.get("volume_quality") or 0.0,
                        relative_strength=cand.get("relative_strength") or 0.0,
                        pct_change_at_10=cand.get("pct_change_at_10") or 0.0,
                        dollar_volume_20d=cand.get("avg_dv") or 0.0,
                    )
                    open_positions.append(trade)
                    log.debug("TGTC %s: ENTER %s @ %.2f stop=%.2f shares=%d",
                              date_str, sym, entry_price, stop_price, shares)

        # Stop/exit management for open positions
        for pos in list(open_positions):
            sym = pos.symbol
            bars = bars_by_symbol.get(sym, [])
            as_of_idx = _bar_idx_at(bars, bar_dt_utc)
            if as_of_idx < 0:
                continue

            current_bar = bars[as_of_idx]
            current_price = float(current_bar["close"])
            current_high = float(current_bar["high"])
            current_low = float(current_bar["low"])

            is_short = pos.side == "short"

            # Peak tracking (long only — shorts don't use peak for partials/BE)
            if not is_short and current_price > pos.peak_price:
                pos.peak_price = current_price

            # risk_per_share is always positive (magnitude of distance to stop)
            if is_short:
                risk_per_share = pos.stop_price - pos.entry_price  # stop > entry for shorts
            else:
                risk_per_share = pos.entry_price - pos.stop_price

            # ── Quick Take Profit (BEFORE stop check) ──────────
            if ex.take_profit_pct is not None:
                if is_short:
                    tp_target = pos.entry_price * (1.0 - ex.take_profit_pct)
                    tp_hit = current_low <= tp_target
                    stop_touched = current_high >= pos.current_stop
                    if tp_hit:
                        pos.tp_collision = bool(stop_touched)
                        if ex.tp_requires_no_stop_touch and stop_touched:
                            pass  # let stop handler decide
                        else:
                            exit_price = tp_target
                            pnl = (pos.entry_price - exit_price) * pos.shares  # short P&L
                            pos.exit_price = exit_price
                            pos.exit_bar_idx = as_of_idx
                            pos.exit_reason = "quick_tp"
                            pos.pnl = round(pnl, 2)
                            pos.r_multiple = round(pnl / (risk_per_share * pos.shares), 2) if risk_per_share * pos.shares > 0 else 0.0
                            pos.status = "closed"
                            equity += pnl
                            daily_loss = min(daily_loss, pnl)
                            open_positions.remove(pos)
                            closed_positions.append(pos)
                            log.debug("TGTC %s: QUICK_TP(short) %s @ %.2f pnl=%.2f", date_str, sym, exit_price, pnl)
                            continue
                else:
                    tp_target = pos.entry_price * (1.0 + ex.take_profit_pct)
                    if current_high >= tp_target:
                        # Always record collision status (used for artifact analysis)
                        pos.tp_collision = bool(current_low <= pos.current_stop)
                        # Conservative ordering: skip TP if stop also touched this bar
                        if ex.tp_requires_no_stop_touch and pos.tp_collision:
                            pass  # let stop_loss handler decide (next block)
                        else:
                            exit_price = tp_target
                            pnl = (exit_price - pos.entry_price) * pos.shares
                            pos.exit_price = exit_price
                            pos.exit_bar_idx = as_of_idx
                            pos.exit_reason = "quick_tp"
                            pos.pnl = round(pnl, 2)
                            pos.r_multiple = round(pnl / (risk_per_share * pos.shares), 2) if risk_per_share * pos.shares > 0 else 0.0
                            pos.status = "closed"
                            equity += pnl
                            daily_loss = min(daily_loss, pnl)
                            open_positions.remove(pos)
                            closed_positions.append(pos)
                            log.debug("TGTC %s: QUICK_TP %s @ %.2f pnl=%.2f collision=%s", date_str, sym, exit_price, pnl, pos.tp_collision)
                            continue

            # ── Partial exit at 1R (long only — shorts skip entirely) ──
            if not is_short and not pos.partial_taken and risk_per_share > 0:
                if ex.partial_levels:
                    # Multi-level partial scale-out (uses bar.high)
                    for i, level in enumerate(ex.partial_levels):
                        if i in pos.partial_levels_taken:
                            continue
                        r_mult = level.get("r_multiple", 1.0)
                        frac = level.get("fraction", 0.33)
                        target = pos.entry_price + r_mult * risk_per_share
                        if current_high >= target:
                            partial_shares = max(1, int(pos.shares * frac))
                            if partial_shares >= pos.shares:
                                partial_shares = pos.shares - 1  # keep at least 1 share
                            if partial_shares > 0:
                                partial_pnl = (target - pos.entry_price) * partial_shares
                                equity += partial_pnl
                                pos.shares -= partial_shares
                                pos.partial_levels_taken.add(i)
                                if not pos.partial_taken and ex.stop_to_be_after_1r and r_mult >= 1.0:
                                    pos.current_stop = pos.entry_price
                                    pos.be_stop_active = True
                                    pos.partial_taken = True  # flag so be-stop only fires once
                                log.debug("TGTC %s: PARTIAL_LEVEL[%d] %s +%d shares pnl=%.2f",
                                          date_str, i, sym, partial_shares, partial_pnl)
                elif not ex.disable_partial_at_1r:
                    target_1r = pos.entry_price + risk_per_share
                    if current_price >= target_1r:
                        partial_shares = max(1, int(pos.shares * ex.partial_at_1r))
                        partial_pnl = (current_price - pos.entry_price) * partial_shares
                        equity += partial_pnl
                        pos.shares -= partial_shares
                        pos.partial_taken = True
                        if ex.stop_to_be_after_1r:
                            pos.current_stop = pos.entry_price
                            pos.be_stop_active = True
                        log.debug("TGTC %s: PARTIAL %s +%d shares pnl=%.2f", date_str, sym, partial_shares, partial_pnl)

            # ── Stop hit ──────────────────────────────────────────────────────
            if is_short:
                stop_hit = current_high >= pos.current_stop
            else:
                stop_hit = current_low <= pos.current_stop

            if stop_hit:
                mode = getattr(ex, "stop_exit_mode", "conservative")
                slippage_bps = getattr(ex, "stop_slippage_bps", 10.0)
                if is_short:
                    if mode == "optimistic":
                        exit_price = pos.current_stop
                    elif mode == "moderate":
                        exit_price = pos.current_stop * (1.0 + slippage_bps / 10000.0)  # worse for short
                    else:
                        exit_price = max(pos.current_stop, float(current_bar["open"]))
                    pnl = (pos.entry_price - exit_price) * pos.shares  # short P&L
                else:
                    if mode == "optimistic":
                        exit_price = pos.current_stop
                    elif mode == "moderate":
                        exit_price = pos.current_stop * (1.0 - slippage_bps / 10000.0)
                    else:
                        exit_price = min(pos.current_stop, float(current_bar["open"]))
                    pnl = (exit_price - pos.entry_price) * pos.shares
                pos.exit_price = exit_price
                pos.exit_bar_idx = as_of_idx
                pos.exit_reason = "stop_loss"
                pos.pnl = round(pnl, 2)
                pos.r_multiple = round(pnl / (risk_per_share * pos.shares), 2) if risk_per_share * pos.shares > 0 else 0.0
                pos.status = "closed"
                equity += pnl
                daily_loss = min(daily_loss, pnl)
                open_positions.remove(pos)
                closed_positions.append(pos)
                continue

            # ── Time-Stop (after stop check, before trend health) ────────────
            if ex.force_exit_after_minutes is not None and pos.entry_dt_utc is not None:
                elapsed_min = (bar_dt_utc - pos.entry_dt_utc).total_seconds() / 60.0
                if elapsed_min >= ex.force_exit_after_minutes:
                    exit_price = current_price
                    if is_short:
                        pnl = (pos.entry_price - exit_price) * pos.shares
                    else:
                        pnl = (exit_price - pos.entry_price) * pos.shares
                    pos.exit_price = exit_price
                    pos.exit_bar_idx = as_of_idx
                    pos.exit_reason = "time_stop"
                    pos.pnl = round(pnl, 2)
                    pos.r_multiple = round(pnl / (risk_per_share * pos.shares), 2) if risk_per_share * pos.shares > 0 else 0.0
                    pos.status = "closed"
                    equity += pnl
                    daily_loss = min(daily_loss, pnl)
                    open_positions.remove(pos)
                    closed_positions.append(pos)
                    log.debug("TGTC %s: TIME_STOP %s @ %.2f elapsed=%.1fm pnl=%.2f",
                              date_str, sym, exit_price, elapsed_min, pnl)
                    continue

            # ── Trend health exit (long only — skip for shorts) ──────────────
            if not is_short:
                trend_score = compute_trend_health(bars, as_of_idx)
                if trend_score <= 1:
                    exit_price = current_price
                    pnl = (exit_price - pos.entry_price) * pos.shares
                    pos.exit_price = exit_price
                    pos.exit_bar_idx = as_of_idx
                    pos.exit_reason = "trend_health_exit"
                    pos.pnl = round(pnl, 2)
                    pos.r_multiple = round(pnl / (risk_per_share * pos.shares), 2) if risk_per_share * pos.shares > 0 else 0.0
                    pos.status = "closed"
                    equity += pnl
                    daily_loss = min(daily_loss, pnl)
                    open_positions.remove(pos)
                    closed_positions.append(pos)
                    continue

        # Equity snapshot
        unrealized = sum(
            (
                (float(bars_by_symbol[p.symbol][_bar_idx_at(bars_by_symbol[p.symbol], bar_dt_utc)]["close"])
                 - p.entry_price) * p.shares
                if p.side == "long"
                else
                (p.entry_price - float(bars_by_symbol[p.symbol][_bar_idx_at(bars_by_symbol[p.symbol], bar_dt_utc)]["close"]))
                * p.shares
            )
            if bars_by_symbol.get(p.symbol) and _bar_idx_at(bars_by_symbol[p.symbol], bar_dt_utc) >= 0
            else 0.0
            for p in open_positions
        )
        equity_snapshots.append({
            "ts_et": (bar_dt_utc - dt.timedelta(hours=_ET_OFFSET)).strftime("%H:%M"),
            "equity": round(equity + unrealized, 2),
        })

    result.trades = closed_positions + open_positions
    result.equity_curve = equity_snapshots
    result.final_equity = round(equity, 2)
    return result
