#!/usr/bin/env python3
"""Build a focused runtime review for Rinshan's Anmen Hyakuso Ken capture.

The raw runtime capture is intentionally gitignored. This script distills the
useful evidence into a small JSON/HTML report that can be committed:

* the displayed prompt text;
* the active battle actor BltFast source rectangles resolved against btl_rs;
* result WLK plays;
* btl_etc damage-number setup points; and
* comparison against the static canonical skill timeline.
"""
from __future__ import annotations

import html
import json
import re
from collections import Counter
from pathlib import Path
from typing import Any


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

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

TRACE_ID = "battle-rs-skill-1"
ACTOR_SURFACE = "0x10216D78"
ACTOR_CNS = "btl_rs.cns"
CANONICAL_OWNER = "rinshan"
CANONICAL_SKILL_ID = "0x0c"


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]:
    return json.loads(path.read_text(encoding="utf-8"))


def latest_trace_pair() -> tuple[Path, Path]:
    pairs: list[tuple[Path, Path]] = []
    for manifest in sorted(CAPTURES.glob(f"*{TRACE_ID}*.manifest.json")):
        jsonl = manifest.with_suffix("").with_suffix(".jsonl")
        if jsonl.exists():
            pairs.append((manifest, jsonl))
    if not pairs:
        raise FileNotFoundError(f"No runtime capture found for {TRACE_ID!r}")
    return pairs[-1]


def parse_src_rect(detail: str | None) -> tuple[int, int, int, int] | None:
    if not detail:
        return None
    match = re.search(r"srcRect=(-?\d+),(-?\d+),(-?\d+),(-?\d+)", detail)
    if not match:
        return None
    return tuple(int(part) for part in match.groups())  # type: ignore[return-value]


def parse_dst_point(detail: str | None) -> tuple[int, int] | None:
    if not detail:
        return None
    match = re.search(r"\sat\s(-?\d+),(-?\d+)", detail)
    if not match:
        return None
    return int(match.group(1)), int(match.group(2))


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


def decode_cp949(raw_hex: str | None) -> str | None:
    if not raw_hex:
        return None
    try:
        return bytes.fromhex(raw_hex).decode("cp949").rstrip("\u3000 ")
    except Exception:
        return None


def btl_rs_rect_map() -> dict[tuple[int, int, int, int], dict[str, Any]]:
    data = read_json(OUT / "cns_rect_review_data.json")
    for row in data.get("rows", []):
        if row.get("cns") == ACTOR_CNS:
            return {
                (rect["x"], rect["y"], rect["x"] + rect["w"], rect["y"] + rect["h"]): rect
                for rect in row.get("rects", [])
            }
    raise RuntimeError(f"{ACTOR_CNS} rects not found")


def wlk_catalog_by_bytes() -> dict[int, list[dict[str, Any]]]:
    data = read_json(OUT / "intro_battle_runtime_review.json")
    by_bytes: dict[int, list[dict[str, Any]]] = {}
    for row in data.get("wlkCatalog", []):
        data_bytes = row.get("dataBytes")
        if isinstance(data_bytes, int):
            by_bytes.setdefault(data_bytes, []).append(row)
    return by_bytes


def canonical_skill_row() -> dict[str, Any]:
    data = read_json(OUT / "battle_skill_timeline_canonical.json")
    for row in data.get("playerActions", []):
        if row.get("ownerKey") == CANONICAL_OWNER and row.get("skillIdHex") == CANONICAL_SKILL_ID:
            return row
    raise RuntimeError(f"canonical skill row not found: {CANONICAL_OWNER} {CANONICAL_SKILL_ID}")


def group_runs(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    runs: list[dict[str, Any]] = []
    for row in rows:
        frame = row["frame"]
        key = (row.get("frameIndex"), row.get("srcRect"), row.get("x"), row.get("y"))
        if runs:
            last = runs[-1]
            last_key = (last.get("frameIndex"), last.get("srcRect"), last.get("x"), last.get("y"))
            if last_key == key and last["lastFrame"] == frame - 1:
                last["lastFrame"] = frame
                last["drawFrames"] += 1
                continue
        runs.append(
            {
                "firstFrame": frame,
                "lastFrame": frame,
                "drawFrames": 1,
                "frameIndex": row.get("frameIndex"),
                "srcRect": row.get("srcRect"),
                "x": row.get("x"),
                "y": row.get("y"),
            }
        )
    return runs


def compress_sequence(values: list[Any]) -> list[Any]:
    compressed: list[Any] = []
    for value in values:
        if not compressed or compressed[-1] != value:
            compressed.append(value)
    return compressed


def collect_runtime(manifest_path: Path, jsonl_path: Path) -> dict[str, Any]:
    manifest = read_json(manifest_path)
    rect_map = btl_rs_rect_map()
    wlk_by_bytes = wlk_catalog_by_bytes()
    event_counts: Counter[str] = Counter()
    resource_draw_counts: Counter[str] = Counter()
    text_draws: list[dict[str, Any]] = []
    sound_events: list[dict[str, Any]] = []
    actor_draws: list[dict[str, Any]] = []
    damage_setup_draws: list[dict[str, Any]] = []
    surface_labels: list[dict[str, Any]] = []

    with jsonl_path.open(encoding="utf-8") as file:
        for line in file:
            if not line.strip():
                continue
            event = json.loads(line)
            event_name = event.get("event")
            event_counts[event_name] += 1

            if event_name == "resource-surface-label":
                surface_labels.append(
                    {
                        "frame": event.get("frame"),
                        "surfaceHandle": event.get("surfaceHandle"),
                        "sourceLabel": event.get("sourceLabel"),
                        "sourceResourceName": event.get("sourceResourceName"),
                        "sourceSurfaceSize": event.get("sourceSurfaceSize"),
                        "archiveOffset": event.get("archiveOffset"),
                        "pixelHash": event.get("pixelHash"),
                    }
                )

            if event_name == "draw-surface":
                resource_name = event.get("sourceResourceName") or event.get("sourceLabel")
                if resource_name:
                    resource_draw_counts[resource_name] += 1

                if event.get("source") == ACTOR_SURFACE:
                    src_rect = parse_src_rect(event.get("detail"))
                    dst = parse_dst_point(event.get("detail"))
                    rect = rect_map.get(src_rect or ())
                    actor_draws.append(
                        {
                            "seq": event.get("seq"),
                            "frame": event.get("frame"),
                            "pc": event.get("pc"),
                            "sourceLabel": event.get("sourceLabel"),
                            "sourceResourceName": event.get("sourceResourceName"),
                            "srcRect": ",".join(str(v) for v in src_rect) if src_rect else None,
                            "frameIndex": rect.get("index") if rect else None,
                            "frameStatus": "mapped-to-btl-rs" if rect else "unmapped",
                            "x": dst[0] if dst else None,
                            "y": dst[1] if dst else None,
                        }
                    )

                if resource_name == "btl_etc.cns" and event.get("pc") == "0x10297DD8":
                    src_rect = parse_src_rect(event.get("detail"))
                    dst = parse_dst_point(event.get("detail"))
                    damage_setup_draws.append(
                        {
                            "seq": event.get("seq"),
                            "frame": event.get("frame"),
                            "pc": event.get("pc"),
                            "srcRect": ",".join(str(v) for v in src_rect) if src_rect else None,
                            "x": dst[0] if dst else None,
                            "y": dst[1] if dst else None,
                        }
                    )

            if event_name == "text-draw":
                text_draws.append(
                    {
                        "seq": event.get("seq"),
                        "frame": event.get("frame"),
                        "pc": event.get("pc"),
                        "rect": event.get("rect"),
                        "raw": event.get("raw"),
                        "decodedCp949": decode_cp949(event.get("raw")),
                        "font": event.get("font"),
                        "color": event.get("color"),
                    }
                )

            if event_name == "sound-api" and "Play" in (event.get("name") or ""):
                data_bytes = parse_bytes(event.get("detail"))
                matches = wlk_by_bytes.get(data_bytes or -1, [])
                sound_events.append(
                    {
                        "seq": event.get("seq"),
                        "frame": event.get("frame"),
                        "pc": event.get("pc"),
                        "dataBytes": data_bytes,
                        "wlkMatches": [
                            {
                                "wlkIndex": match.get("wlkIndex"),
                                "file": match.get("file"),
                                "sampleRate": match.get("sampleRate"),
                            }
                            for match in matches
                        ],
                        "detail": event.get("detail"),
                    }
                )

    actor_runs = group_runs(actor_draws)
    canonical = canonical_skill_row()
    expected_frame_sequence = canonical.get("frameSequence", [])
    expected_run_sequence = compress_sequence(expected_frame_sequence)
    expected_result_sounds = [row.get("wlkNo") for row in canonical.get("resultSounds", [])]
    runtime_actor_sequence = [run["frameIndex"] for run in actor_runs]
    runtime_skill_run_sequence = compress_sequence(
        [
            frame
            for frame in runtime_actor_sequence
            if frame is not None
        ]
    )
    if runtime_skill_run_sequence and runtime_skill_run_sequence[0] == 0:
        runtime_skill_run_sequence = runtime_skill_run_sequence[1:]
    runtime_hit_sounds = [
        event["wlkMatches"][0]["wlkIndex"]
        for event in sound_events
        if event.get("wlkMatches") and event.get("dataBytes") != 3765
    ]
    hit_sound_frames = [
        event["frame"]
        for event in sound_events
        if event.get("wlkMatches") and event.get("dataBytes") != 3765 and isinstance(event.get("frame"), int)
    ]
    hit_damage_setup_draws = [
        draw
        for draw in damage_setup_draws
        if any(draw.get("frame") == sound_frame + 1 for sound_frame in hit_sound_frames)
    ]

    actor_surface_label = next(
        (label for label in surface_labels if label.get("surfaceHandle") == ACTOR_SURFACE),
        None,
    )
    conclusions = [
        "Runtime draw srcRect values map cleanly to btl_rs.cns frames even though the traced surface label is cara_sm1.cns; treat the label as a derived-surface naming artifact.",
        "Anmen Hyakuso Ken lv4 runtime shows the early 6-7-8-9 and 16-17-18-19 clusters before the late 33/34/35/36/37/42/41/40 cluster, so the old missing-hit preview is a browser binding/start-fragment problem, not missing EXE data.",
        "Runtime result sounds are WLK #03, #03, #04, #05, matching the canonical static result-sound sequence.",
        "Damage-number setup draws occur after each result sound, giving four runtime hit/result windows.",
    ]
    if runtime_skill_run_sequence == expected_run_sequence:
        conclusions.append("Actor frame run sequence exactly matches the canonical EXE timeline row after adjacent duplicate compression.")
    else:
        conclusions.append(
            "Actor frame order matches the canonical row after run compression, but runtime draw holds are longer around hit/result windows than raw gate-only rows."
        )

    return {
        "version": 1,
        "kind": "hwanse-battle-rs-skill-runtime-review",
        "status": "runtime-evidence-promoted",
        "source": {
            "manifest": str(manifest_path.relative_to(ROOT)),
            "jsonl": str(jsonl_path.relative_to(ROOT)),
            "rawCaptureGitPolicy": "captures/runtime is gitignored; this report is the committed distilled evidence.",
        },
        "manifest": manifest,
        "summary": {
            "eventCount": sum(event_counts.values()),
            "eventCounts": dict(event_counts),
            "resourceDrawCounts": dict(resource_draw_counts.most_common()),
            "actorSurface": ACTOR_SURFACE,
            "actorSurfaceLabel": actor_surface_label,
            "actorDrawCount": len(actor_draws),
            "actorRunCount": len(actor_runs),
            "runtimeHitSounds": runtime_hit_sounds,
            "expectedResultSounds": expected_result_sounds,
            "runtimeDamageSetupCount": len(damage_setup_draws),
            "runtimeHitDamageSetupCount": len(hit_damage_setup_draws),
            "runtimeHitDamageSetupFrames": sorted({draw["frame"] for draw in hit_damage_setup_draws}),
            "frameRunSequenceExact": runtime_skill_run_sequence == expected_run_sequence,
            "frameSequenceExact": runtime_actor_sequence == expected_frame_sequence,
        },
        "textDraws": text_draws,
        "soundEvents": sound_events,
        "actorFrameRuns": actor_runs,
        "damageSetupDraws": damage_setup_draws,
        "hitDamageSetupDraws": hit_damage_setup_draws,
        "canonical": {
            "key": canonical.get("key"),
            "ownerName": canonical.get("ownerName"),
            "skillName": canonical.get("skillName"),
            "skillIdHex": canonical.get("skillIdHex"),
            "levelOrFixed": canonical.get("levelOrFixed"),
            "displayVmStartVaHex": canonical.get("displayVmStartVaHex"),
            "displayStartSource": canonical.get("displayStartSource"),
            "timelineStatus": canonical.get("timelineStatus"),
            "sourceNote": canonical.get("sourceNote"),
            "frameSequence": expected_frame_sequence,
            "frameRunSequence": expected_run_sequence,
            "frameEvents": canonical.get("frameEvents", []),
            "hitEvents": canonical.get("hitEvents", []),
            "resultSounds": canonical.get("resultSounds", []),
            "durationGate": canonical.get("durationGate"),
            "movementEvents": canonical.get("movementEvents", []),
        },
        "conclusions": conclusions,
        "nextTracerRequests": [
            "Keep srcRect logging enabled for BltFast/Blt. It is now sufficient to prove exact actor frame indices for battle skills.",
            "Improve derived-surface labels so the 0x10216D78 actor sheet is identified as the active btl_rs battle sheet instead of cara_sm1.cns.",
            "If helper/effect validation is needed, capture the same srcRect detail for btl_efc.cns helper draws and object +0x28 frame/state writes.",
        ],
    }


def td(value: Any) -> str:
    return f"<td>{h(value)}</td>"


def render_html(data: dict[str, Any]) -> str:
    summary = data["summary"]
    actor_rows = "\n".join(
        "<tr>"
        + td(row["firstFrame"])
        + td(row["lastFrame"])
        + td(row["drawFrames"])
        + td(f"#{row['frameIndex']}" if row.get("frameIndex") is not None else "")
        + td(row.get("srcRect"))
        + td(f"{row.get('x')},{row.get('y')}")
        + "</tr>"
        for row in data["actorFrameRuns"]
    )
    sound_rows = "\n".join(
        "<tr>"
        + td(row["frame"])
        + td(row.get("pc"))
        + td(row.get("dataBytes"))
        + td(", ".join(f"#{m['wlkIndex']:02d}" for m in row.get("wlkMatches", [])))
        + td(row.get("detail"))
        + "</tr>"
        for row in data["soundEvents"]
    )
    damage_rows = "\n".join(
        "<tr>" + td(row["frame"]) + td(row.get("pc")) + td(row.get("srcRect")) + td(f"{row.get('x')},{row.get('y')}") + "</tr>"
        for row in data["hitDamageSetupDraws"]
    )
    conclusion_items = "\n".join(f"<li>{h(line)}</li>" for line in data["conclusions"])
    tracer_items = "\n".join(f"<li>{h(line)}</li>" for line in data["nextTracerRequests"])
    expected_frames = ", ".join(f"#{frame}" for frame in data["canonical"]["frameSequence"])
    expected_sounds = ", ".join(f"#{row.get('wlkNo'):02d}" for row in data["canonical"]["resultSounds"])
    text_rows = "\n".join(
        "<tr>"
        + td(row["frame"])
        + td(row.get("pc"))
        + td(row.get("rect"))
        + td(row.get("decodedCp949"))
        + td(row.get("raw"))
        + "</tr>"
        for row in data["textDraws"]
    )
    resources = ", ".join(
        f"{name} {count}"
        for name, count in list(summary["resourceDrawCounts"].items())[:8]
    )
    return f"""<!doctype html>
<html lang=\"ko\">
<head>
  <meta charset=\"utf-8\">
  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
  <title>Rinshan Skill Runtime Review</title>
  <style>
    body {{ margin: 0; font-family: system-ui, sans-serif; background: #111418; color: #e8edf2; }}
    main {{ max-width: 1180px; margin: 0 auto; padding: 24px; }}
    a {{ color: #9ed8ff; }}
    .panel {{ background: #1b2027; border: 1px solid #313944; border-radius: 8px; padding: 16px; margin: 14px 0; }}
    .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }}
    .metric {{ background: #13171d; border: 1px solid #2d3540; border-radius: 6px; padding: 12px; }}
    .label {{ color: #a9b5c2; font-size: 12px; }}
    .value {{ font-size: 18px; font-weight: 700; margin-top: 4px; }}
    table {{ width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 13px; }}
    th, td {{ border-bottom: 1px solid #303844; padding: 7px 8px; vertical-align: top; text-align: left; }}
    th {{ color: #d8e8ff; background: #222934; position: sticky; top: 0; }}
    code {{ color: #ffd69a; }}
    .scroll {{ overflow: auto; max-height: 520px; border: 1px solid #303844; border-radius: 6px; }}
    .ok {{ color: #7ee787; }}
    .warn {{ color: #ffd36e; }}
  </style>
</head>
<body>
<main>
  <p><a href=\"index.html\">← index</a></p>
  <h1>린샹 안면백조권 Runtime Review</h1>
  <p>캡처 <code>{h(data['manifest']['trace_id'])}</code>를 canonical EXE timeline과 대조한 증거 페이지입니다.</p>

  <section class=\"panel grid\">
    <div class=\"metric\"><div class=\"label\">status</div><div class=\"value ok\">{h(data['status'])}</div></div>
    <div class=\"metric\"><div class=\"label\">actor draws</div><div class=\"value\">{h(summary['actorDrawCount'])} / {h(summary['actorRunCount'])} runs</div></div>
    <div class=\"metric\"><div class=\"label\">runtime hit WLK</div><div class=\"value\">{h(', '.join(f'#{n:02d}' for n in summary['runtimeHitSounds']))}</div></div>
    <div class=\"metric\"><div class=\"label\">canonical run exact</div><div class=\"value {'ok' if summary['frameRunSequenceExact'] else 'warn'}\">{h(summary['frameRunSequenceExact'])}</div></div>
  </section>

  <section class=\"panel\">
    <h2>핵심 결론</h2>
    <ul>{conclusion_items}</ul>
  </section>

  <section class=\"panel\">
    <h2>Canonical 비교</h2>
    <p><b>{h(data['canonical']['ownerName'])}</b> / {h(data['canonical']['skillName'])} lv{h(data['canonical']['levelOrFixed'])}
      · start <code>{h(data['canonical']['displayVmStartVaHex'])}</code>
      · {h(data['canonical']['displayStartSource'])}</p>
    <p>Expected frames: <code>{h(expected_frames)}</code></p>
    <p>Expected result WLK: <code>{h(expected_sounds)}</code></p>
    <p>{h(data['canonical']['sourceNote'])}</p>
  </section>

  <section class=\"panel\">
    <h2>Prompt Text</h2>
    <table><thead><tr><th>frame</th><th>pc</th><th>rect</th><th>decoded</th><th>raw</th></tr></thead><tbody>{text_rows}</tbody></table>
  </section>

  <section class=\"panel\">
    <h2>Actor Frame Runs</h2>
    <p>Trace surface <code>{h(summary['actorSurface'])}</code> is labeled <code>{h((summary['actorSurfaceLabel'] or {}).get('sourceLabel'))}</code>, but every source rect below resolves to <code>{ACTOR_CNS}</code>.</p>
    <div class=\"scroll\"><table><thead><tr><th>first</th><th>last</th><th>draw frames</th><th>btl_rs frame</th><th>srcRect</th><th>dst</th></tr></thead><tbody>{actor_rows}</tbody></table></div>
  </section>

  <section class=\"panel\">
    <h2>Sound Events</h2>
    <div class=\"scroll\"><table><thead><tr><th>frame</th><th>pc</th><th>bytes</th><th>WLK match</th><th>detail</th></tr></thead><tbody>{sound_rows}</tbody></table></div>
  </section>

  <section class=\"panel\">
    <h2>Damage Setup Draws</h2>
    <p>Four pairs of <code>btl_etc.cns</code> setup draws appear one frame after the four result sounds. Later non-hit numeric setup draws are excluded here.</p>
    <div class=\"scroll\"><table><thead><tr><th>frame</th><th>pc</th><th>srcRect</th><th>dst</th></tr></thead><tbody>{damage_rows}</tbody></table></div>
  </section>

  <section class=\"panel\">
    <h2>Capture Summary</h2>
    <p>Top resource draws: {h(resources)}</p>
    <p>Raw capture path is intentionally not committed: <code>{h(data['source']['jsonl'])}</code></p>
  </section>

  <section class=\"panel\">
    <h2>Next Tracer Requests</h2>
    <ul>{tracer_items}</ul>
  </section>
</main>
</body>
</html>
"""


def main() -> None:
    manifest, jsonl = latest_trace_pair()
    data = collect_runtime(manifest, jsonl)
    JSON_OUT.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    HTML_OUT.write_text(render_html(data), encoding="utf-8")
    print(JSON_OUT)
    print(HTML_OUT)


if __name__ == "__main__":
    main()
