#!/usr/bin/env python3
"""Live-trade forensics for the V2 Hyperliquid bot.

Read-only. Pulls public account fills and candle snapshots, then maps them to
local journal submit/LLM context. No private key is needed.
"""
from __future__ import annotations

import argparse
import datetime as dt
import json
import math
import os
import time
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo

import requests


INFO_URL = "https://api.hyperliquid.xyz/info"
TZ = ZoneInfo("Europe/Berlin")


def post_info(payload: dict[str, Any], timeout: float = 20.0) -> Any:
    response = requests.post(INFO_URL, json=payload, timeout=timeout)
    response.raise_for_status()
    return response.json()


def parse_time(value: str | None, hours: float) -> int:
    if value:
        text = value.strip()
        if text.endswith("Z"):
            parsed = dt.datetime.fromisoformat(text.replace("Z", "+00:00"))
        else:
            parsed = dt.datetime.fromisoformat(text)
            if parsed.tzinfo is None:
                parsed = parsed.replace(tzinfo=TZ)
        return int(parsed.timestamp() * 1000)
    return int((time.time() - hours * 3600) * 1000)


def fmt_time(ms: int | None) -> str:
    if not ms:
        return "open"
    return dt.datetime.fromtimestamp(ms / 1000, tz=TZ).strftime("%Y-%m-%d %H:%M:%S")


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


def fmt_num(value: Any) -> str:
    value = fnum(value)
    if abs(value) >= 100:
        return f"{value:.2f}"
    if abs(value) >= 1:
        return f"{value:.6f}".rstrip("0").rstrip(".")
    return f"{value:.8f}".rstrip("0").rstrip("0").rstrip(".")


def pct(part: float, whole: float) -> float:
    return part / whole * 100.0 if whole else 0.0


def load_journal(path: Path, start_ms: int) -> list[dict[str, Any]]:
    if not path.exists():
        return []
    rows: list[dict[str, Any]] = []
    for line in path.read_text().splitlines():
        try:
            row = json.loads(line)
        except json.JSONDecodeError:
            continue
        if int(row.get("ts") or 0) >= start_ms:
            rows.append(row)
    return rows


def fetch_fills(address: str, start_ms: int, end_ms: int | None = None) -> list[dict[str, Any]]:
    payload: dict[str, Any] = {
        "type": "userFillsByTime",
        "user": address,
        "startTime": start_ms,
        "aggregateByTime": True,
    }
    if end_ms:
        payload["endTime"] = end_ms
    result = post_info(payload)
    return sorted(result if isinstance(result, list) else [], key=lambda row: int(row.get("time") or 0))


def fetch_candles(coin: str, start_ms: int, end_ms: int) -> list[dict[str, Any]]:
    result = post_info({
        "type": "candleSnapshot",
        "req": {
            "coin": coin,
            "interval": "1m",
            "startTime": start_ms,
            "endTime": end_ms,
        },
    })
    return result if isinstance(result, list) else []


def group_trades(fills: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[tuple[str, str], dict[str, Any]]]:
    trades: list[dict[str, Any]] = []
    open_trades: dict[tuple[str, str], dict[str, Any]] = {}
    for fill in fills:
        coin = str(fill.get("coin") or "")
        direction = str(fill.get("dir") or "")
        t_ms = int(fill.get("time") or 0)
        px = fnum(fill.get("px"))
        sz = fnum(fill.get("sz"))
        pnl = fnum(fill.get("closedPnl"))
        fee = fnum(fill.get("fee"))
        if direction.startswith("Open"):
            side = "long" if "Long" in direction else "short"
            key = (coin, side)
            trade = open_trades.setdefault(key, {
                "coin": coin,
                "side": side,
                "open_time": t_ms,
                "qty": 0.0,
                "entry_value": 0.0,
                "open_fees": 0.0,
                "close_time": None,
                "closed_qty": 0.0,
                "exit_value": 0.0,
                "closed_pnl": 0.0,
                "close_fees": 0.0,
                "fills_open": [],
                "fills_close": [],
            })
            trade["qty"] += sz
            trade["entry_value"] += px * sz
            trade["open_fees"] += fee
            trade["fills_open"].append(fill)
            trade["open_time"] = min(int(trade["open_time"]), t_ms)
            continue
        if direction.startswith("Close"):
            side = "long" if "Long" in direction else "short"
            key = (coin, side)
            trade = open_trades.setdefault(key, {
                "coin": coin,
                "side": side,
                "open_time": t_ms,
                "qty": 0.0,
                "entry_value": 0.0,
                "open_fees": 0.0,
                "close_time": None,
                "closed_qty": 0.0,
                "exit_value": 0.0,
                "closed_pnl": 0.0,
                "close_fees": 0.0,
                "fills_open": [],
                "fills_close": [],
            })
            trade["closed_qty"] += sz
            trade["exit_value"] += px * sz
            trade["closed_pnl"] += pnl
            trade["close_fees"] += fee
            trade["close_time"] = t_ms
            trade["fills_close"].append(fill)
            if trade["qty"] > 0 and trade["closed_qty"] >= trade["qty"] - 1e-12:
                trades.append(trade)
                del open_trades[key]
    trades.extend(open_trades.values())
    trades.sort(key=lambda row: int(row.get("open_time") or 0))
    return trades, open_trades


def entry_price(trade: dict[str, Any]) -> float:
    qty = fnum(trade.get("qty"))
    return fnum(trade.get("entry_value")) / qty if qty else 0.0


def exit_price(trade: dict[str, Any]) -> float | None:
    qty = fnum(trade.get("closed_qty"))
    return fnum(trade.get("exit_value")) / qty if qty else None


def candle_metrics(trade: dict[str, Any], candles: list[dict[str, Any]], end_ms: int) -> dict[str, Any]:
    entry = entry_price(trade)
    qty = fnum(trade.get("qty"))
    start = int(trade.get("open_time") or 0) - 60_000
    end = int(trade.get("close_time") or 0) or end_ms
    selected = [row for row in candles if start <= int(row.get("t") or 0) <= end + 60_000]
    if not selected or entry <= 0 or qty <= 0:
        return {}
    high = max(fnum(row.get("h")) for row in selected)
    low = min(fnum(row.get("l")) for row in selected)
    side = str(trade.get("side"))
    if side == "long":
        mfe = high - entry
        mae = low - entry
    else:
        mfe = entry - low
        mae = entry - high
    return {
        "high": high,
        "low": low,
        "mfe_pct": pct(mfe, entry),
        "mae_pct": pct(mae, entry),
        "mfe_usd": mfe * qty,
        "mae_usd": mae * qty,
        "candles": len(selected),
    }


def match_submit(trade: dict[str, Any], journal_rows: list[dict[str, Any]]) -> dict[str, Any] | None:
    coin = trade.get("coin")
    side = trade.get("side")
    opened = int(trade.get("open_time") or 0)
    candidates = [
        row for row in journal_rows
        if row.get("event") == "submit"
        and row.get("coin") == coin
        and row.get("side") == side
        and abs(int(row.get("ts") or 0) - opened) <= 15 * 60_000
    ]
    if not candidates:
        return None
    return min(candidates, key=lambda row: abs(int(row.get("ts") or 0) - opened))


def trade_verdict(trade: dict[str, Any], submit: dict[str, Any] | None, metrics: dict[str, Any]) -> list[str]:
    verdicts: list[str] = []
    gross = fnum(trade.get("closed_pnl"))
    fees = fnum(trade.get("open_fees")) + fnum(trade.get("close_fees"))
    if trade.get("close_time") and gross - fees < 0:
        verdicts.append("loss_after_fees")
    if metrics:
        if metrics.get("mfe_usd", 0.0) > abs(gross) and gross < 0:
            verdicts.append("gave_back_large_mfe")
        if metrics.get("mfe_pct", 0.0) < 0.2 and gross < 0:
            verdicts.append("wrong_quickly")
    if submit:
        extra = submit.get("exit_plan_metrics") if isinstance(submit.get("exit_plan_metrics"), dict) else {}
        noise = extra.get("noise_state") if isinstance(extra.get("noise_state"), dict) else {}
        entry = fnum(submit.get("entry"))
        stop = fnum(submit.get("stop"))
        stop_dist = abs(entry - stop)
        floor = fnum(noise.get("dynamic_stop_floor"))
        atr = fnum(noise.get("atr14"))
        if floor and stop_dist < floor * 1.25:
            verdicts.append("borderline_noise_stop")
        if atr and stop_dist < atr:
            verdicts.append("stop_below_atr")
        ws_stale = _submit_ws_stale_s(submit)
        if ws_stale is not None and ws_stale > 20:
            verdicts.append("stale_ws_context")
        playbook = submit.get("playbook_match") if isinstance(submit.get("playbook_match"), dict) else {}
        if "breakout" in str(playbook.get("playbook") or "").lower():
            if "retest" not in json.dumps(playbook, sort_keys=True).lower():
                verdicts.append("breakout_without_retest_text")
    return verdicts


def _submit_ws_stale_s(submit: dict[str, Any]) -> float | None:
    summary = submit.get("market_summary")
    if not isinstance(summary, list):
        return None
    coin = submit.get("coin")
    for row in summary:
        if isinstance(row, dict) and row.get("coin") == coin:
            ws = row.get("ws") if isinstance(row.get("ws"), dict) else {}
            if ws.get("stale_s") is not None:
                return fnum(ws.get("stale_s"))
    return None


def render_markdown(report: dict[str, Any]) -> str:
    lines = [
        f"# Trade Forensics - {report['generated_at']}",
        "",
        f"Window: `{report['window_start']}` -> `{report['window_end']}`",
        "",
        "## Summary",
        "",
        f"- Closed trades: `{report['summary']['closed_count']}`",
        f"- Open trades: `{report['summary']['open_count']}`",
        f"- Gross closed PnL: `${report['summary']['closed_gross']:+.2f}`",
        f"- Fees: `${report['summary']['fees']:.2f}`",
        f"- Net closed PnL: `${report['summary']['closed_net']:+.2f}`",
        "",
        "## Trades",
        "",
    ]
    for row in report["trades"]:
        lines.extend([
            f"### {row['status']} {row['coin']} {row['side'].upper()}",
            "",
            f"- Time: `{row['open_time']}` -> `{row['close_time']}`",
            f"- Entry/Exit: `{row['entry']}` -> `{row['exit']}`",
            f"- Gross/Fees/Net: `${row['gross_pnl']:+.2f}` / `${row['fees']:.2f}` / `{row['net_pnl']}`",
            f"- MFE/MAE: `{row.get('mfe_pct', '-')}` / `{row.get('mae_pct', '-')}`",
            f"- Verdicts: `{', '.join(row['verdicts']) or 'ok'}`",
        ])
        if row.get("setup_type"):
            lines.append(f"- Setup: `{row['setup_type']}`")
        if row.get("reason"):
            lines.append(f"- LLM reason: {row['reason']}")
        if row.get("trigger_level"):
            lines.append(f"- Trigger: {row['trigger_level']}")
        if row.get("exit_plan"):
            lines.append(f"- Exit plan: {row['exit_plan']}")
        lines.append("")
    lines.extend([
        "## Suggested Next Checks",
        "",
        "- `wrong_quickly`: entry trigger likely not confirmed; tighten playbook/retest gate.",
        "- `gave_back_large_mfe`: TP/scaleout/active management decision is required; with BE off this is expected risk.",
        "- `stale_ws_context`: do not allow live orderflow entries from stale websocket data.",
        "- `borderline_noise_stop`: widen stop or wait for better entry/target.",
        "",
    ])
    return "\n".join(lines)


def build_report(args: argparse.Namespace) -> dict[str, Any]:
    address = args.address or os.environ.get("HYPERLIQUID_ACCOUNT_ADDRESS")
    if not address:
        raise SystemExit("Missing --address or HYPERLIQUID_ACCOUNT_ADDRESS")
    start_ms = parse_time(args.since, args.hours)
    end_ms = int(time.time() * 1000)
    journal_rows = load_journal(Path(args.journal), start_ms)
    fills = fetch_fills(address, start_ms, end_ms)
    trades, _open = group_trades(fills)

    candles_by_coin: dict[str, list[dict[str, Any]]] = {}
    for coin in sorted({str(row.get("coin")) for row in trades}):
        candles_by_coin[coin] = fetch_candles(coin, start_ms - 10 * 60_000, end_ms + 60_000)
        time.sleep(args.sleep)

    rows: list[dict[str, Any]] = []
    closed_gross = 0.0
    fees_total = 0.0
    closed_count = 0
    for trade in trades:
        submit = match_submit(trade, journal_rows)
        metrics = candle_metrics(trade, candles_by_coin.get(str(trade.get("coin")), []), end_ms)
        gross = fnum(trade.get("closed_pnl"))
        fees = fnum(trade.get("open_fees")) + fnum(trade.get("close_fees"))
        closed = bool(trade.get("close_time")) and fnum(trade.get("closed_qty")) >= fnum(trade.get("qty")) - 1e-12
        if closed:
            closed_count += 1
            closed_gross += gross
            fees_total += fees
        playbook = submit.get("playbook_match") if isinstance(submit, dict) and isinstance(submit.get("playbook_match"), dict) else {}
        exit_plan = submit.get("exit_plan") if isinstance(submit, dict) and isinstance(submit.get("exit_plan"), dict) else {}
        rows.append({
            "status": "closed" if closed else "open",
            "coin": trade.get("coin"),
            "side": trade.get("side"),
            "open_time": fmt_time(int(trade.get("open_time") or 0)),
            "close_time": fmt_time(int(trade.get("close_time") or 0)) if closed else "open",
            "entry": fmt_num(entry_price(trade)),
            "exit": fmt_num(exit_price(trade)) if exit_price(trade) is not None else "-",
            "gross_pnl": gross,
            "fees": fees,
            "net_pnl": f"${gross - fees:+.2f}" if closed else "-",
            "mfe_pct": f"{metrics.get('mfe_pct', 0.0):+.2f}%" if metrics else "-",
            "mae_pct": f"{metrics.get('mae_pct', 0.0):+.2f}%" if metrics else "-",
            "mfe_usd": metrics.get("mfe_usd"),
            "mae_usd": metrics.get("mae_usd"),
            "setup_type": submit.get("setup_type") if submit else None,
            "reason": submit.get("reason") if submit else None,
            "trigger_level": playbook.get("trigger_level"),
            "exit_plan": "; ".join(
                str(exit_plan.get(key) or "")
                for key in ("stop_invalidation_level", "target_level", "rr_after_noise_stop")
                if exit_plan.get(key)
            ),
            "verdicts": trade_verdict(trade, submit, metrics),
        })

    return {
        "generated_at": dt.datetime.now(TZ).strftime("%Y-%m-%d %H:%M:%S %Z"),
        "window_start": fmt_time(start_ms),
        "window_end": fmt_time(end_ms),
        "summary": {
            "closed_count": closed_count,
            "open_count": len(rows) - closed_count,
            "closed_gross": round(closed_gross, 6),
            "fees": round(fees_total, 6),
            "closed_net": round(closed_gross - fees_total, 6),
        },
        "trades": rows,
    }


def main() -> int:
    parser = argparse.ArgumentParser(description="Hyperliquid V2 trade forensics.")
    parser.add_argument("--address", help="Account address. Defaults to HYPERLIQUID_ACCOUNT_ADDRESS.")
    parser.add_argument("--journal", default="data/journal.jsonl")
    parser.add_argument("--hours", type=float, default=24.0)
    parser.add_argument("--since", help="ISO datetime, default now-hours. Naive values are Europe/Berlin.")
    parser.add_argument("--sleep", type=float, default=0.20, help="Pause between candle requests.")
    parser.add_argument("--json", action="store_true")
    parser.add_argument("--out", help="Write Markdown report to this path.")
    args = parser.parse_args()

    report = build_report(args)
    if args.json:
        print(json.dumps(report, indent=2, ensure_ascii=False))
    else:
        rendered = render_markdown(report)
        print(rendered)
        if args.out:
            out = Path(args.out)
            out.parent.mkdir(parents=True, exist_ok=True)
            out.write_text(rendered)
            print(f"\nwrote {out}")
    return 0


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