#!/usr/bin/env python3
"""Replay old journal snapshots through the current DeepSeek brain.

This answers a narrow question:

Given an old live market snapshot from the journal, what would the current
prompt/parser/level-repair pipeline decide, and would the resulting live-style
IOC limit entry, TP, or SL have been hit on later Hyperliquid candles?

The prompt side avoids lookahead by only attaching candles completed before the
snapshot timestamp. The outcome side uses later 1m candles for a conservative
fill/TP/SL simulation.
"""
from __future__ import annotations

import argparse
import json
import os
import sys
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

from hlbot.api import HLClient
from hlbot.brain import LLMBrain, LLMTradeDecision
from hlbot.cli import _ioc_entry_limit, _rr_at_entry
from hlbot.config import Config
from hlbot.exit_plan import evaluate_exit_plan
from hlbot.level_repair import repair_trade_levels
from hlbot.models import Candle, MarketRegime, OrderflowSnapshot, Setup
from hlbot.policy import TradingPolicy
from hlbot.research_gate import evaluate_research_gate
from hlbot.risk import RiskManager


INTERVAL_MS = {
    "1m": 60_000,
    "5m": 5 * 60_000,
    "15m": 15 * 60_000,
    "1h": 60 * 60_000,
    "4h": 4 * 60 * 60_000,
}


@dataclass
class ReplayResult:
    snapshot_time: str
    snapshot_ts: int
    old_action: str
    old_reason: str
    action: str
    coin: str
    side: str
    setup_type: str
    confidence: float
    entry: float
    stop: float
    target: float
    entry_limit: float = 0.0
    rr: float = 0.0
    gate: str = ""
    reason: str = ""
    filled: bool = False
    fill_time: str = "-"
    fill_price: float = 0.0
    first_hit: str = "no_trade"
    exit_time: str = "-"
    exit_price: float = 0.0
    gross_pnl: float = 0.0
    fees: float = 0.0
    net_pnl: float = 0.0
    mfe_pct: float = 0.0
    mae_pct: float = 0.0
    max_profit_usd: float = 0.0
    raw_response: str = ""


def parse_time(value: str | None) -> int:
    if not value:
        return 0
    text = value.strip()
    if text.isdigit():
        return int(text)
    if text.endswith("Z"):
        text = text[:-1] + "+00:00"
    dt = datetime.fromisoformat(text)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return int(dt.timestamp() * 1000)


def fmt_ts(ms: int | None) -> str:
    if not ms:
        return "-"
    return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")


def load_rows(path: Path) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    with path.open() as fh:
        for line in fh:
            try:
                rows.append(json.loads(line))
            except json.JSONDecodeError:
                continue
    return rows


def select_snapshots(
    rows: list[dict[str, Any]],
    *,
    times: list[int],
    since: int,
    until: int,
    limit: int,
) -> list[dict[str, Any]]:
    snapshots = [
        row
        for row in rows
        if row.get("event") == "llm_decision"
        and row.get("market_summary")
        and int(row.get("ts") or 0) >= since
        and (not until or int(row.get("ts") or 0) <= until)
    ]
    if times:
        picked = []
        for ts in times:
            nearest = min(snapshots, key=lambda row: abs(int(row.get("ts") or 0) - ts), default=None)
            if nearest and abs(int(nearest.get("ts") or 0) - ts) <= 10 * 60_000:
                if nearest not in picked:
                    picked.append(nearest)
        return picked[:limit]

    # Prefer old enter attempts because they had concrete plans and enough
    # later candles to judge TP/SL. Keep spacing so we do not replay duplicates.
    old_enters = [row for row in snapshots if row.get("action") == "enter"]
    selected: list[dict[str, Any]] = []
    min_gap_ms = 20 * 60_000
    for row in old_enters:
        ts = int(row.get("ts") or 0)
        if selected and ts - int(selected[-1].get("ts") or 0) < min_gap_ms:
            continue
        selected.append(row)
    return selected[-limit:]


def fnum(value: Any, default: float = 0.0) -> float:
    try:
        return float(value)
    except (TypeError, ValueError):
        return default


def reconstruct_coin_data(client: HLClient, snapshot: dict[str, Any]) -> list[dict[str, Any]]:
    out = []
    ts = int(snapshot.get("ts") or 0)
    for item in snapshot.get("market_summary") or []:
        coin = str(item.get("coin") or "")
        if not coin:
            continue
        of = OrderflowSnapshot(
            ts=ts,
            mid=fnum(item.get("mid")),
            spread_bps=fnum(item.get("spread_bps"), 999.0),
            book_imbalance=fnum(item.get("book_imbalance")),
            wall_skew=fnum(item.get("wall_skew")),
            stacked_bid_count=int(item.get("stacked_bid_count") or 0),
            stacked_ask_count=int(item.get("stacked_ask_count") or 0),
            bid_depth_usd=fnum(item.get("bid_depth_usd")),
            ask_depth_usd=fnum(item.get("ask_depth_usd")),
            tape_delta_usd=fnum(item.get("tape_delta_usd")),
            tape_delta_pct=fnum(item.get("tape_delta_pct")),
            cvd=fnum(item.get("cvd")),
            cvd_divergence=bool(item.get("cvd_divergence")),
            large_prints=int(item.get("large_prints") or 0),
            trades_per_sec=fnum(item.get("trades_per_sec")),
            bid_absorption=fnum(item.get("bid_absorption")),
            ask_absorption=fnum(item.get("ask_absorption")),
            vwap=fnum(item.get("vwap")),
            price_vs_vwap=fnum(item.get("price_vs_vwap")),
            rsi_14=fnum(item.get("rsi_14"), 50.0),
            macd_histogram=fnum(item.get("macd_histogram")),
            atr_14=fnum(item.get("atr_14")),
            vpoc=fnum(item.get("vpoc")),
            vah=fnum(item.get("vah")),
            val=fnum(item.get("val")),
            funding_rate=fnum(item.get("funding_rate")),
            open_interest=fnum(item.get("open_interest")),
            premium_bps=fnum(item.get("premium_bps")),
        )
        regime_raw = item.get("regime") or {}
        regime = MarketRegime(
            trend_15m=str(regime_raw.get("trend_15m") or "chop"),
            trend_1h=str(regime_raw.get("trend_1h") or "chop"),
            trend_4h=str(regime_raw.get("trend_4h") or "chop"),
            vol_regime=str(regime_raw.get("vol_regime") or "normal"),
            overall=str(regime_raw.get("overall") or "ranging"),
        )
        candles = fetch_prompt_candles(client, coin, ts)
        out.append(
            {
                "coin": coin,
                "of": of,
                "regime": regime,
                "candles_summary": item.get("candles_summary") or "",
                "levels": item.get("levels") or {},
                "trigger_state": item.get("trigger_state") or {},
                "trigger_meta": item.get("trigger_meta") or {},
                "crowding_state": item.get("crowding_state") or {},
                "noise_state": item.get("noise_state") or {},
                "session": item.get("session") or {},
                "ws_summary": item.get("ws") or {},
                "market_context": item.get("market_context") or {},
                "coinglass": item.get("coinglass") or {},
                **candles,
            }
        )
    return out


def fetch_prompt_candles(client: HLClient, coin: str, ts: int) -> dict[str, list[Candle]]:
    windows = {
        "1m": 2 * 60 * 60_000,
        "5m": 8 * 60 * 60_000,
        "15m": 30 * 60 * 60_000,
        "1h": 96 * 60 * 60_000,
        "4h": 14 * 24 * 60 * 60_000,
    }
    result: dict[str, list[Candle]] = {}
    for interval, lookback in windows.items():
        raw = client.candles(coin, interval=interval, start_ms=ts - lookback, end_ms=ts)
        step = INTERVAL_MS[interval]
        candles = [Candle.from_hl(row) for row in raw if int(row.get("t") or 0) + step <= ts]
        result[f"candles_{interval}"] = candles
    return result


def gate_current_decision(
    decision: LLMTradeDecision,
    coin_data: list[dict[str, Any]],
    cfg: Config,
    policy: TradingPolicy,
) -> tuple[bool, str, LLMTradeDecision, Any | None, float]:
    if decision.action != "enter":
        return False, "wait", decision, None, 0.0
    selected = next((cd for cd in coin_data if cd.get("coin") == decision.coin), None)
    if not selected:
        return False, "selected coin missing from snapshot", decision, None, 0.0

    repair = repair_trade_levels(
        decision,
        selected,
        min_rr=cfg.min_rr,
        min_stop_noise_multiple=cfg.min_stop_noise_multiple,
        fee_bps=cfg.fee_bps,
        min_target_pct=cfg.min_target_pct if cfg.enforce_min_target_distance else 0.0,
        min_target_atr_multiple=cfg.min_target_atr_multiple if cfg.enforce_min_target_distance else 0.0,
        prefer_htf_targets=cfg.prefer_htf_targets,
        runner_target_rr=cfg.runner_target_rr,
        max_target_rr=cfg.max_target_rr,
        runner_min_confidence=cfg.runner_min_confidence,
    )
    if not repair.allowed:
        return False, f"level_repair: {repair.reason}", decision, None, 0.0
    decision = repair.decision

    research = evaluate_research_gate(
        decision,
        selected,
        ws_book_max_age_s=cfg.ws_book_max_age_s,
        ws_tape_max_age_s=cfg.ws_tape_max_age_s,
        policy=policy,
    )
    if not research.allowed:
        return False, f"research_gate: {research.reason}", decision, None, 0.0

    exit_gate = evaluate_exit_plan(
        decision,
        selected,
        min_rr=cfg.min_rr,
        stop_atr_multiplier=cfg.stop_atr_multiplier,
        min_stop_noise_multiple=cfg.min_stop_noise_multiple,
        min_target_pct=cfg.min_target_pct if cfg.enforce_min_target_distance else 0.0,
        min_target_atr_multiple=cfg.min_target_atr_multiple if cfg.enforce_min_target_distance else 0.0,
    )
    if not exit_gate.allowed:
        return False, f"exit_plan_gate: {exit_gate.reason}", decision, None, 0.0

    of = selected["of"]
    setup = Setup(
        setup_type=decision.setup_type,
        side=decision.side,
        coin=decision.coin,
        confidence=decision.confidence,
        edge=2.0,
        evidence=decision.evidence,
        entry_ref=decision.entry_price,
        invalidation=decision.stop_price,
        target=decision.target_price,
        atr=of.atr_14,
    )
    risk = RiskManager(
        equity_usd=cfg.equity_usd,
        max_risk_pct=cfg.max_risk_pct,
        min_rr=cfg.min_rr,
        max_daily_losses=999,
        max_daily_trades=999,
        min_confidence=cfg.min_confidence,
        spread_max_bps=cfg.spread_max_bps,
        fixed_margin_usd=cfg.fixed_margin_usd,
        max_leverage=cfg.max_leverage,
        stop_atr_multiplier=cfg.stop_atr_multiplier,
    )
    reject = risk.check(setup, of)
    if reject:
        return False, f"risk: {reject}", decision, None, 0.0
    plan = risk.build_plan(setup, of, leverage=cfg.max_leverage)
    entry_limit = _ioc_entry_limit(plan, decision.side, of.spread_bps, cfg)
    return True, "passed", decision, plan, entry_limit


def fetch_future_candles(client: HLClient, coin: str, ts: int, horizon_hours: float) -> list[Candle]:
    raw = client.candles(
        coin,
        interval="1m",
        start_ms=ts,
        end_ms=ts + int(horizon_hours * 3600 * 1000),
    )
    return [Candle.from_hl(row) for row in raw if int(row.get("t") or 0) >= ts]


def simulate_ioc_and_tpsl(
    *,
    side: str,
    snapshot_entry: float,
    entry_limit: float,
    stop: float,
    target: float,
    candles: list[Candle],
    notional: float,
    fee_bps: float,
) -> dict[str, Any]:
    if not candles:
        return {"filled": False, "first_hit": "no_candles"}

    first = candles[0]
    if side == "long":
        filled = snapshot_entry <= entry_limit or first.low <= entry_limit
    else:
        filled = snapshot_entry >= entry_limit or first.high >= entry_limit
    if not filled:
        return {
            "filled": False,
            "first_hit": "entry_not_filled_ioc",
            "fill_time": "-",
            "fill_price": 0.0,
        }

    fill_price = snapshot_entry if (side == "long" and snapshot_entry <= entry_limit) or (side == "short" and snapshot_entry >= entry_limit) else entry_limit
    mfe = 0.0
    mae = 0.0
    first_hit = "open_at_horizon"
    exit_time = "-"
    exit_price = candles[-1].close

    for candle in candles:
        if side == "long":
            mfe = max(mfe, candle.high - fill_price)
            mae = max(mae, fill_price - candle.low)
            hit_tp = candle.high >= target
            hit_sl = candle.low <= stop
        else:
            mfe = max(mfe, fill_price - candle.low)
            mae = max(mae, candle.high - fill_price)
            hit_tp = candle.low <= target
            hit_sl = candle.high >= stop

        if hit_tp and hit_sl:
            # Conservative: 1m candle cannot tell sequence, count stop first.
            first_hit = "same_1m_candle_conservative_stop"
            exit_time = fmt_ts(candle.ts)
            exit_price = stop
            break
        if hit_sl:
            first_hit = "stop"
            exit_time = fmt_ts(candle.ts)
            exit_price = stop
            break
        if hit_tp:
            first_hit = "target"
            exit_time = fmt_ts(candle.ts)
            exit_price = target
            break

    sign = 1.0 if side == "long" else -1.0
    gross = notional * ((exit_price - fill_price) / fill_price) * sign
    fees = notional * (float(fee_bps) * 2.0 / 10_000.0)
    max_profit = notional * (mfe / fill_price)
    return {
        "filled": True,
        "fill_time": fmt_ts(first.ts),
        "fill_price": fill_price,
        "first_hit": first_hit,
        "exit_time": exit_time,
        "exit_price": exit_price,
        "gross_pnl": gross,
        "fees": fees,
        "net_pnl": gross - fees,
        "mfe_pct": mfe / fill_price * 100.0,
        "mae_pct": mae / fill_price * 100.0,
        "max_profit_usd": max_profit,
    }


def replay_one(
    client: HLClient,
    brain: LLMBrain,
    cfg: Config,
    policy: TradingPolicy,
    snapshot: dict[str, Any],
    *,
    horizon_hours: float,
) -> ReplayResult:
    coin_data = reconstruct_coin_data(client, snapshot)
    decision = brain.decide(coin_data)
    passed, gate, decision, plan, entry_limit = gate_current_decision(decision, coin_data, cfg, policy)
    result = ReplayResult(
        snapshot_time=fmt_ts(int(snapshot.get("ts") or 0)),
        snapshot_ts=int(snapshot.get("ts") or 0),
        old_action=str(snapshot.get("action") or ""),
        old_reason=str(snapshot.get("reason") or ""),
        action=decision.action,
        coin=decision.coin,
        side=decision.side,
        setup_type=decision.setup_type,
        confidence=decision.confidence,
        entry=decision.entry_price,
        stop=decision.stop_price,
        target=decision.target_price,
        entry_limit=float(entry_limit or 0.0),
        rr=_rr_at_entry(decision.side, float(entry_limit or decision.entry_price or 0.0), decision.stop_price, decision.target_price)
        if decision.action == "enter"
        else 0.0,
        gate=gate,
        reason=decision.reason,
        raw_response=decision.raw_response,
    )
    if not passed or not plan:
        result.first_hit = "no_trade"
        return result

    candles = fetch_future_candles(client, decision.coin, result.snapshot_ts, horizon_hours)
    sim = simulate_ioc_and_tpsl(
        side=decision.side,
        snapshot_entry=float(decision.entry_price),
        entry_limit=float(entry_limit),
        stop=float(plan.stop),
        target=float(plan.target),
        candles=candles,
        notional=cfg.fixed_margin_usd * cfg.max_leverage,
        fee_bps=cfg.fee_bps,
    )
    for key, value in sim.items():
        if hasattr(result, key):
            setattr(result, key, value)
    return result


def render(results: list[ReplayResult], cfg: Config) -> str:
    lines = [
        "CURRENT BRAIN HISTORICAL REPLAY",
        "",
        f"Config: margin=${cfg.fixed_margin_usd:.2f}, leverage={cfg.max_leverage}x, notional=${cfg.fixed_margin_usd * cfg.max_leverage:.2f}",
        "Fill model: live-style IOC limit. Entry must be fillable in the first 1m candle after the snapshot.",
        "TP/SL model: later 1m candles. If TP and SL hit in same 1m candle, counted as conservative stop.",
        "",
    ]
    total = 0.0
    filled = 0
    targets = 0
    stops = 0
    for idx, row in enumerate(results, 1):
        lines.append(
            f"{idx}. {row.snapshot_time} | old={row.old_action or '-'} | "
            f"new={row.action} {row.coin} {row.side} {row.setup_type} conf={row.confidence:.2f}"
        )
        lines.append(f"   reason: {row.reason[:220]}")
        if row.action != "enter" or row.gate != "passed":
            lines.append(f"   no trade: {row.gate}")
            lines.append(f"   old reason: {row.old_reason[:180]}")
            lines.append("")
            continue
        filled += 1 if row.filled else 0
        targets += 1 if row.first_hit == "target" else 0
        stops += 1 if "stop" in row.first_hit else 0
        total += row.net_pnl if row.filled else 0.0
        lines.append(
            f"   plan: entry={row.entry:.8g} IOC_limit={row.entry_limit:.8g} "
            f"SL={row.stop:.8g} TP={row.target:.8g} RR@limit={row.rr:.2f}"
        )
        lines.append(
            f"   fill: {row.filled} at {row.fill_time} price={row.fill_price:.8g} | "
            f"first_hit={row.first_hit} exit={row.exit_time} px={row.exit_price:.8g}"
        )
        lines.append(
            f"   pnl: gross=${row.gross_pnl:+.2f} fees=${row.fees:.2f} "
            f"net=${row.net_pnl:+.2f} | MFE={row.mfe_pct:.2f}% "
            f"MAE={row.mae_pct:.2f}% maxProfit~${row.max_profit_usd:.2f}"
        )
        lines.append("")
    lines.append(
        f"SUMMARY: trades_filled={filled}/{len(results)} targets={targets} stops={stops} net=${total:+.2f}"
    )
    return "\n".join(lines)


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--config", default="config.toml")
    parser.add_argument("--journal", default="data/journal.jsonl")
    parser.add_argument("--since")
    parser.add_argument("--until")
    parser.add_argument("--times", help="Comma-separated ISO timestamps. Nearest journal snapshot is replayed.")
    parser.add_argument("--limit", type=int, default=4)
    parser.add_argument("--horizon-hours", type=float, default=6.0)
    parser.add_argument("--out", default="data/reports/current-brain-replay-latest.md")
    args = parser.parse_args()

    cfg = Config.load(args.config)
    policy = TradingPolicy.from_config(cfg)
    rows = load_rows(Path(args.journal))
    times = [parse_time(item) for item in args.times.split(",")] if args.times else []
    snapshots = select_snapshots(
        rows,
        times=times,
        since=parse_time(args.since),
        until=parse_time(args.until),
        limit=args.limit,
    )
    if not snapshots:
        print("No matching snapshots found", file=sys.stderr)
        return 2

    api_key = os.environ.get("DEEPSEEK_API_KEY", "")
    if not api_key:
        print("DEEPSEEK_API_KEY missing", file=sys.stderr)
        return 2

    client = HLClient(timeout=20)
    brain = LLMBrain(
        api_key=api_key,
        base_url=cfg.llm_base_url,
        model=cfg.llm_model,
        temperature=cfg.llm_temperature,
        timeout=cfg.llm_timeout,
        thinking=cfg.llm_thinking,
        equity=cfg.equity_usd,
        margin=cfg.fixed_margin_usd,
        leverage=cfg.max_leverage,
        policy=policy,
        ohlc_lessons_path=cfg.ohlc_lessons_path,
    )

    results = []
    for idx, snapshot in enumerate(snapshots, 1):
        print(f"Replay {idx}/{len(snapshots)} snapshot {fmt_ts(int(snapshot.get('ts') or 0))}", file=sys.stderr)
        results.append(replay_one(client, brain, cfg, policy, snapshot, horizon_hours=args.horizon_hours))

    report = render(results, cfg)
    Path(args.out).write_text(report + "\n")
    print(report)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
