#!/usr/bin/env python3
"""Summarize runtime evidence from the opening battle trace.

The raw intro-battle trace is intentionally left in captures/runtime because it
is large and transient. This builder promotes the compact, useful evidence:

* phase anchors from TextOutA skill title draws,
* runtime CNS surface lifecycle and draw rectangles,
* actor/monster/effect draw sequences during the three intro attacks,
* DirectSound buffer byte lengths matched to extracted zero-based WLK files.
"""
from __future__ import annotations

import html
import json
import re
import wave
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
CAPTURES = ROOT / "captures" / "runtime"
OUT = ROOT / "out"
WEB = ROOT / "web"
WLK = ROOT / "extract_wlk"

JSON_OUT = OUT / "intro_battle_runtime_review.json"
HTML_OUT = WEB / "intro_battle_runtime_review.html"

INTERESTING_RESOURCES = {
    "aaa.cns",
    "logo_00.cns",
    "title.cns",
    "map_k1.cns",
    "map_k2.cns",
    "btl_k2.cns",
    "btl_at.cns",
    "btl_rs.cns",
    "btl_sm.cns",
    "zs_dd.cns",
    "btl_efc.cns",
    "btl_etc.cns",
    "window.cns",
    "status.cns",
}

ACTION_RESOURCES = [
    "btl_at.cns",
    "btl_rs.cns",
    "btl_sm.cns",
    "zs_dd.cns",
    "btl_efc.cns",
    "btl_etc.cns",
]

PHASE_TITLE_HINTS = [
    ("맹호스페셜", "Ataho 맹호스페셜"),
    ("선렬각", "Linxiang 선렬각"),
    ("쾌진격", "Smash 쾌진격"),
]


def h(value: Any) -> str:
    return html.escape("" if value is None else str(value), quote=True)


def read_json(path: Path) -> dict[str, Any]:
    if not path.exists():
        return {}
    return json.loads(path.read_text(encoding="utf-8"))


def latest_intro_battle_trace() -> tuple[Path, Path] | None:
    pairs = []
    for manifest in sorted(CAPTURES.glob("*intro-battle*.manifest.json")):
        jsonl = manifest.with_suffix("").with_suffix(".jsonl")
        if jsonl.exists():
            pairs.append((manifest, jsonl))
    return pairs[-1] if pairs else None


def load_events(path: Path) -> list[dict[str, Any]]:
    events: list[dict[str, Any]] = []
    with path.open(encoding="utf-8") as file:
        for line_no, line in enumerate(file, 1):
            if not line.strip():
                continue
            event = json.loads(line)
            event["_line"] = line_no
            events.append(event)
    return events


def raw_to_text(raw: str | None) -> str:
    if not raw:
        return ""
    try:
        return bytes.fromhex(raw).decode("cp949", errors="replace").rstrip("\x00")
    except ValueError:
        return ""


def parse_rect(value: str | None) -> tuple[int, int, int, int] | None:
    if not value:
        return None
    parts = value.split(",")
    if len(parts) != 4:
        return None
    try:
        return tuple(int(part) for part in parts)  # type: ignore[return-value]
    except ValueError:
        return None


def rect_size(rect: str | None) -> str | None:
    parsed = parse_rect(rect)
    if not parsed:
        return None
    x0, y0, x1, y1 = parsed
    return f"{x1 - x0}x{y1 - y0}"


def parse_draw_detail(detail: str | None) -> dict[str, Any]:
    if not detail:
        return {}
    parsed: dict[str, Any] = {}
    for key, pattern in {
        "caller": r"caller=(0x[0-9a-fA-F]+)",
        "srcRect": r"srcRect=([0-9,\-]+)",
        "dstRect": r"dstRect=([0-9,\-]+)",
        "flags": r"flags=(0x[0-9a-fA-F]+)",
        "srcColorKey": r"srcColorKey=([^ ]+)",
        "appliedColorKey": r"appliedColorKey=([^ ]+)",
    }.items():
        match = re.search(pattern, detail)
        if match:
            parsed[key] = match.group(1)
    at = re.search(r"\sat\s(-?\d+),(-?\d+)", detail)
    if at:
        parsed["dstPoint"] = f"{at.group(1)},{at.group(2)}"
    parsed["srcSize"] = rect_size(parsed.get("srcRect"))
    return {key: value for key, value in parsed.items() if value is not None}


def resource_label(event: dict[str, Any]) -> str | None:
    return (
        event.get("sourceResourceName")
        or event.get("sourceLabel")
        or event.get("resourceName")
    )


def compact_event(event: dict[str, Any]) -> dict[str, Any]:
    keys = [
        "seq",
        "frame",
        "tick",
        "step",
        "event",
        "pc",
        "caller",
        "name",
        "resourceName",
        "resourceType",
        "archiveOffset",
        "byteLength",
        "imageWidth",
        "imageHeight",
        "surfaceHandle",
        "source",
        "destination",
        "sourceLabel",
        "destinationLabel",
        "sourceResourceName",
        "destinationResourceName",
        "sourceSurfaceSize",
        "destinationSurfaceSize",
        "rect",
        "font",
        "color",
        "raw",
        "detail",
        "runner",
        "object",
        "stream",
        "opcode",
    ]
    out = {key: event.get(key) for key in keys if key in event}
    if event.get("raw"):
        decoded = raw_to_text(event.get("raw"))
        if decoded:
            out["decodedText"] = decoded
    parsed = parse_draw_detail(event.get("detail"))
    if parsed:
        out["parsed"] = parsed
    return out


def wlk_catalog() -> dict[int, dict[str, Any]]:
    out: dict[int, dict[str, Any]] = {}
    if not WLK.exists():
        return out
    for path in sorted(WLK.glob("*.wav")):
        try:
            index = int(path.stem)
        except ValueError:
            continue
        with wave.open(str(path), "rb") as wav:
            channels = wav.getnchannels()
            sample_width = wav.getsampwidth()
            frames = wav.getnframes()
            sample_rate = wav.getframerate()
            data_bytes = frames * channels * sample_width
        out[index] = {
            "wlkIndex": index,
            "file": path.name,
            "fileBytes": path.stat().st_size,
            "dataBytes": data_bytes,
            "frames": frames,
            "sampleRate": sample_rate,
            "channels": channels,
            "sampleWidth": sample_width,
        }
    return out


def match_wlk(data_bytes: int | None, catalog: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
    if data_bytes is None:
        return []
    exact = [row for row in catalog.values() if row["dataBytes"] == data_bytes]
    if exact:
        return [{**row, "match": "exact-data-bytes"} for row in exact]
    near = sorted(catalog.values(), key=lambda row: abs(row["dataBytes"] - data_bytes))[:3]
    return [{**row, "match": f"nearest-delta-{abs(row['dataBytes'] - data_bytes)}"} for row in near]


def extract_sound_bytes(detail: str | None) -> int | None:
    if not detail:
        return None
    match = re.search(r"bytes=(\d+)", detail)
    return int(match.group(1)) if match else None


def extract_sound_format(detail: str | None) -> str | None:
    if not detail:
        return None
    match = re.search(r"format=([^ ]+)", detail)
    return match.group(1) if match else None


def summarize_text(events: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    rows = []
    anchors = []
    for event in events:
        if event.get("event") != "text-draw":
            continue
        decoded = raw_to_text(event.get("raw")) or event.get("text") or ""
        row = {
            "seq": event.get("seq"),
            "frame": event.get("frame"),
            "tick": event.get("tick"),
            "pc": event.get("pc"),
            "rect": event.get("rect"),
            "font": event.get("font"),
            "color": event.get("color"),
            "raw": event.get("raw"),
            "decodedText": decoded,
        }
        rows.append(row)
        for hint, label in PHASE_TITLE_HINTS:
            if hint in decoded:
                anchors.append({**row, "phaseLabel": label, "title": hint})
    return rows, anchors


def logo_runs(draws: list[dict[str, Any]]) -> list[dict[str, Any]]:
    frames = sorted({row.get("frame") for row in draws if resource_label(row) == "logo_00.cns" and isinstance(row.get("frame"), int)})
    if not frames:
        return []
    runs: list[list[int]] = []
    current = [frames[0]]
    for frame in frames[1:]:
        if frame <= current[-1] + 1:
            current.append(frame)
        else:
            runs.append(current)
            current = [frame]
    runs.append(current)
    out = []
    for i, run in enumerate(runs, 1):
        run_draws = [row for row in draws if row.get("frame") in set(run) and resource_label(row) == "logo_00.cns"]
        out.append(
            {
                "runIndex": i,
                "firstFrame": run[0],
                "lastFrame": run[-1],
                "frameCount": len(run),
                "drawCount": len(run_draws),
                "topRects": top_draw_combos(run_draws, limit=8),
            }
        )
    return out


def build_action_phases(anchors: list[dict[str, Any]], draws: list[dict[str, Any]]) -> list[dict[str, Any]]:
    phases = []
    logo_frames = sorted(
        row.get("frame")
        for row in draws
        if resource_label(row) == "logo_00.cns" and isinstance(row.get("frame"), int)
    )
    for i, anchor in enumerate(sorted(anchors, key=lambda row: row["frame"])):
        start = anchor["frame"]
        next_anchor = anchors[i + 1]["frame"] if i + 1 < len(anchors) else None
        after_start_logo = [frame for frame in logo_frames if frame > start and (next_anchor is None or frame < next_anchor)]
        if after_start_logo:
            end = after_start_logo[0] - 1
        elif next_anchor:
            end = next_anchor - 1
        else:
            end = max((row.get("frame") for row in draws if isinstance(row.get("frame"), int)), default=start)
        phases.append(
            {
                "label": anchor["phaseLabel"],
                "title": anchor["title"],
                "textAnchor": anchor,
                "firstFrame": start,
                "lastFrame": end,
                "frameSpan": end - start + 1,
            }
        )
    return phases


def top_draw_combos(draws: list[dict[str, Any]], limit: int = 12) -> list[dict[str, Any]]:
    counter: Counter[tuple[str | None, str | None, str | None, str | None]] = Counter()
    samples: dict[tuple[str | None, str | None, str | None, str | None], dict[str, Any]] = {}
    for row in draws:
        parsed = parse_draw_detail(row.get("detail"))
        key = (
            parsed.get("srcRect"),
            parsed.get("dstPoint") or parsed.get("dstRect"),
            parsed.get("srcSize"),
            parsed.get("flags"),
        )
        counter[key] += 1
        samples.setdefault(key, row)
    out = []
    for key, count in counter.most_common(limit):
        sample = samples[key]
        out.append(
            {
                "count": count,
                "srcRect": key[0],
                "destination": key[1],
                "srcSize": key[2],
                "flags": key[3],
                "firstSeq": sample.get("seq"),
                "firstFrame": sample.get("frame"),
                "pc": sample.get("pc"),
                "caller": parse_draw_detail(sample.get("detail")).get("caller"),
            }
        )
    return out


def compress_draw_sequence(draws: list[dict[str, Any]], limit: int = 80) -> list[dict[str, Any]]:
    runs = []
    current: dict[str, Any] | None = None
    for row in sorted(draws, key=lambda event: event.get("seq", 0)):
        parsed = parse_draw_detail(row.get("detail"))
        key = (parsed.get("srcRect"), parsed.get("dstPoint") or parsed.get("dstRect"), parsed.get("flags"))
        if current and current["key"] == key and row.get("frame") <= current["lastFrame"] + 1:
            current["count"] += 1
            current["lastFrame"] = row.get("frame")
            current["lastSeq"] = row.get("seq")
            continue
        if current:
            runs.append(current)
        current = {
            "key": key,
            "count": 1,
            "firstSeq": row.get("seq"),
            "lastSeq": row.get("seq"),
            "firstFrame": row.get("frame"),
            "lastFrame": row.get("frame"),
            "srcRect": parsed.get("srcRect"),
            "destination": parsed.get("dstPoint") or parsed.get("dstRect"),
            "srcSize": parsed.get("srcSize"),
            "flags": parsed.get("flags"),
            "caller": parsed.get("caller"),
        }
    if current:
        runs.append(current)
    return runs[:limit]


def draw_bounds(draws: list[dict[str, Any]]) -> dict[str, Any] | None:
    xs: list[int] = []
    ys: list[int] = []
    for row in draws:
        parsed = parse_draw_detail(row.get("detail"))
        point = parsed.get("dstPoint")
        rect = parsed.get("srcRect")
        if not point:
            continue
        try:
            x, y = [int(v) for v in point.split(",")]
        except ValueError:
            continue
        xs.append(x)
        ys.append(y)
        parsed_rect = parse_rect(rect)
        if parsed_rect:
            x0, y0, x1, y1 = parsed_rect
            xs.append(x + (x1 - x0))
            ys.append(y + (y1 - y0))
    if not xs:
        return None
    return {"x0": min(xs), "y0": min(ys), "x1": max(xs), "y1": max(ys)}


def summarize_phase(phase: dict[str, Any], draws: list[dict[str, Any]], sounds: list[dict[str, Any]]) -> dict[str, Any]:
    first = phase["firstFrame"]
    last = phase["lastFrame"]
    phase_draws = [row for row in draws if first <= row.get("frame", -1) <= last]
    phase_sounds = [row for row in sounds if first <= row.get("frame", -1) <= last]
    resource_summaries = []
    for resource in ACTION_RESOURCES:
        rows = [row for row in phase_draws if resource_label(row) == resource]
        if not rows:
            continue
        parsed_rects = [parse_draw_detail(row.get("detail")).get("srcRect") for row in rows]
        points = [parse_draw_detail(row.get("detail")).get("dstPoint") for row in rows]
        per_frame = Counter(row.get("frame") for row in rows)
        resource_summaries.append(
            {
                "resource": resource,
                "drawCount": len(rows),
                "uniqueSourceRects": len({value for value in parsed_rects if value}),
                "uniqueDestinations": len({value for value in points if value}),
                "firstFrame": min(row.get("frame") for row in rows),
                "lastFrame": max(row.get("frame") for row in rows),
                "dstBounds": draw_bounds(rows),
                "maxDrawsPerFrame": max(per_frame.values()) if per_frame else 0,
                "topCombos": top_draw_combos(rows, limit=10),
                "sequenceRuns": compress_draw_sequence(rows, limit=50),
            }
        )
    return {
        **phase,
        "resourceSummaries": resource_summaries,
        "soundEvents": phase_sounds,
    }


def summarize_sounds(events: list[dict[str, Any]], catalog: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
    rows = []
    for event in events:
        if event.get("event") != "sound-api":
            continue
        name = event.get("name")
        if name not in {
            "IDirectSound::CreateSoundBuffer",
            "IDirectSoundBuffer::Play",
            "IDirectSoundBuffer::Lock",
            "IDirectSoundBuffer::Unlock",
            "IDirectSoundBuffer::Stop",
            "IDirectSoundBuffer::Release",
            "midiStreamOpen",
            "midiStreamOut",
            "midiOutShortMsg",
            "midiStreamClose",
        }:
            continue
        bytes_ = extract_sound_bytes(event.get("detail"))
        row = compact_event(event)
        if bytes_ is not None:
            row["pcmBytes"] = bytes_
            row["format"] = extract_sound_format(event.get("detail"))
            row["wlkMatches"] = match_wlk(bytes_, catalog)
        rows.append(row)
    return rows


def summarize_palette(events: list[dict[str, Any]]) -> dict[str, Any]:
    rows = [event for event in events if event.get("event") == "surface-palette"]
    frames = sorted({event.get("frame") for event in rows if isinstance(event.get("frame"), int)})
    runs: list[list[int]] = []
    if frames:
        current = [frames[0]]
        for frame in frames[1:]:
            if frame <= current[-1] + 1:
                current.append(frame)
            else:
                runs.append(current)
                current = [frame]
        runs.append(current)
    return {
        "eventCount": len(rows),
        "callerCounts": Counter(row.get("caller") for row in rows).most_common(8),
        "surfaceCounts": Counter(row.get("surfaceHandle") for row in rows).most_common(8),
        "runs": [
            {
                "runIndex": i + 1,
                "firstFrame": run[0],
                "lastFrame": run[-1],
                "frameCount": len(run),
                "eventCount": sum(1 for row in rows if row.get("frame") in set(run)),
                "firstPaletteHash": next((row.get("paletteHash") for row in rows if row.get("frame") == run[0]), None),
                "lastPaletteHash": next((row.get("paletteHash") for row in reversed(rows) if row.get("frame") == run[-1]), None),
            }
            for i, run in enumerate(runs)
        ],
    }


def build() -> dict[str, Any]:
    trace = latest_intro_battle_trace()
    if not trace:
        return {
            "kind": "hwanse-intro-battle-runtime-review",
            "status": "missing-runtime-trace",
            "summary": {"traceFound": False},
        }
    manifest_path, jsonl_path = trace
    manifest = read_json(manifest_path)
    events = load_events(jsonl_path)
    catalog = wlk_catalog()

    event_counts = Counter(event.get("event") for event in events)
    draw_rows = [event for event in events if event.get("event") == "draw-surface"]
    text_rows, text_anchors = summarize_text(events)
    sound_rows = summarize_sounds(events, catalog)
    create_or_play_sounds = [
        row
        for row in sound_rows
        if row.get("name") in {"IDirectSound::CreateSoundBuffer", "IDirectSoundBuffer::Play"}
    ]
    action_phases = build_action_phases(text_anchors, draw_rows)

    resource_lifecycle = [
        compact_event(event)
        for event in events
        if event.get("event")
        in {
            "resource-open",
            "resource-read",
            "resource-decode",
            "surface-create",
            "surface-colorkey",
            "resource-surface-label",
            "surface-upload",
        }
        and (event.get("resourceName") in INTERESTING_RESOURCES or event.get("sourceResourceName") in INTERESTING_RESOURCES)
    ]

    resource_draw_counts = Counter(resource_label(row) for row in draw_rows)
    vm_rows = [
        compact_event(event)
        for event in events
        if event.get("event") in {"vm-op", "vm-op-transition", "object-stream-change"}
    ]
    vm_opcode_counts = Counter(
        (event.get("stream"), event.get("opcode"))
        for event in events
        if event.get("event") == "vm-op"
    )

    phase_summaries = [summarize_phase(phase, draw_rows, create_or_play_sounds) for phase in action_phases]
    logo_run_rows = logo_runs(draw_rows)
    title_draws = [compact_event(row) for row in draw_rows if resource_label(row) == "title.cns"]

    return {
        "version": 1,
        "kind": "hwanse-intro-battle-runtime-review",
        "status": "runtime-evidence-promoted",
        "source": {
            "manifest": str(manifest_path.relative_to(ROOT)),
            "jsonl": str(jsonl_path.relative_to(ROOT)),
            "jsonlBytes": jsonl_path.stat().st_size,
            "rawCaptureGitPolicy": "captures/runtime is ignored; compact evidence is promoted into out/web",
        },
        "manifest": manifest,
        "summary": {
            "eventCount": len(events),
            "eventCounts": event_counts.most_common(),
            "resourceDrawCounts": resource_draw_counts.most_common(24),
            "textAnchorCount": len(text_anchors),
            "actionPhaseCount": len(phase_summaries),
            "directSoundCreateOrPlayCount": len(create_or_play_sounds),
            "exactWlkSoundMatches": sum(
                1
                for row in create_or_play_sounds
                if row.get("wlkMatches") and row["wlkMatches"][0].get("match") == "exact-data-bytes"
            ),
            "paletteEventCount": event_counts.get("surface-palette", 0),
        },
        "textDraws": text_rows,
        "textAnchors": text_anchors,
        "resourceLifecycle": resource_lifecycle,
        "palette": summarize_palette(events),
        "actionPhases": phase_summaries,
        "logoRuns": logo_run_rows,
        "titleDraws": title_draws,
        "soundEvents": sound_rows,
        "wlkCatalog": list(catalog.values()),
        "vmSummary": {
            "sampleRows": vm_rows[:80],
            "topStreamOpcodes": [
                {"stream": key[0], "opcode": key[1], "count": count}
                for key, count in vm_opcode_counts.most_common(30)
            ],
        },
        "conclusions": [
            "aaa.cns fade 이후 배틀 구간에서 logo_00.cns, map_k1/k2, btl_k2, btl_at/btl_rs/btl_sm, zs_dd, btl_efc, btl_etc 로드와 draw가 런타임으로 관측됐다.",
            "기술명 TextOutA가 맹호스페셜/선렬각/쾌진격 phase anchor를 직접 제공하므로, 해당 구간의 actor frame/destination은 수동 추정이 아니라 런타임 근거로 분리할 수 있다.",
            "DirectSound PCM bytes는 extract_wlk/*.wav의 data chunk 길이와 exact match되어 0-base WLK index 매칭 근거가 생겼다.",
            "btl_efc.cns는 각 공격 phase에서 수백 개의 BltFast draw를 발생시켜 입자/이펙트 scatter가 실제 런타임에 존재함을 확인한다. 개별 helper opcode 의미는 별도 해석 대상이다.",
            "zs_dd.cns는 normal frame과 hit frame 사이에서 좌우 흔들림/clip 변형 destination이 반복되며, 피격 흔들림은 actor frame 변경이 아니라 draw 위치/rect 변형으로 관측된다.",
        ],
    }


def render_counter(rows: list[Any], headers: tuple[str, str], limit: int = 20) -> str:
    body = []
    for key, count in rows[:limit]:
        body.append(f"<tr><td><code>{h(key)}</code></td><td>{h(count)}</td></tr>")
    return f"<table><thead><tr><th>{h(headers[0])}</th><th>{h(headers[1])}</th></tr></thead><tbody>{''.join(body)}</tbody></table>"


def render_sound_table(rows: list[dict[str, Any]], limit: int = 80) -> str:
    body = []
    for row in rows[:limit]:
        matches = row.get("wlkMatches") or []
        match = matches[0] if matches else {}
        body.append(
            "<tr>"
            f"<td>{h(row.get('frame'))}</td>"
            f"<td>{h(row.get('name'))}</td>"
            f"<td>{h(row.get('pcmBytes'))}</td>"
            f"<td><code>{h(match.get('file'))}</code> <span class=\"muted\">#{h(match.get('wlkIndex'))} {h(match.get('match'))}</span></td>"
            f"<td>{h(row.get('format'))}</td>"
            f"<td><code>{h(row.get('pc'))}</code></td>"
            "</tr>"
        )
    return "<table><thead><tr><th>frame</th><th>api</th><th>PCM bytes</th><th>WLK match</th><th>format</th><th>pc</th></tr></thead><tbody>" + "".join(body) + "</tbody></table>"


def render_phase(phase: dict[str, Any]) -> str:
    resource_cards = []
    for resource in phase.get("resourceSummaries", []):
        top_rows = "".join(
            "<tr>"
            f"<td>{h(row.get('count'))}</td>"
            f"<td><code>{h(row.get('srcRect'))}</code></td>"
            f"<td><code>{h(row.get('destination'))}</code></td>"
            f"<td>{h(row.get('srcSize'))}</td>"
            f"<td><code>{h(row.get('caller'))}</code></td>"
            "</tr>"
            for row in resource.get("topCombos", [])[:8]
        )
        sequence_rows = "".join(
            "<tr>"
            f"<td>{h(run.get('firstFrame'))}..{h(run.get('lastFrame'))}</td>"
            f"<td>{h(run.get('count'))}</td>"
            f"<td><code>{h(run.get('srcRect'))}</code></td>"
            f"<td><code>{h(run.get('destination'))}</code></td>"
            f"<td>{h(run.get('srcSize'))}</td>"
            "</tr>"
            for run in resource.get("sequenceRuns", [])[:16]
        )
        bounds = resource.get("dstBounds") or {}
        resource_cards.append(
            f"""
            <details class="resource-card">
              <summary><strong>{h(resource.get('resource'))}</strong>
                <span class="badge">{h(resource.get('drawCount'))} draws</span>
                <span class="badge">{h(resource.get('uniqueSourceRects'))} srcRects</span>
                <span class="badge">{h(resource.get('uniqueDestinations'))} dst</span>
                <span class="muted">bounds {h(bounds.get('x0'))},{h(bounds.get('y0'))}..{h(bounds.get('x1'))},{h(bounds.get('y1'))}</span>
              </summary>
              <h4>Top source/destination combos</h4>
              <table><thead><tr><th>count</th><th>srcRect</th><th>dst</th><th>size</th><th>caller</th></tr></thead><tbody>{top_rows}</tbody></table>
              <h4>First compressed draw sequence</h4>
              <table><thead><tr><th>frames</th><th>count</th><th>srcRect</th><th>dst</th><th>size</th></tr></thead><tbody>{sequence_rows}</tbody></table>
            </details>
            """
        )
    return (
        f"<section class=\"phase\"><h3>{h(phase.get('label'))}</h3>"
        f"<p class=\"muted\">frames {h(phase.get('firstFrame'))}..{h(phase.get('lastFrame'))}, span {h(phase.get('frameSpan'))}; "
        f"TextOutA anchor rect <code>{h((phase.get('textAnchor') or {}).get('rect'))}</code></p>"
        + "".join(resource_cards)
        + "<h4>Phase sounds</h4>"
        + render_sound_table(phase.get("soundEvents", []), limit=40)
        + "</section>"
    )


def render_html(data: dict[str, Any]) -> str:
    summary = data.get("summary", {})
    text_anchor_rows = "".join(
        "<tr>"
        f"<td>{h(row.get('frame'))}</td>"
        f"<td>{h(row.get('phaseLabel'))}</td>"
        f"<td><code>{h(row.get('pc'))}</code></td>"
        f"<td><code>{h(row.get('rect'))}</code></td>"
        f"<td>{h(row.get('font'))}</td>"
        f"<td><code>{h(row.get('raw'))}</code></td>"
        f"<td>{h(row.get('decodedText'))}</td>"
        "</tr>"
        for row in data.get("textAnchors", [])
    )
    logo_rows = "".join(
        "<tr>"
        f"<td>{h(row.get('runIndex'))}</td>"
        f"<td>{h(row.get('firstFrame'))}..{h(row.get('lastFrame'))}</td>"
        f"<td>{h(row.get('frameCount'))}</td>"
        f"<td>{h(row.get('drawCount'))}</td>"
        f"<td>{h(', '.join((rect.get('srcRect') or '-') for rect in row.get('topRects', [])[:4]))}</td>"
        "</tr>"
        for row in data.get("logoRuns", [])
    )
    conclusions = "".join(f"<li>{h(row)}</li>" for row in data.get("conclusions", []))
    phases = "".join(render_phase(phase) for phase in data.get("actionPhases", []))
    palette_rows = "".join(
        "<tr>"
        f"<td>{h(row.get('runIndex'))}</td>"
        f"<td>{h(row.get('firstFrame'))}..{h(row.get('lastFrame'))}</td>"
        f"<td>{h(row.get('eventCount'))}</td>"
        f"<td><code>{h(row.get('firstPaletteHash'))}</code> -> <code>{h(row.get('lastPaletteHash'))}</code></td>"
        "</tr>"
        for row in (data.get("palette") or {}).get("runs", [])
    )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Intro Battle Runtime Review</title>
  <style>
    :root {{ color-scheme: dark; --bg:#101214; --panel:#181c20; --line:#303840; --text:#e8edf2; --muted:#98a4ad; --accent:#80cbc4; }}
    body {{ margin:0; background:var(--bg); color:var(--text); font-family:system-ui,-apple-system,Segoe UI,sans-serif; }}
    main {{ max-width:1280px; margin:0 auto; padding:24px; }}
    a {{ color:var(--accent); }}
    h1, h2, h3, h4 {{ margin:0 0 10px; }}
    section, details.resource-card {{ background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:16px; margin:14px 0; }}
    details.resource-card summary {{ cursor:pointer; }}
    .summary-grid {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:10px; }}
    .metric {{ background:#11161a; border:1px solid var(--line); border-radius:8px; padding:12px; }}
    .metric strong {{ display:block; font-size:24px; }}
    .muted {{ color:var(--muted); }}
    .badge {{ display:inline-block; margin-left:8px; padding:2px 7px; border-radius:999px; background:#263238; color:#cfd8dc; font-size:12px; }}
    code {{ color:#d7e8ff; }}
    table {{ width:100%; border-collapse:collapse; margin:10px 0 0; font-size:13px; }}
    th,td {{ border-bottom:1px solid var(--line); padding:6px 8px; text-align:left; vertical-align:top; }}
    th {{ color:#c8d2dc; background:#15191d; position:sticky; top:0; }}
    .table-wrap {{ overflow:auto; }}
    ol {{ margin-top:6px; }}
  </style>
</head>
<body>
<main>
  <h1>Intro Battle Runtime Review</h1>
  <p class="muted">aaa.cns 페이드 직후부터 배틀 연출, logo_00/title 최초 화면까지의 런타임 draw/sound/text 근거 요약입니다. 원본 대용량 로그는 <code>{h(data.get('source', {}).get('jsonl'))}</code>에 있고, 이 페이지는 승격된 요약만 표시합니다.</p>
  <section>
    <h2>Summary</h2>
    <div class="summary-grid">
      <div class="metric"><span>events</span><strong>{h(summary.get('eventCount'))}</strong></div>
      <div class="metric"><span>action anchors</span><strong>{h(summary.get('textAnchorCount'))}</strong></div>
      <div class="metric"><span>action phases</span><strong>{h(summary.get('actionPhaseCount'))}</strong></div>
      <div class="metric"><span>DSound create/play</span><strong>{h(summary.get('directSoundCreateOrPlayCount'))}</strong></div>
      <div class="metric"><span>exact WLK matches</span><strong>{h(summary.get('exactWlkSoundMatches'))}</strong></div>
      <div class="metric"><span>palette events</span><strong>{h(summary.get('paletteEventCount'))}</strong></div>
    </div>
  </section>
  <section>
    <h2>Core Conclusions</h2>
    <ol>{conclusions}</ol>
  </section>
  <section>
    <h2>Event Counts</h2>
    <div class="table-wrap">{render_counter(summary.get('eventCounts', []), ('event','count'), 24)}</div>
  </section>
  <section>
    <h2>Top Draw Resources</h2>
    <div class="table-wrap">{render_counter(summary.get('resourceDrawCounts', []), ('resource','draw count'), 24)}</div>
  </section>
  <section>
    <h2>Text Anchors</h2>
    <div class="table-wrap"><table><thead><tr><th>frame</th><th>phase</th><th>pc</th><th>rect</th><th>font</th><th>raw CP949</th><th>decoded</th></tr></thead><tbody>{text_anchor_rows}</tbody></table></div>
  </section>
  <section>
    <h2>Palette Runs</h2>
    <p class="muted">로고/섬광성 전환은 alpha draw가 아니라 primary surface palette 변경으로 관측됩니다.</p>
    <div class="table-wrap"><table><thead><tr><th>#</th><th>frames</th><th>events</th><th>palette hash</th></tr></thead><tbody>{palette_rows}</tbody></table></div>
  </section>
  <section>
    <h2>Logo Runs</h2>
    <div class="table-wrap"><table><thead><tr><th>#</th><th>frames</th><th>frame count</th><th>draws</th><th>top source rects</th></tr></thead><tbody>{logo_rows}</tbody></table></div>
  </section>
  {phases}
  <section>
    <h2>Sound Events</h2>
    <p class="muted">PCM bytes가 WLK WAV data chunk와 exact match될 때만 zero-based WLK 번호를 확정 근거로 표시합니다.</p>
    <div class="table-wrap">{render_sound_table([row for row in data.get('soundEvents', []) if row.get('name') in {'IDirectSound::CreateSoundBuffer','IDirectSoundBuffer::Play'}], limit=120)}</div>
  </section>
  <section>
    <h2>JSON</h2>
    <p><a href="../out/intro_battle_runtime_review.json">out/intro_battle_runtime_review.json</a></p>
  </section>
</main>
</body>
</html>
"""


def main() -> None:
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    data = build()
    JSON_OUT.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    HTML_OUT.write_text(render_html(data), encoding="utf-8")
    print(f"wrote {JSON_OUT.relative_to(ROOT)}")
    print(f"wrote {HTML_OUT.relative_to(ROOT)}")


if __name__ == "__main__":
    main()
