#!/usr/bin/env python3
"""Map bot candidates against later OHLC to see full move capture.

This is intentionally forensic. It does not predict trades; it answers:
given an LLM candidate or blocked submit, where did price go afterwards,
what percent of the move did the planned TP capture, and why was it blocked?
"""
from __future__ import annotations

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

import requests


HL_INFO_URL = "https://api.hyperliquid.xyz/info"


@dataclass
class Candidate:
    ts: int
    event: str
    coin: str
    side: str
    entry: float
    stop: float
    target: float
    confidence: float
    setup_type: str
    reason: str
    blocker: 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 load_rows(path: Path) -> list[dict[str, Any]]:
    rows = []
    with path.open() as fh:
        for line in fh:
            line = line.strip()
            if not line:
                continue
            try:
                rows.append(json.loads(line))
            except json.JSONDecodeError:
                continue
    return rows


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


def extract_candidate(row: dict[str, Any]) -> Candidate | None:
    event = str(row.get("event") or "")
    if event not in {"llm_decision", "risk_reject", "submit"}:
        return None

    extra = row.get("extra") if isinstance(row.get("extra"), dict) else {}
    metrics = extra.get("gate_metrics") if isinstance(extra.get("gate_metrics"), dict) else {}

    entry = fnum(row.get("entry")) or fnum(metrics.get("entry")) or fnum(extra.get("plan_entry"))
    stop = fnum(row.get("stop")) or fnum(metrics.get("stop"))
    target = fnum(row.get("target")) or fnum(metrics.get("target"))
    side = str(row.get("side") or "").lower()
    coin = str(row.get("coin") or "")
    if side not in {"long", "short"} or not coin or entry <= 0 or stop <= 0 or target <= 0:
        return None

    if event == "llm_decision" and str(row.get("action") or "") != "enter":
        return None

    return Candidate(
        ts=int(row.get("ts") or 0),
        event=event,
        coin=coin,
        side=side,
        entry=entry,
        stop=stop,
        target=target,
        confidence=fnum(row.get("confidence")),
        setup_type=str(row.get("setup_type") or ""),
        reason=str(row.get("reason") or extra.get("reason") or ""),
        blocker=str(row.get("reason") or ""),
    )


def fetch_candles(coin: str, start_ms: int, end_ms: int, interval: str = "5m") -> list[dict[str, Any]]:
    payload = {
        "type": "candleSnapshot",
        "req": {"coin": coin, "interval": interval, "startTime": start_ms, "endTime": end_ms},
    }
    r = requests.post(HL_INFO_URL, json=payload, timeout=20)
    r.raise_for_status()
    data = r.json()
    return data if isinstance(data, list) else []


def candle_float(candle: dict[str, Any], key: str) -> float:
    return fnum(candle.get(key))


def hit_times(candidate: Candidate, candles: list[dict[str, Any]]) -> tuple[int | None, int | None, float, int | None]:
    tp_time = None
    sl_time = None
    extreme = candidate.entry
    extreme_time = None
    for candle in candles:
        ts = int(candle.get("t") or candle.get("T") or 0)
        high = candle_float(candle, "h")
        low = candle_float(candle, "l")
        if candidate.side == "long":
            if high > extreme:
                extreme = high
                extreme_time = ts
            if tp_time is None and high >= candidate.target:
                tp_time = ts
            if sl_time is None and low <= candidate.stop:
                sl_time = ts
        else:
            if low and low < extreme:
                extreme = low
                extreme_time = ts
            if tp_time is None and low <= candidate.target:
                tp_time = ts
            if sl_time is None and high >= candidate.stop:
                sl_time = ts
    return tp_time, sl_time, extreme, extreme_time


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")


def analyze(candidates: list[Candidate], *, horizon_hours: float, notional: float) -> list[dict[str, Any]]:
    out = []
    horizon_ms = int(horizon_hours * 3600 * 1000)
    for cand in candidates:
        candles = fetch_candles(cand.coin, cand.ts, cand.ts + horizon_ms)
        if not candles:
            continue
        tp_time, sl_time, extreme, extreme_time = hit_times(cand, candles)
        full_move = abs(extreme - cand.entry)
        planned_reward = abs(cand.target - cand.entry)
        risk = abs(cand.entry - cand.stop)
        capture = planned_reward / full_move if full_move > 0 else 0.0
        max_pnl = notional * (full_move / cand.entry) if cand.entry else 0.0
        planned_pnl = notional * (planned_reward / cand.entry) if cand.entry else 0.0
        first_hit = "none"
        if tp_time and sl_time:
            first_hit = "target" if tp_time < sl_time else "stop" if sl_time < tp_time else "same_candle"
        elif tp_time:
            first_hit = "target"
        elif sl_time:
            first_hit = "stop"
        out.append(
            {
                "time": fmt_ts(cand.ts),
                "coin": cand.coin,
                "side": cand.side,
                "event": cand.event,
                "setup": cand.setup_type,
                "entry": cand.entry,
                "stop": cand.stop,
                "target": cand.target,
                "extreme": extreme,
                "extreme_time": fmt_ts(extreme_time),
                "move_pct": full_move / cand.entry * 100 if cand.entry else 0.0,
                "mfe_r": full_move / risk if risk > 0 else 0.0,
                "tp_capture_pct": capture * 100,
                "first_hit": first_hit,
                "tp_time": fmt_ts(tp_time),
                "sl_time": fmt_ts(sl_time),
                "max_pnl": max_pnl,
                "missed_after_tp": max(0.0, max_pnl - planned_pnl),
                "blocker": cand.blocker,
                "reason": cand.reason,
            }
        )
    out.sort(key=lambda row: row["max_pnl"], reverse=True)
    return out


def render(rows: list[dict[str, Any]], limit: int) -> str:
    lines = ["MISSED/REJECTED CANDIDATE FULL MOVE FORENSICS", ""]
    for row in rows[:limit]:
        lines.append(
            f"{row['time']} {row['coin']} {row['side']} {row['setup']} "
            f"first={row['first_hit']} move={row['move_pct']:.2f}% mfe={row['mfe_r']:.2f}R "
            f"tp_capture={row['tp_capture_pct']:.1f}% maxPnL~${row['max_pnl']:.2f} "
            f"missedAfterTP~${row['missed_after_tp']:.2f}"
        )
        lines.append(
            f"  entry={row['entry']:.8g} stop={row['stop']:.8g} "
            f"target={row['target']:.8g} extreme={row['extreme']:.8g} at {row['extreme_time']}"
        )
        lines.append(f"  blocker: {row['blocker'][:220]}")
        lines.append(f"  reason: {row['reason'][:220]}")
        lines.append("")
    return "\n".join(lines)


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--journal", default="data/journal.jsonl")
    parser.add_argument("--since", help="ISO time or epoch ms")
    parser.add_argument("--horizon-hours", type=float, default=6.0)
    parser.add_argument("--notional", type=float, default=400.0)
    parser.add_argument("--limit", type=int, default=40)
    parser.add_argument("--out")
    args = parser.parse_args()

    journal = Path(args.journal)
    rows = load_rows(journal)
    since_ms = parse_time(args.since)
    candidates = [cand for row in rows if (cand := extract_candidate(row)) and cand.ts >= since_ms]
    report = render(analyze(candidates, horizon_hours=args.horizon_hours, notional=args.notional), args.limit)
    if args.out:
        Path(args.out).write_text(report + "\n")
    print(report)
    return 0


if __name__ == "__main__":
    sys.exit(main())
