"""
Export an event-driven orderbook replay viewer for a recorded LPL/LOL market.

This viewer is built from orderbook_events.jsonl, not periodic snapshots. It is
intended for microstructure analysis around video-marked game events.
"""
from __future__ import annotations

import html
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path

import click
from loguru import logger

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
    sys.path.insert(0, ROOT)

from analysis.lpl_orderbook.event_replay import iter_event_frames  # noqa: E402


def _safe_json(value: object) -> str:
    return (
        json.dumps(value, ensure_ascii=False, separators=(",", ":"))
        .replace("</", "<\\/")
        .replace("\u2028", "\\u2028")
        .replace("\u2029", "\\u2029")
    )


def export_event_viewer(
    *,
    slug: str,
    data_root: Path,
    output_path: Path | None,
    depth: int,
    frame_stride: int,
    market_kind: str | None = None,
) -> dict[str, object]:
    meta, frames = iter_event_frames(
        slug=slug,
        data_root=data_root,
        depth=depth,
        market_kind=market_kind,
    )
    if frame_stride > 1:
        frames = frames[::frame_stride]
    market_dir = data_root / slug
    if output_path is None:
        suffix = f"_{market_kind}" if market_kind else ""
        output_path = market_dir / f"event_orderbook_replay{suffix}.html"
    output_path.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        "meta": meta,
        "frames": frames,
        "depth": depth,
        "frame_stride": frame_stride,
        "market_kind": market_kind or "",
        "time_basis": {
            "primary": "received_at_wall",
            "secondary": "exchange_ts",
            "delay": "received_at_wall - exchange_ts",
        },
        "generated_at": datetime.now(timezone.utc).isoformat(),
    }
    output_path.write_text(_render_html(payload), encoding="utf-8")
    return {
        "slug": slug,
        "frames": len(frames),
        "output_path": str(output_path),
        "started_at": frames[0]["received_at_wall"] if frames else "",
        "ended_at": frames[-1]["received_at_wall"] if frames else "",
    }


def _render_html(payload: dict[str, object]) -> str:
    data = _safe_json(payload)
    title = html.escape(str((payload.get("meta") or {}).get("question") or "Event Replay"))
    template = """<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>__TITLE__</title>
  <style>
    :root { color-scheme: dark; --bg:#0f1419; --panel:#151b21; --line:#28313a; --muted:#8b98a6; --text:#f1f4f7; --red:#ef4444; --green:#35c978; --accent:#8ab4ff; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
    * { box-sizing: border-box; }
    body { margin: 0; overflow: auto; background: var(--bg); color: var(--text); }
    .app { width: min(1700px, 100vw); min-height: 100vh; margin: 0 auto; padding: 8px 8px 70px; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 8px; }
    .top { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; min-height: 34px; }
    h1 { margin: 0; font-size: 15px; }
    .meta { color: var(--muted); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .stats { display: flex; gap: 12px; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
    .main { min-height: 0; display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr) 380px; gap: 8px; }
    .team, .event { min-height: 0; display: grid; grid-template-rows: auto auto minmax(150px, 1fr); gap: 8px; }
    .title, .event-title { height: 30px; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 0 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); font-size: 12px; }
    .title strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
    .book, .box { min-height: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--panel); }
    .book { height: 382px; }
    .head, .row, .mid { display: grid; grid-template-columns: 58px 76px 96px 106px; justify-content: end; align-items: center; }
    .head { height: 28px; color: var(--muted); border-bottom: 1px solid var(--line); font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .7px; }
    .row { position: relative; min-height: 29px; font-size: 12px; border-bottom: 1px solid rgba(255,255,255,.025); }
    .num { text-align: right; padding-right: 8px; font-variant-numeric: tabular-nums; }
    .price { font-size: 14px; font-weight: 800; }
    .ask .price { color: var(--red); }
    .bid .price { color: var(--green); }
    .bar { position: absolute; inset: 0 auto 0 0; opacity: .75; pointer-events: none; }
    .ask .bar { background: rgba(239,68,68,.13); }
    .bid .bar { background: rgba(53,201,120,.13); }
    .mid { min-height: 32px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); color: var(--muted); font-size: 11px; font-weight: 750; }
    .pill { display:inline-flex; border-radius:5px; padding:4px 7px; color:white; font-size:11px; font-weight:800; }
    .pill.ask { background:#dc2626; }
    .pill.bid { background:#2ca461; }
    .label { padding-left: 8px; }
    .event-body { padding: 10px; overflow: auto; font-size: 12px; }
    .trade-panel { min-height: 170px; max-height: calc(100vh - 500px); }
    .kv { display: grid; grid-template-columns: 112px minmax(0,1fr); gap: 8px; margin: 5px 0; color: var(--muted); }
    .kv strong { color: var(--text); overflow-wrap: anywhere; }
    .changes { margin-top: 10px; border-top: 1px solid var(--line); padding-top: 8px; }
    .trade-list { display: grid; gap: 3px; }
    .change-row, .trade-row { display: grid; grid-template-columns: minmax(0,1fr) 44px 58px 76px; gap: 6px; min-height: 24px; align-items: center; font-variant-numeric: tabular-nums; border-bottom: 1px solid rgba(255,255,255,.025); }
    .trade-row div, .change-row div { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .trade-row.buy .trade-price { color: var(--green); font-weight: 800; }
    .trade-row.sell .trade-price { color: var(--red); font-weight: 800; }
    .controls { position: fixed; left: 8px; right: 8px; bottom: 8px; z-index: 20; width: min(1684px, calc(100vw - 16px)); margin: 0 auto; display: grid; grid-template-columns: 86px 74px 74px 74px 1fr; gap: 8px; align-items: center; background: rgba(21,27,33,.96); border: 1px solid var(--line); border-radius: 8px; padding: 7px; box-shadow: 0 10px 24px rgba(0,0,0,.35); backdrop-filter: blur(8px); }
    button, select { height: 28px; border-radius: 6px; border: 1px solid #34414d; background: #202832; color: var(--text); font-size: 12px; }
    input[type=range] { width: 100%; accent-color: var(--accent); }
  </style>
</head>
<body>
<script id="replay-data" type="application/json">__DATA__</script>
<div class="app">
  <div class="top">
    <div><h1>Event Order Book Replay</h1><div id="question" class="meta"></div></div>
    <div class="stats"><span id="recvTime"></span><span id="frameNo"></span></div>
  </div>
  <div class="main"><div id="teams" class="main" style="display:contents"></div><aside class="event"><div class="event-title"><strong>Event</strong><span id="delay"></span></div><div class="box event-body" id="eventBody"></div></aside></div>
  <div class="controls">
    <select id="speed"><option value="0.5">0.5x</option><option value="1">1x</option><option value="2" selected>2x</option><option value="5">5x</option><option value="10">10x</option></select>
    <button id="play">Play</button>
    <button id="back5">-5s</button>
    <button id="step">Step</button>
    <input id="slider" type="range" min="0" max="0" value="0">
  </div>
</div>
<script>
const payload = JSON.parse(document.getElementById('replay-data').textContent);
const frames = payload.frames || [];
const tokenIds = (payload.meta && payload.meta.token_ids) || [];
let idx = 0;
let playing = false;
let rafId = null;
let lastTick = 0;
let frameCarry = 0;
let historyBuiltTo = -1;
const tradeHistory = new Map();
const els = { question: document.getElementById('question'), teams: document.getElementById('teams'), recvTime: document.getElementById('recvTime'), frameNo: document.getElementById('frameNo'), delay: document.getElementById('delay'), eventBody: document.getElementById('eventBody'), speed: document.getElementById('speed'), play: document.getElementById('play'), back5: document.getElementById('back5'), step: document.getElementById('step'), slider: document.getElementById('slider') };
const teamEls = new Map();
function fmtTime(value) { const d = new Date(value); if (Number.isNaN(d.getTime())) return value || '-'; const parts = new Intl.DateTimeFormat('en-CA', { timeZone:'Asia/Shanghai', hour12:false, month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' }).formatToParts(d).reduce((a,p)=>{a[p.type]=p.value; return a;}, {}); return `${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}.${String(d.getMilliseconds()).padStart(3,'0')}`; }
function cents(v) { return v === null || v === undefined || v === '' ? '-' : `${Math.round(Number(v) * 1000) / 10}c`; }
function shares(v) { return Number(v || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
function money(v) { return `$${Number(v || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; }
function row(level, side, max, before) { const n = Number(level.price) * Number(level.size); const width = Math.max(2, Math.min(100, n / Math.max(1, max) * 100)); return `<div class="row ${side}"><div class="bar" style="width:${width}%"></div><div></div><div class="price num">${cents(level.price)}</div><div class="num">${shares(level.size)}</div><div class="num">${money(before + n)}</div></div>`; }
function sideRows(levels, side) { const top = (levels || []).slice(0, 5); const max = Math.max(1, ...top.map(l => Number(l.price) * Number(l.size))); let cum = 0; const rows = top.map(l => { const out = row(l, side, max, cum); cum += Number(l.price) * Number(l.size); return out; }); return (side === 'ask' ? rows.reverse() : rows).join(''); }
function makeTeam(tokenId) { const sample = frames.find(f => f.books && f.books[tokenId]); const outcome = sample ? sample.books[tokenId].outcome : tokenId; const el = document.createElement('section'); el.className = 'team'; el.innerHTML = `<div class="title"><strong>${outcome}</strong><span class="best"></span></div><div class="book"><div class="head"><div></div><div class="num">Price</div><div class="num">Shares</div><div class="num">Total</div></div><div class="asks"></div><div class="mid"><div class="label"><span class="pill ask">Asks</span></div><div class="last num"></div><div class="spread num"></div><div></div></div><div class="bids"></div></div><div class="box event-body trade-panel"><div class="trade"></div></div>`; els.teams.appendChild(el); teamEls.set(tokenId, { best: el.querySelector('.best'), asks: el.querySelector('.asks'), bids: el.querySelector('.bids'), last: el.querySelector('.last'), spread: el.querySelector('.spread'), trade: el.querySelector('.trade') }); }
function resetTradeHistory() { tradeHistory.clear(); for (const t of tokenIds) tradeHistory.set(t, []); historyBuiltTo = -1; }
function appendTrade(frameIndex) { const tr = frames[frameIndex] && frames[frameIndex].trade; if (!tr || !tr.asset_id || !tradeHistory.has(tr.asset_id)) return; const prev = tradeHistory.get(tr.asset_id); const key = `${tr.transaction_hash || ''}|${tr.price}|${tr.size}|${frames[frameIndex].received_at_wall}`; if (!prev.some(x => x.key === key)) prev.push({ ...tr, key, received_at_wall: frames[frameIndex].received_at_wall, exchange_ts: frames[frameIndex].exchange_ts }); }
function buildTradeHistory(frameIndex) { if (frameIndex < historyBuiltTo) resetTradeHistory(); for (let i = historyBuiltTo + 1; i <= frameIndex; i++) appendTrade(i); historyBuiltTo = Math.max(historyBuiltTo, frameIndex); }
function renderTradeList(tokenId, latest) { const rows = (tradeHistory.get(tokenId) || []).slice(-80).reverse(); if (!rows.length) return '<div class="trade-row"><div>No trade yet</div><div></div><div></div><div></div></div>'; return `<div class="trade-list">${rows.map(t => `<div class="trade-row ${String(t.side).toLowerCase()}"><div title="${t.transaction_hash || ''}">${fmtTime(t.received_at_wall)}</div><div>${t.side}</div><div class="trade-price num">${cents(t.price)}</div><div class="num">${shares(t.size)}</div></div>`).join('')}</div>`; }
function renderTeam(tokenId, frame) { const book = frame.books[tokenId]; const view = teamEls.get(tokenId); if (!book || !view) return; const bid = Number(book.best_bid); const ask = Number(book.best_ask); view.best.textContent = `${cents(book.best_bid)} / ${cents(book.best_ask)}`; view.asks.innerHTML = sideRows(book.asks, 'ask'); view.bids.innerHTML = sideRows(book.bids, 'bid'); view.last.textContent = book.last_trade ? `Last ${cents(book.last_trade.price)}` : 'Last -'; view.spread.textContent = Number.isFinite(bid) && Number.isFinite(ask) ? `Spread ${cents(ask - bid)}` : 'Spread -'; view.trade.innerHTML = renderTradeList(tokenId, book.last_trade); }
function renderEvent(frame) { const changes = (frame.changed_levels || []).slice(0, 12).map(c => `<div class="change-row"><div>${(frame.books[c.asset_id] && frame.books[c.asset_id].outcome) || c.asset_id}</div><div>${c.side}</div><div class="num">${cents(c.price)}</div><div class="num">${shares(c.size)}</div></div>`).join(''); const trade = frame.trade ? `<div class="trade-row ${String(frame.trade.side).toLowerCase()}"><div>${frame.trade.outcome}</div><div>${frame.trade.side}</div><div class="trade-price num">${cents(frame.trade.price)}</div><div class="num">${shares(frame.trade.size)}</div></div>` : ''; els.eventBody.innerHTML = `<div class="kv"><span>event_type</span><strong>${frame.event_type}</strong></div><div class="kv"><span>received</span><strong>${fmtTime(frame.received_at_wall)}</strong></div><div class="kv"><span>exchange</span><strong>${fmtTime(frame.exchange_ts)}</strong></div><div class="kv"><span>message</span><strong>${frame.message_index || ''}</strong></div><div class="kv"><span>asset</span><strong>${frame.outcome || frame.asset_id || '-'}</strong></div>${trade ? `<div class="changes"><strong>trade</strong>${trade}</div>` : ''}${changes ? `<div class="changes"><strong>changed levels</strong>${changes}</div>` : ''}`; els.delay.textContent = frame.delay_ms === null || frame.delay_ms === undefined ? 'delay -' : `delay ${Math.round(Number(frame.delay_ms))}ms`; }
function render() { if (!frames.length) return; const f = frames[idx]; buildTradeHistory(idx); for (const t of tokenIds) renderTeam(t, f); renderEvent(f); els.recvTime.textContent = fmtTime(f.received_at_wall); els.frameNo.textContent = `${idx + 1} / ${frames.length}`; els.slider.value = String(idx); }
function seekSeconds(deltaSeconds) { if (!frames.length) return; const current = new Date(frames[idx].received_at_wall).getTime(); if (!Number.isFinite(current)) return; const target = current + deltaSeconds * 1000; let lo = 0, hi = frames.length - 1, ans = 0; while (lo <= hi) { const mid = Math.floor((lo + hi) / 2); const t = new Date(frames[mid].received_at_wall).getTime(); if (t <= target) { ans = mid; lo = mid + 1; } else { hi = mid - 1; } } idx = Math.max(0, Math.min(frames.length - 1, ans)); render(); }
function stop() { playing = false; if (rafId) cancelAnimationFrame(rafId); rafId = null; lastTick = 0; frameCarry = 0; els.play.textContent = 'Play'; }
function tick(now) { if (!playing) return; if (!lastTick) lastTick = now; const elapsed = now - lastTick; lastTick = now; const frameMs = Math.max(20, 180 / Number(els.speed.value || 1)); frameCarry += elapsed / frameMs; const step = Math.min(8, Math.floor(frameCarry)); if (step > 0) { frameCarry -= step; idx = Math.min(frames.length - 1, idx + step); render(); if (idx >= frames.length - 1) { stop(); return; } } rafId = requestAnimationFrame(tick); }
function play() { if (playing) return stop(); playing = true; els.play.textContent = 'Pause'; rafId = requestAnimationFrame(tick); }
els.question.textContent = `${(payload.meta && payload.meta.slug) || ''} | ${(payload.meta && payload.meta.question) || ''}`;
for (const t of tokenIds) makeTeam(t);
els.slider.max = String(Math.max(0, frames.length - 1));
els.slider.addEventListener('input', () => { idx = Number(els.slider.value); render(); });
els.speed.addEventListener('change', () => { if (playing) { stop(); play(); } });
els.play.addEventListener('click', play);
els.back5.addEventListener('click', () => seekSeconds(-5));
els.step.addEventListener('click', () => { idx = Math.min(frames.length - 1, idx + 1); render(); });
render();
</script>
</body>
</html>
"""
    return template.replace("__TITLE__", title).replace("__DATA__", data)


@click.command()
@click.option("--slug", required=True, help="Recorded Polymarket market slug")
@click.option(
    "--data-root",
    default="data/lpl",
    show_default=True,
    type=click.Path(file_okay=False, path_type=Path),
    help="Recording data root",
)
@click.option(
    "--output",
    "output_path",
    default=None,
    type=click.Path(dir_okay=False, path_type=Path),
    help="Output HTML path. Defaults to data/lpl/<slug>/event_orderbook_replay.html",
)
@click.option("--depth", default=5, show_default=True, type=int, help="Levels per side")
@click.option(
    "--market-kind",
    default=None,
    type=click.Choice(["moneyline", "game1", "game2", "game3", "game4"]),
    help="Export one target market from a unified event recording.",
)
@click.option(
    "--frame-stride",
    default=1,
    show_default=True,
    type=int,
    help="Keep every Nth event frame. Use this only for very large browser payloads.",
)
def main(
    slug: str,
    data_root: Path,
    output_path: Path | None,
    depth: int,
    market_kind: str | None,
    frame_stride: int,
) -> None:
    result = export_event_viewer(
        slug=slug,
        data_root=data_root,
        output_path=output_path,
        depth=depth,
        market_kind=market_kind,
        frame_stride=frame_stride,
    )
    logger.info(
        "event viewer exported: "
        f"frames={result['frames']} "
        f"started_at={result['started_at']} "
        f"ended_at={result['ended_at']} "
        f"output={result['output_path']}"
    )


if __name__ == "__main__":
    main()
