from __future__ import annotations

import argparse
import html
import json
import os
import requests
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping

from config import BotConfig, RuntimePaths
from strategy_registry import load_strategy_presets


@dataclass(frozen=True)
class StrategyScorecard:
    status: str
    score: int
    sample_size: int
    win_rate_pct: float
    profit_factor: float
    max_drawdown_usd: float
    max_consecutive_losses: int
    coin_leakage_pct: float
    avg_risk_usd: float
    total_pnl_usd: float
    realized_pnl_usd: float
    unrealized_pnl_usd: float
    reasons: tuple[str, ...]


@dataclass(frozen=True)
class PaperBotStatus:
    strategy_id: str
    pid: int | None
    running: bool
    journal_events: int
    entries: int
    closed_trades: int
    realized_pnl_usd: float
    near_miss_events: int
    near_misses: tuple[dict, ...]
    open_position_count: int
    open_unrealized_pnl_usd: float
    open_positions: tuple[dict, ...]
    scorecard: StrategyScorecard
    research_note: str
    last_log_lines: tuple[str, ...]


@dataclass(frozen=True)
class DashboardSnapshot:
    runtime_dir: Path
    live_orders_enabled: bool
    paper_bots: tuple[PaperBotStatus, ...]
    reports: Mapping[str, str]
    kill_switch_present: bool


def _pid_running(pid: int | None) -> bool:
    if pid is None:
        return False
    try:
        os.kill(pid, 0)
    except OSError:
        return False
    return True


def _read_process_env(pid: int | None) -> dict[str, str]:
    if pid is None:
        return {}
    try:
        raw = Path(f"/proc/{pid}/environ").read_bytes()
    except OSError:
        return {}
    env: dict[str, str] = {}
    for item in raw.split(b"\0"):
        if not item or b"=" not in item:
            continue
        key, value = item.split(b"=", 1)
        env[key.decode("utf-8", errors="replace")] = value.decode("utf-8", errors="replace")
    return env


def _pid_matches_paper_strategy(pid: int | None, *, strategy_id: str, runtime_dir: Path) -> bool:
    """Return true only for the exact paper AutoTrader strategy process.

    A stale bot.pid can be reused by an unrelated process. The dashboard/scorecard
    must therefore verify the process env, not only os.kill(pid, 0).
    """
    if not _pid_running(pid):
        return False
    env = _read_process_env(pid)
    return (
        env.get("CTB_STRATEGY_ID") == strategy_id
        and env.get("CTB_RUNTIME_DIR") == str(runtime_dir)
        and env.get("CTB_PAPER_TRADING") == "true"
        and env.get("CTB_DRY_RUN") == "false"
    )


def _read_pid(path: Path) -> int | None:
    try:
        return int(path.read_text(encoding="utf-8").strip())
    except (FileNotFoundError, ValueError):
        return None


def _tail(path: Path, limit: int = 5) -> tuple[str, ...]:
    try:
        lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
    except FileNotFoundError:
        return ()
    return tuple(lines[-limit:])


def _count_lines(path: Path) -> int:
    try:
        return sum(1 for line in path.read_text(encoding="utf-8", errors="replace").splitlines() if line.strip())
    except FileNotFoundError:
        return 0


def _read_near_misses(path: Path, limit: int = 5) -> tuple[dict, ...]:
    rows: list[dict] = []
    try:
        lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
    except FileNotFoundError:
        return ()
    for line in lines:
        if not line.strip():
            continue
        try:
            row = json.loads(line)
        except json.JSONDecodeError:
            continue
        if isinstance(row, dict):
            rows.append(row)
    rows.sort(key=lambda row: (float(row.get("distance_pct", 999.0)), -abs(float(row.get("drop_pct", 0.0)))))
    return tuple(rows[:limit])


def _journal_stats(path: Path) -> tuple[int, int, int, float]:
    events = 0
    entries = 0
    closed = 0
    pnl = 0.0
    if not path.exists():
        return events, entries, closed, pnl
    for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
        if not line.strip():
            continue
        events += 1
        try:
            row = json.loads(line)
        except json.JSONDecodeError:
            continue
        event = str(row.get("event") or row.get("event_type") or "").lower()
        if event == "entry":
            entries += 1
        if event == "exit":
            closed += 1
            pnl += float(row.get("realized_pnl_usd") or 0.0)
    return events, entries, closed, round(pnl, 8)





def _read_journal_rows(path: Path) -> tuple[dict, ...]:
    rows: list[dict] = []
    if not path.exists():
        return ()
    for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
        if not line.strip():
            continue
        try:
            row = json.loads(line)
        except json.JSONDecodeError:
            continue
        if isinstance(row, dict):
            rows.append(row)
    return tuple(rows)


def _score_strategy(
    *,
    journal_rows: tuple[dict, ...],
    open_unrealized_pnl_usd: float,
    open_position_count: int,
    research_note: str,
) -> StrategyScorecard:
    entries = [row for row in journal_rows if str(row.get("event") or row.get("event_type") or "").lower() == "entry"]
    exits = [row for row in journal_rows if str(row.get("event") or row.get("event_type") or "").lower() == "exit"]
    pnls = [float(row.get("realized_pnl_usd") or 0.0) for row in exits]
    wins = [pnl for pnl in pnls if pnl > 0]
    losses = [pnl for pnl in pnls if pnl < 0]
    realized = round(sum(pnls), 8)
    total_pnl = round(realized + open_unrealized_pnl_usd, 8)
    gross_profit = sum(wins)
    gross_loss = abs(sum(losses))
    if gross_loss > 0:
        profit_factor = gross_profit / gross_loss
    elif gross_profit > 0:
        profit_factor = 99.0
    else:
        profit_factor = 0.0

    equity = 0.0
    peak = 0.0
    max_dd = 0.0
    max_consecutive_losses = 0
    current_losses = 0
    for pnl in pnls:
        equity += pnl
        peak = max(peak, equity)
        max_dd = min(max_dd, equity - peak)
        if pnl < 0:
            current_losses += 1
            max_consecutive_losses = max(max_consecutive_losses, current_losses)
        else:
            current_losses = 0

    coin_counts: dict[str, int] = {}
    risk_values: list[float] = []
    for row in entries:
        coin = str(row.get("coin") or "UNKNOWN").upper()
        coin_counts[coin] = coin_counts.get(coin, 0) + 1
        extra = row.get("extra") if isinstance(row.get("extra"), dict) else {}
        try:
            risk = float(extra.get("risk_usd") or 0.0)
        except (TypeError, ValueError):
            risk = 0.0
        if risk > 0:
            risk_values.append(risk)
    coin_leakage = (max(coin_counts.values()) / len(entries) * 100.0) if entries else 0.0
    avg_risk = sum(risk_values) / len(risk_values) if risk_values else 0.0
    sample_size = len(exits)
    win_rate = (len(wins) / sample_size * 100.0) if sample_size else 0.0

    score = 0
    reasons: list[str] = []
    if sample_size >= 50:
        score += 30
    elif sample_size >= 30:
        score += 24
    elif sample_size >= 10:
        score += 14
    elif sample_size >= 5:
        score += 8
    else:
        reasons.append("sample < 5 closed trades")

    if profit_factor >= 2.0:
        score += 25
    elif profit_factor >= 1.5:
        score += 18
    elif profit_factor >= 1.1:
        score += 8
    else:
        reasons.append("profit factor below 1.1")

    if total_pnl > 0:
        score += 15
    else:
        reasons.append("total PnL not positive")

    if coin_leakage <= 50.0 and entries:
        score += 10
    elif coin_leakage > 70.0:
        reasons.append("coin leakage above 70%")

    if max_dd >= -2.0:
        score += 10
    elif max_dd < -5.0:
        reasons.append("drawdown above 5 USDC")

    if max_consecutive_losses <= 2:
        score += 5
    else:
        reasons.append("more than 2 consecutive losses")

    if open_position_count <= 2:
        score += 5
    else:
        reasons.append("too many open positions")

    if research_note:
        reasons.append("research sampler / not champion")

    if research_note or sample_size < 5:
        status = "Research"
    elif profit_factor < 1.1 or total_pnl <= 0:
        status = "Blocked"
    elif sample_size >= 30 and profit_factor >= 1.5 and total_pnl > 0 and coin_leakage <= 60.0 and max_dd >= -5.0:
        status = "Live-preview-ready"
    else:
        status = "Candidate"

    if not reasons:
        reasons.append("metrics acceptable for current stage")
    return StrategyScorecard(
        status=status,
        score=max(0, min(100, int(round(score)))),
        sample_size=sample_size,
        win_rate_pct=round(win_rate, 2),
        profit_factor=round(min(profit_factor, 99.0), 2),
        max_drawdown_usd=round(max_dd, 8),
        max_consecutive_losses=max_consecutive_losses,
        coin_leakage_pct=round(coin_leakage, 2),
        avg_risk_usd=round(avg_risk, 8),
        total_pnl_usd=total_pnl,
        realized_pnl_usd=realized,
        unrealized_pnl_usd=round(open_unrealized_pnl_usd, 8),
        reasons=tuple(reasons),
    )


def _read_reports(reports_dir: Path) -> dict[str, str]:
    reports = {}
    for path in sorted(reports_dir.glob("*.txt")):
        reports[path.stem] = path.read_text(encoding="utf-8", errors="replace")[:12_000]
    return reports


def _fetch_live_prices() -> dict[str, float]:
    try:
        response = requests.post("https://api.hyperliquid.xyz/info", json={"type": "allMids"}, timeout=5)
        response.raise_for_status()
        data = response.json()
        return {str(coin).upper(): float(price) for coin, price in data.items()}
    except Exception:
        return {}


def _read_open_positions(paper_state_path: Path, live_prices: Mapping[str, float]) -> tuple[tuple[dict, ...], float]:
    try:
        state = json.loads(paper_state_path.read_text(encoding="utf-8"))
    except (FileNotFoundError, json.JSONDecodeError):
        return (), 0.0
    positions = state.get("positions", {}) if isinstance(state, dict) else {}
    leverage_map = state.get("leverage", {}) if isinstance(state, dict) else {}
    rows: list[dict] = []
    total_unrealized = 0.0
    for coin, pos in sorted(positions.items()):
        try:
            contracts = float(pos.get("contracts", 0.0))
            entry_price = float(pos.get("entryPrice", 0.0))
        except (TypeError, ValueError, AttributeError):
            continue
        if contracts <= 0 or entry_price <= 0:
            continue
        coin = str(coin).upper()
        symbol = str(pos.get("symbol") or f"{coin}/USDC:USDC")
        side = str(pos.get("side", "long"))
        current_price = float(live_prices.get(coin, entry_price))
        direction = 1.0 if side == "long" else -1.0
        unrealized = (current_price - entry_price) * contracts * direction
        notional = current_price * contracts
        leverage = float(leverage_map.get(symbol, pos.get("leverage", 1.0)) or 1.0)
        margin = notional / leverage if leverage else 0.0
        row = {
            "coin": coin,
            "side": side,
            "contracts": round(contracts, 8),
            "entry_price": round(entry_price, 8),
            "current_price": round(current_price, 8),
            "unrealized_pnl_usd": round(unrealized, 8),
            "notional_usd": round(notional, 8),
            "margin_usd": round(margin, 8),
            "leverage": round(leverage, 8),
        }
        rows.append(row)
        total_unrealized += unrealized
    return tuple(rows), round(total_unrealized, 8)


def build_dashboard_snapshot(*, runtime_dir: str | Path | None = None) -> DashboardSnapshot:
    cfg = BotConfig.from_file()
    paths = RuntimePaths.from_config(cfg)
    root = Path(runtime_dir).expanduser().resolve() if runtime_dir is not None else paths.runtime_dir
    experiments = root / "experiments"
    presets = load_strategy_presets()
    live_prices = _fetch_live_prices()
    bots: list[PaperBotStatus] = []
    for exp_dir in sorted(experiments.glob("*")) if experiments.exists() else []:
        if not exp_dir.is_dir():
            continue
        pid = _read_pid(exp_dir / "bot.pid")
        journal_path = exp_dir / "Tradeanalyse" / "trade_journal.jsonl"
        events, entries, closed, pnl = _journal_stats(journal_path)
        journal_rows = _read_journal_rows(journal_path)
        near_miss_path = exp_dir / "Tradeanalyse" / "near_miss.jsonl"
        open_positions, open_unrealized = _read_open_positions(exp_dir / "paper_state.json", live_prices)
        preset = presets.get(exp_dir.name)
        research_note = "Research sampler — not a champion candidate" if preset and "research-sampler" in preset.tags else ""
        scorecard = _score_strategy(
            journal_rows=journal_rows,
            open_unrealized_pnl_usd=open_unrealized,
            open_position_count=len(open_positions),
            research_note=research_note,
        )
        bots.append(PaperBotStatus(
            strategy_id=exp_dir.name,
            pid=pid,
            running=_pid_matches_paper_strategy(pid, strategy_id=exp_dir.name, runtime_dir=exp_dir),
            journal_events=events,
            entries=entries,
            closed_trades=closed,
            realized_pnl_usd=pnl,
            near_miss_events=_count_lines(near_miss_path),
            near_misses=_read_near_misses(near_miss_path),
            open_position_count=len(open_positions),
            open_unrealized_pnl_usd=open_unrealized,
            open_positions=open_positions,
            scorecard=scorecard,
            research_note=research_note,
            last_log_lines=_tail(exp_dir / "bot.log"),
        ))
    return DashboardSnapshot(
        runtime_dir=root,
        live_orders_enabled=False,
        paper_bots=tuple(bots),
        reports=_read_reports(root / "reports"),
        kill_switch_present=(root / "KILL_SWITCH").exists(),
    )


def render_dashboard_html(snapshot: DashboardSnapshot) -> str:
    rows = []
    scorecard_rows = []
    open_position_blocks = []
    near_miss_blocks = []
    for bot in snapshot.paper_bots:
        log = "<br>".join(html.escape(line) for line in bot.last_log_lines)
        strategy_cell = html.escape(bot.strategy_id)
        if bot.research_note:
            strategy_cell += f"<br><span class='warn'>{html.escape(bot.research_note)}</span>"
        rows.append(
            "<tr>"
            f"<td>{strategy_cell}</td>"
            f"<td>{'yes' if bot.running else 'no'}</td>"
            f"<td>{bot.pid or ''}</td>"
            f"<td>{bot.journal_events}</td>"
            f"<td>{bot.closed_trades}</td>"
            f"<td>{bot.open_position_count}</td>"
            f"<td>{bot.open_unrealized_pnl_usd:+.4f}</td>"
            f"<td>{bot.near_miss_events}</td>"
            f"<td>{bot.realized_pnl_usd:+.4f}</td>"
            f"<td>{bot.scorecard.status}</td>"
            f"<td>{bot.scorecard.score}</td>"
            f"<td class='log'>{log}</td>"
            "</tr>"
        )
        scorecard_rows.append(
            "<tr>"
            f"<td>{strategy_cell}</td>"
            f"<td>{html.escape(bot.scorecard.status)}</td>"
            f"<td>{bot.scorecard.score}</td>"
            f"<td>{bot.scorecard.sample_size}</td>"
            f"<td>{bot.scorecard.win_rate_pct:.1f}%</td>"
            f"<td>{bot.scorecard.profit_factor:.2f}</td>"
            f"<td>{bot.scorecard.total_pnl_usd:+.4f}</td>"
            f"<td>{bot.scorecard.realized_pnl_usd:+.4f}</td>"
            f"<td>{bot.scorecard.unrealized_pnl_usd:+.4f}</td>"
            f"<td>{bot.scorecard.max_drawdown_usd:+.4f}</td>"
            f"<td>{bot.scorecard.max_consecutive_losses}</td>"
            f"<td>{bot.scorecard.coin_leakage_pct:.1f}%</td>"
            f"<td>{bot.scorecard.avg_risk_usd:.4f}</td>"
            f"<td>{html.escape('; '.join(bot.scorecard.reasons))}</td>"
            "</tr>"
        )
        if bot.open_positions:
            position_rows = []
            for pos in bot.open_positions:
                position_rows.append(
                    "<tr>"
                    f"<td>{html.escape(str(pos.get('coin', '')))}</td>"
                    f"<td>{html.escape(str(pos.get('side', '')))}</td>"
                    f"<td>{float(pos.get('contracts', 0.0)):.8f}</td>"
                    f"<td>{float(pos.get('entry_price', 0.0)):.8f}</td>"
                    f"<td>{float(pos.get('current_price', 0.0)):.8f}</td>"
                    f"<td>{float(pos.get('notional_usd', 0.0)):.2f}</td>"
                    f"<td>{float(pos.get('margin_usd', 0.0)):.2f}</td>"
                    f"<td>{float(pos.get('leverage', 0.0)):.2f}x</td>"
                    f"<td>{float(pos.get('unrealized_pnl_usd', 0.0)):+.4f}</td>"
                    "</tr>"
                )
            open_position_blocks.append(
                f"<h3>{html.escape(bot.strategy_id)}</h3>"
                "<table><thead><tr><th>Coin</th><th>Side</th><th>Contracts</th><th>Entry</th><th>Current</th><th>Notional</th><th>Margin</th><th>Lev</th><th>Unrealized</th></tr></thead><tbody>"
                + "\n".join(position_rows)
                + "</tbody></table>"
            )
        if bot.near_misses:
            detail_rows = []
            for row in bot.near_misses:
                detail_rows.append(
                    "<tr>"
                    f"<td>{html.escape(str(row.get('coin', '')))}</td>"
                    f"<td>{float(row.get('drop_pct', 0.0)):+.2f}%</td>"
                    f"<td>{float(row.get('required_drop_pct', 0.0)):.2f}%</td>"
                    f"<td>{float(row.get('distance_pct', 0.0)):.2f}%</td>"
                    f"<td>{html.escape(str(row.get('reason', '')))}</td>"
                    "</tr>"
                )
            near_miss_blocks.append(
                f"<h3>{html.escape(bot.strategy_id)}</h3>"
                "<table><thead><tr><th>Coin</th><th>Drop</th><th>Required</th><th>Distance</th><th>Reject reason</th></tr></thead><tbody>"
                + "\n".join(detail_rows)
                + "</tbody></table>"
            )
    report_blocks = []
    for name, content in snapshot.reports.items():
        report_blocks.append(f"<h2>{html.escape(name)}</h2><pre>{html.escape(content)}</pre>")
    return """<!doctype html>
<html><head><meta charset="utf-8"><title>CryptoTradingBot Dashboard</title>
<style>
body{font-family:system-ui,Arial,sans-serif;background:#0f1115;color:#e8e8e8;margin:24px} table{border-collapse:collapse;width:100%} th,td{border:1px solid #333;padding:8px;vertical-align:top} th{background:#1e2430} pre{background:#171b22;padding:12px;overflow:auto}.ok{color:#8bd17c}.warn{color:#ffcc66}.log{font-size:12px;color:#b9c0cc}
</style></head><body>
<h1>CryptoTradingBot Dashboard</h1>
<p><strong>Read-only</strong>: no execution controls, no order buttons, no config mutation.</p>
<p>Runtime: __RUNTIME__</p>
<p>Live orders enabled: <span class="ok">no</span> | Kill switch present: __KILL__</p>
<h2>Paper Bots</h2>
<table><thead><tr><th>Strategy</th><th>Running</th><th>PID</th><th>Journal events</th><th>Closed trades</th><th>Open pos</th><th>Open PnL</th><th>Near misses</th><th>PnL USDC</th><th>Status</th><th>Score</th><th>Last log</th></tr></thead><tbody>
__ROWS__
</tbody></table>
<h2>Live-readiness Strategy Scorecard</h2>
<p>Read-only scoring. <strong>Research</strong> means data collection only; <strong>Live-preview-ready</strong> means preview discussion may start, not live autopilot.</p>
<table><thead><tr><th>Strategy</th><th>Status</th><th>Score</th><th>Closed</th><th>Winrate</th><th>PF</th><th>Total PnL</th><th>Realized</th><th>Unrealized</th><th>Max DD</th><th>Max losses</th><th>Coin leakage</th><th>Avg risk</th><th>Reasons</th></tr></thead><tbody>
__SCORECARD_ROWS__
</tbody></table>
<h2>Open Positions</h2>
<p>Read-only paper/live position view with current Hyperliquid mids when available.</p>
__OPEN_POSITIONS__
<h2>Near Miss Telemetry</h2>
<p>Closest rejected entry signals by strategy. Distance shows how many percentage points were still missing to trigger.</p>
__NEAR_MISSES__
__REPORTS__
</body></html>""".replace("__RUNTIME__", html.escape(str(snapshot.runtime_dir))).replace("__KILL__", "yes" if snapshot.kill_switch_present else "no").replace("__ROWS__", "\n".join(rows)).replace("__SCORECARD_ROWS__", "\n".join(scorecard_rows)).replace("__OPEN_POSITIONS__", "\n".join(open_position_blocks) if open_position_blocks else "<p>No open positions.</p>").replace("__NEAR_MISSES__", "\n".join(near_miss_blocks) if near_miss_blocks else "<p>No near misses recorded yet.</p>").replace("__REPORTS__", "\n".join(report_blocks))


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Render read-only CryptoTradingBot dashboard HTML.")
    parser.add_argument("--output", default=None)
    args = parser.parse_args(argv)
    snapshot = build_dashboard_snapshot()
    output = Path(args.output).expanduser().resolve() if args.output else snapshot.runtime_dir / "reports" / "dashboard.html"
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(render_dashboard_html(snapshot), encoding="utf-8")
    print(output)
    return 0


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