#!/usr/bin/env python3
"""Read-only live trading forensics for the Hyperliquid V2 bot.

Pulls exchange fills and open positions, joins them with the local journal,
and flags obvious orderflow-research violations. No keys are required and no
orders are sent.
"""
from __future__ import annotations

import argparse
import json
import os
import re
import sys
import urllib.request
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any


BERLIN = timezone(timedelta(hours=2), name="Europe/Berlin")
HL_INFO_URL = "https://api.hyperliquid.xyz/info"


@dataclass
class FillStats:
    fills: int = 0
    open_fills: int = 0
    close_fills: int = 0
    wins: int = 0
    losses: int = 0
    gross_pnl: float = 0.0
    fees: float = 0.0

    @property
    def net_pnl(self) -> float:
        return self.gross_pnl - self.fees


@dataclass
class TradeRow:
    coin: str
    side: str
    open_time: int
    close_time: int | None = None
    entry_px: float = 0.0
    exit_px: float = 0.0
    sz: float = 0.0
    close_sz: float = 0.0
    gross_pnl: float = 0.0
    fees: float = 0.0
    submit: dict[str, Any] | None = None

    @property
    def net_pnl(self) -> float:
        return self.gross_pnl - self.fees

    @property
    def closed(self) -> bool:
        return self.close_time is not None and self.close_sz >= self.sz * 0.999


def post_info(payload: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
    body = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        HL_INFO_URL,
        data=body,
        headers={"content-type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=20) as resp:
        return json.loads(resp.read().decode("utf-8"))


def parse_time(value: str | None, hours: float | None) -> int:
    if value:
        raw = value.strip().replace("T", " ")
        for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
            try:
                dt = datetime.strptime(raw, fmt)
                return int(dt.replace(tzinfo=BERLIN).astimezone(timezone.utc).timestamp() * 1000)
            except ValueError:
                pass
        raise SystemExit(f"Could not parse --since: {value!r}")
    if hours is None:
        hours = 24.0
    return int((datetime.now(timezone.utc) - timedelta(hours=hours)).timestamp() * 1000)


def fmt_ts(ms: int) -> str:
    return datetime.fromtimestamp(ms / 1000, BERLIN).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 fill_is_close(fill: dict[str, Any]) -> bool:
    direction = str(fill.get("dir") or "").lower()
    return "close" in direction or abs(fnum(fill.get("closedPnl"))) > 0


def summarize_fills(fills: list[dict[str, Any]], since_ms: int) -> tuple[FillStats, dict[str, FillStats]]:
    stats = FillStats()
    by_coin: dict[str, FillStats] = defaultdict(FillStats)
    for fill in fills:
        if int(fill.get("time") or 0) < since_ms:
            continue
        coin = str(fill.get("coin") or "?")
        fee = abs(fnum(fill.get("fee")))
        pnl = fnum(fill.get("closedPnl"))
        is_close = fill_is_close(fill)

        for bucket in (stats, by_coin[coin]):
            bucket.fills += 1
            bucket.fees += fee
            bucket.gross_pnl += pnl
            if is_close:
                bucket.close_fills += 1
                if pnl > 0:
                    bucket.wins += 1
                elif pnl < 0:
                    bucket.losses += 1
            else:
                bucket.open_fills += 1
    return stats, dict(sorted(by_coin.items(), key=lambda kv: kv[1].net_pnl))


def fill_side(fill: dict[str, Any]) -> str:
    direction = str(fill.get("dir") or "").lower()
    if "long" in direction:
        return "long"
    if "short" in direction:
        return "short"
    return ""


def reconstruct_trades(fills: list[dict[str, Any]], submits: list[dict[str, Any]]) -> tuple[list[TradeRow], list[dict[str, Any]]]:
    active: dict[str, TradeRow] = {}
    closed: list[TradeRow] = []
    orphan_closes: list[dict[str, Any]] = []
    sorted_fills = sorted(fills, key=lambda f: (int(f.get("time") or 0), str(f.get("tid") or "")))

    def match_submit(trade: TradeRow) -> dict[str, Any] | None:
        candidates = []
        for event in submits:
            if event.get("coin") != trade.coin or event.get("side") != trade.side:
                continue
            dt = abs(int(event.get("ts") or 0) - trade.open_time)
            if dt <= 20 * 60 * 1000:
                candidates.append((dt, event))
        candidates.sort(key=lambda x: x[0])
        return candidates[0][1] if candidates else None

    for fill in sorted_fills:
        coin = str(fill.get("coin") or "?")
        side = fill_side(fill)
        if not side:
            continue
        ts = int(fill.get("time") or 0)
        px = fnum(fill.get("px"))
        sz = fnum(fill.get("sz"))
        fee = abs(fnum(fill.get("fee")))
        pnl = fnum(fill.get("closedPnl"))
        is_close = fill_is_close(fill)

        if not is_close:
            trade = active.get(coin)
            if trade is None or trade.side != side:
                trade = TradeRow(coin=coin, side=side, open_time=ts)
                trade.submit = match_submit(trade)
                active[coin] = trade
            new_size = trade.sz + sz
            trade.entry_px = ((trade.entry_px * trade.sz) + (px * sz)) / new_size if new_size else px
            trade.sz = new_size
            trade.fees += fee
            continue

        trade = active.get(coin)
        if trade is None or trade.side != side:
            orphan_closes.append(fill)
            continue
        new_close_size = trade.close_sz + sz
        trade.exit_px = ((trade.exit_px * trade.close_sz) + (px * sz)) / new_close_size if new_close_size else px
        trade.close_sz = new_close_size
        trade.close_time = ts
        trade.gross_pnl += pnl
        trade.fees += fee
        if trade.closed:
            closed.append(trade)
            del active[coin]

    still_open = list(active.values())
    return closed + still_open, orphan_closes


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": max(0, start_ms),
            "endTime": end_ms,
        },
    }
    data = post_info(payload)
    return data if isinstance(data, list) else []


def trade_mfe_mae(trade: TradeRow, candles: list[dict[str, Any]]) -> tuple[float, float]:
    if not candles or trade.entry_px <= 0:
        return 0.0, 0.0
    start = trade.open_time
    end = trade.close_time or int(datetime.now(timezone.utc).timestamp() * 1000)
    highs = [fnum(c.get("h")) for c in candles if start <= int(c.get("t") or 0) <= end]
    lows = [fnum(c.get("l")) for c in candles if start <= int(c.get("t") or 0) <= end]
    if not highs or not lows:
        return 0.0, 0.0
    if trade.side == "long":
        mfe = (max(highs) - trade.entry_px) / trade.entry_px * 100
        mae = (trade.entry_px - min(lows)) / trade.entry_px * 100
    else:
        mfe = (trade.entry_px - min(lows)) / trade.entry_px * 100
        mae = (max(highs) - trade.entry_px) / trade.entry_px * 100
    return max(0.0, mfe), max(0.0, mae)


def classify_exit_source(trade: TradeRow, journal_events: list[dict[str, Any]]) -> str:
    """Best-effort exit provenance from price levels.

    Hyperliquid userFills do not reliably tell us whether a close was manual
    or a bot-managed TP/SL in this script. This classifies only obvious level
    matches and leaves the rest as manual_or_unknown.
    """
    if not trade.closed:
        return "open"
    if not trade.submit:
        return "manual_or_unknown"
    if trade.entry_px <= 0 or trade.exit_px <= 0:
        return "manual_or_unknown"

    tolerance = max(trade.entry_px * 0.0008, abs(trade.entry_px) * 0.0001)
    target = fnum(trade.submit.get("target"))
    stop = fnum(trade.submit.get("stop"))

    if _level_hit(trade.side, trade.exit_px, target, "target", tolerance):
        return "target_or_manual_at_target"
    if _level_hit(trade.side, trade.exit_px, stop, "stop", tolerance):
        return "stop_or_manual_at_stop"

    managed_levels: list[float] = []
    for event in journal_events:
        if event.get("coin") != trade.coin or event.get("side") != trade.side:
            continue
        ts = int(event.get("ts") or 0)
        if ts < trade.open_time or (trade.close_time and ts > trade.close_time + 2 * 60 * 1000):
            continue
        if event.get("event") in ("stop_to_break_even", "profit_lock"):
            level = fnum(event.get("stop"))
            if level > 0:
                managed_levels.append(level)

    for level in managed_levels:
        if _near(trade.exit_px, level, tolerance):
            return "manager_stop_or_manual_at_level"

    return "manual_or_unknown"


def _near(px: float, level: float, tolerance: float) -> bool:
    return level > 0 and abs(px - level) <= tolerance


def _level_hit(side: str, px: float, level: float, kind: str, tolerance: float) -> bool:
    if level <= 0:
        return False
    if _near(px, level, tolerance):
        return True
    if kind == "target":
        return px >= level - tolerance if side == "long" else px <= level + tolerance
    return px <= level + tolerance if side == "long" else px >= level - tolerance


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


def text_blob(event: dict[str, Any]) -> str:
    parts: list[str] = []
    for key in ("setup_type", "reason", "side"):
        parts.append(str(event.get(key) or ""))
    parts.extend(str(x) for x in event.get("evidence") or [])
    extra = event.get("extra") or {}
    parts.append(str(extra.get("reason") or ""))
    lr = extra.get("level_reasoning") or {}
    if isinstance(lr, dict):
        parts.extend(str(v) for v in lr.values())
    tp = extra.get("trade_plan") or {}
    if isinstance(tp, dict):
        parts.extend(str(v) for v in tp.values())
    lr = event.get("level_reasoning") or {}
    if isinstance(lr, dict):
        parts.extend(str(v) for v in lr.values())
    tp = event.get("trade_plan") or {}
    if isinstance(tp, dict):
        parts.extend(str(v) for v in tp.values())
    return " ".join(parts).lower()


def entry_blob(event: dict[str, Any]) -> str:
    """Only the text that justifies the actual entry, not later no-reentry notes."""
    parts: list[str] = [
        str(event.get("setup_type") or ""),
        str(event.get("reason") or ""),
        str(event.get("side") or ""),
    ]
    parts.extend(str(x) for x in event.get("evidence") or [])

    extra = event.get("extra") or {}
    for source in (event, extra):
        lr = source.get("level_reasoning") or {}
        if isinstance(lr, dict):
            parts.append(str(lr.get("entry") or ""))
        tp = source.get("trade_plan") or {}
        if isinstance(tp, dict):
            parts.append(str(tp.get("thesis") or ""))
            parts.append(str(tp.get("entry_trigger") or ""))
    return " ".join(parts).lower()


def metric_value(blob: str, name: str) -> float | None:
    pattern = rf"\b{name}\s*[=:]\s*([+-]?\d+(?:\.\d+)?)"
    match = re.search(pattern, blob)
    if not match:
        return None
    return fnum(match.group(1))


def research_warnings(event: dict[str, Any]) -> list[str]:
    """Heuristic checks mapped to /root/wiki/raw/orderflow* playbooks."""
    side = str(event.get("side") or "").lower()
    setup_type = str(event.get("setup_type") or "").lower()
    blob = text_blob(event)
    entry_text = entry_blob(event)
    warnings: list[str] = []
    ask_abs = metric_value(blob, "ask_absorption")
    bid_abs = metric_value(blob, "bid_absorption")

    if side == "long" and ask_abs is not None and ask_abs >= 0.5:
        warnings.append(f"long has ask_absorption={ask_abs:.2f}; research says buyers may be absorbed")
    if side == "short" and bid_abs is not None and bid_abs >= 0.5:
        warnings.append(f"short has bid_absorption={bid_abs:.2f}; research says sellers may be trapped")

    if side == "long" and "below vwap" in entry_text and not any(k in entry_text for k in ("reclaim", "sweep", "absor", "failed low")):
        warnings.append("long below VWAP without reclaim/sweep/absorption confirmation")
    if side == "short" and "above vwap" in entry_text and not any(k in entry_text for k in ("loss", "lost", "failed", "sweep", "absor")):
        warnings.append("short above VWAP without loss/failed-push/absorption confirmation")

    if "trend_continuation" in setup_type and not any(k in entry_text for k in ("pullback", "retest", "held vwap", "vwap hold", "failed", "acceptance")):
        warnings.append("trend continuation lacks pullback/retest/acceptance wording")

    if "current mid" in entry_text and not any(k in entry_text for k in ("reclaim", "pullback", "failed", "sweep", "acceptance", "hold")):
        warnings.append("entry at current mid may be chase; research prefers trigger/reclaim/pullback")

    if "tape" in entry_text and not any(k in entry_text for k in ("price lifting", "price falling", "reclaim", "failed", "stalls", "absorption")):
        warnings.append("tape cited without price-response confirmation")

    return warnings


def open_positions(user_state: dict[str, Any]) -> list[dict[str, Any]]:
    positions = []
    for item in user_state.get("assetPositions", []):
        pos = item.get("position") or {}
        szi = fnum(pos.get("szi"))
        if abs(szi) <= 0:
            continue
        positions.append(
            {
                "coin": pos.get("coin"),
                "side": "long" if szi > 0 else "short",
                "szi": szi,
                "entry": fnum(pos.get("entryPx")),
                "unrealized": fnum(pos.get("unrealizedPnl")),
                "liq": fnum(pos.get("liquidationPx")),
                "leverage": (pos.get("leverage") or {}).get("value"),
            }
        )
    return positions


def print_report(args: argparse.Namespace) -> int:
    since_ms = parse_time(args.since, args.hours)
    address = args.address or os.environ.get("HYPERLIQUID_ACCOUNT_ADDRESS")
    if not address:
        raise SystemExit("Set --address or HYPERLIQUID_ACCOUNT_ADDRESS")

    fills_raw = post_info({"type": "userFills", "user": address})
    if not isinstance(fills_raw, list):
        fills_raw = []
    fills = [f for f in fills_raw if int(f.get("time") or 0) >= since_ms]

    state_raw = post_info({"type": "clearinghouseState", "user": address})
    if not isinstance(state_raw, dict):
        state_raw = {}
    positions = open_positions(state_raw)
    unrealized = sum(p["unrealized"] for p in positions)

    stats, by_coin = summarize_fills(fills, since_ms)
    journal_events = load_journal(Path(args.journal), since_ms)
    submits = [e for e in journal_events if e.get("event") == "submit"]
    rejects = [e for e in journal_events if e.get("event") == "risk_reject"]
    waits = [e for e in journal_events if e.get("event") == "wait"]
    order_errors = [e for e in journal_events if e.get("event") == "order_error"]
    trades, orphan_closes = reconstruct_trades(fills, submits)

    print(f"Live Forensics since {fmt_ts(since_ms)}")
    print("=" * 72)
    print(f"Address: {address}")
    print(
        "Fills: {fills} | opens {opens} | closes {closes} | wins {wins} | losses {losses}".format(
            fills=stats.fills,
            opens=stats.open_fills,
            closes=stats.close_fills,
            wins=stats.wins,
            losses=stats.losses,
        )
    )
    print(f"Gross closed PnL: {stats.gross_pnl:+.4f} USDC")
    print(f"Fees:             -{stats.fees:.4f} USDC")
    print(f"Net realized:     {stats.net_pnl:+.4f} USDC")
    print(f"Open unrealized:  {unrealized:+.4f} USDC")
    print(f"Realized+open:    {stats.net_pnl + unrealized:+.4f} USDC")
    print("Note: exchange fills can include manual closes; use exit_source below before calling it bot edge.")
    print()

    print("By Coin")
    print("-" * 72)
    for coin, st in by_coin.items():
        print(
            f"{coin:8} net={st.net_pnl:+8.4f} gross={st.gross_pnl:+8.4f} "
            f"fees={st.fees:6.4f} closes={st.close_fills:2d} W/L={st.wins}/{st.losses}"
        )
    print()

    print("Open Positions")
    print("-" * 72)
    if positions:
        for p in positions:
            print(
                f"{p['coin']:8} {p['side']:5} szi={p['szi']:+.8g} entry={p['entry']:.8g} "
                f"uPnL={p['unrealized']:+.4f} liq={p['liq']:.8g} x{p['leverage']}"
            )
    else:
        print("none")
    print()

    print("Journal")
    print("-" * 72)
    print(
        f"submits={len(submits)} risk_rejects={len(rejects)} waits={len(waits)} order_errors={len(order_errors)}"
    )
    if order_errors:
        for e in order_errors[-5:]:
            print(f"order_error {fmt_ts(int(e.get('ts') or 0))} {e.get('coin')} {e.get('side')}: {(e.get('extra') or {}).get('error')}")
    if orphan_closes:
        print(f"orphan_closes={len(orphan_closes)} (positions opened before report window)")
    print()

    print("Trade Reconstruction")
    print("-" * 72)
    if not trades:
        print("none")
    candle_cache: dict[str, list[dict[str, Any]]] = {}
    if args.ohlc and trades:
        now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
        for coin in sorted({t.coin for t in trades}):
            starts = [t.open_time for t in trades if t.coin == coin]
            ends = [t.close_time or now_ms for t in trades if t.coin == coin]
            candle_cache[coin] = fetch_candles(
                coin,
                min(starts) - 10 * 60 * 1000,
                max(ends) + 10 * 60 * 1000,
                args.ohlc_interval,
            )
    for trade in sorted(trades, key=lambda t: t.open_time):
        status = "closed" if trade.closed else "open"
        exit_source = classify_exit_source(trade, journal_events)
        mfe = mae = 0.0
        if args.ohlc:
            mfe, mae = trade_mfe_mae(trade, candle_cache.get(trade.coin, []))
        submit = trade.submit or {}
        setup = submit.get("setup_type") or "?"
        stop = fnum(submit.get("stop"))
        target = fnum(submit.get("target"))
        stop_note = ""
        if stop and trade.entry_px:
            stop_pct = abs(trade.entry_px - stop) / trade.entry_px * 100
            stop_note = f" stop={stop:.8g} ({stop_pct:.2f}%)"
        target_note = f" target={target:.8g}" if target else ""
        ohlc_note = f" MFE={mfe:.2f}% MAE={mae:.2f}%" if args.ohlc else ""
        print(
            f"{fmt_ts(trade.open_time)} {trade.coin:8} {trade.side:5} {status:6} "
            f"entry={trade.entry_px:.8g} exit={trade.exit_px:.8g} "
            f"net={trade.net_pnl:+.4f} fees={trade.fees:.4f} exit={exit_source} {setup}"
            f"{stop_note}{target_note}{ohlc_note}"
        )
        if args.ohlc and trade.closed and trade.net_pnl < 0 and mfe > mae:
            print("  - OHLC note: loss despite larger favorable excursion; inspect exit/BE/management")
        if args.ohlc and trade.closed and trade.net_pnl < 0 and mfe < 0.25:
            print("  - OHLC note: no meaningful MFE; entry thesis likely wrong or too late")
    print()

    print("Research Gate Warnings")
    print("-" * 72)
    any_warning = False
    for event in submits[-args.last_submits :]:
        warnings = research_warnings(event)
        if not warnings:
            continue
        any_warning = True
        ts = fmt_ts(int(event.get("ts") or 0))
        label = f"{ts} {event.get('coin')} {event.get('side')} {event.get('setup_type')}"
        print(label)
        for warning in warnings:
            print(f"  - {warning}")
    if not any_warning:
        print("none detected in recent submits")
    print()

    print("Research Sources Used")
    print("-" * 72)
    print("/root/wiki/raw/orderflow-basics.md")
    print("/root/wiki/raw/orderflow-strategies.md")
    print("/root/wiki/raw/orderflow-comprehensive-summary.md")
    print("/root/wiki/raw/hyperliquid-orderflow-research-2026-05-23.md")
    print("/root/wiki/raw/hyperliquid-data-strategy-playbook-2026-05-23.md")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description="Read-only Hyperliquid live forensics")
    parser.add_argument("--hours", type=float, default=24.0)
    parser.add_argument("--since", help="Berlin time, e.g. '2026-05-23 06:30'")
    parser.add_argument("--address", help="Hyperliquid account address")
    parser.add_argument("--journal", default="data/journal.jsonl")
    parser.add_argument("--last-submits", type=int, default=12)
    parser.add_argument("--ohlc", action="store_true", help="Fetch 5m candles and calculate MFE/MAE per reconstructed trade")
    parser.add_argument("--ohlc-interval", default="5m")
    return print_report(parser.parse_args())


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