#!/usr/bin/env python3
"""Bridge intro-battle runtime effect draws to static helper evidence.

This report is deliberately narrower than the full intro battle review. It
answers one question: which runtime `btl_efc.cns` draw rectangles observed in
the opening battle are already explained by static battle action/helper data,
and which ones are extra result/presentation particles that should not be
mistaken for a skill-local helper.
"""
from __future__ import annotations

import html
import json
import re
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"

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

PHASE_TO_RECORD = {
    "Ataho 맹호스페셜": "ataho:0x19",
    "Linxiang 선렬각": "rinshan:0x10",
    "Smash 쾌진격": "smashu: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]:
    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 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,\-]+)",
        "flags": r"flags=(0x[0-9a-fA-F]+)",
    }.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)}"
    return parsed


def build_btl_efc_rect_map() -> dict[str, dict[str, Any]]:
    cns = read_json(OUT / "cns_rect_review_data.json")
    rect_map: dict[str, dict[str, Any]] = {}
    for row in cns.get("rows", []):
        if row.get("cns") != "btl_efc.cns":
            continue
        for rect in row.get("rects", []):
            key = f"{rect['x']},{rect['y']},{rect['x'] + rect['w']},{rect['y'] + rect['h']}"
            rect_map[key] = {**rect, "srcRect": key}
    return rect_map


def expected_skill_rows() -> dict[str, dict[str, Any]]:
    patterns = read_json(OUT / "battle_effect_animation_pattern_review.json")
    rows = {}
    for row in patterns.get("skillRows", []):
        record_key = row.get("recordKey")
        if record_key in PHASE_TO_RECORD.values():
            frames = []
            helpers = []
            for helper in row.get("helperAnimations", []):
                helper_frames = []
                for script in helper.get("frameScripts", []):
                    helper_frames.extend(script.get("frameSequence", []))
                for init in helper.get("initFrames", []):
                    if init.get("frame") is not None:
                        helper_frames.append(init.get("frame"))
                for event in (helper.get("directSpawn") or {}).get("events", []):
                    if event.get("frame") is not None:
                        helper_frames.append(event.get("frame"))
                helpers.append(
                    {
                        "helperId": helper.get("helperId"),
                        "animationClass": helper.get("animationClass"),
                        "behaviorClass": helper.get("behaviorClass"),
                        "effectFrameLabels": helper.get("effectFrameLabels", []),
                        "frames": sorted({int(frame) for frame in helper_frames if frame is not None}),
                        "directSpawnCount": (helper.get("directSpawn") or {}).get("count", 0),
                        "rootCounterSets": helper.get("rootCounterSets", {}),
                        "executionRequirements": helper.get("executionRequirements", []),
                    }
                )
                frames.extend(helpers[-1]["frames"])
            rows[record_key] = {
                "recordKey": record_key,
                "ownerName": row.get("ownerName"),
                "skillName": row.get("skillName"),
                "skillIdHex": row.get("skillIdHex"),
                "levelOrFixed": row.get("levelOrFixed"),
                "helperIds": row.get("helperIds", []),
                "effectAnimationClass": row.get("effectAnimationClass"),
                "effectFrameLabels": row.get("effectFrameLabels", []),
                "expectedFrames": sorted(set(frames)),
                "resultWlkNos": row.get("resultWlkNos", []),
                "effectWlkNos": row.get("effectWlkNos", []),
                "helperAnimations": helpers,
            }
    return rows


def load_runtime_effect_draws(
    jsonl_path: Path,
    phases: list[dict[str, Any]],
    rect_map: dict[str, dict[str, Any]],
) -> dict[str, list[dict[str, Any]]]:
    by_phase = {phase["label"]: [] for phase in phases}
    with jsonl_path.open(encoding="utf-8") as file:
        for line in file:
            if not line.strip():
                continue
            event = json.loads(line)
            if event.get("event") != "draw-surface":
                continue
            if event.get("sourceResourceName") != "btl_efc.cns" and event.get("sourceLabel") != "btl_efc.cns":
                continue
            frame = event.get("frame")
            if not isinstance(frame, int):
                continue
            phase_label = None
            for phase in phases:
                if phase["firstFrame"] <= frame <= phase["lastFrame"]:
                    phase_label = phase["label"]
                    break
            if not phase_label:
                continue
            parsed = parse_draw_detail(event.get("detail"))
            src_rect = parsed.get("srcRect")
            rect = rect_map.get(src_rect or "")
            point = parsed.get("dstPoint")
            row = {
                "seq": event.get("seq"),
                "frame": frame,
                "pc": event.get("pc"),
                "caller": parsed.get("caller"),
                "srcRect": src_rect,
                "dstPoint": point,
                "flags": parsed.get("flags"),
                "effectFrame": rect.get("index") if rect else None,
                "rectStatus": "mapped" if rect else "unmapped",
            }
            if rect:
                row["rect"] = rect
            by_phase[phase_label].append(row)
    return by_phase


def bounds(points: list[str | None], rects: list[dict[str, Any] | None]) -> dict[str, int] | None:
    xs: list[int] = []
    ys: list[int] = []
    for point, rect in zip(points, rects):
        if not point:
            continue
        try:
            x, y = [int(part) for part in point.split(",")]
        except ValueError:
            continue
        xs.append(x)
        ys.append(y)
        if rect:
            xs.append(x + int(rect.get("w", 0)))
            ys.append(y + int(rect.get("h", 0)))
    if not xs:
        return None
    return {"x0": min(xs), "y0": min(ys), "x1": max(xs), "y1": max(ys)}


def classify_observed_frame(frame: int | None, expected_frames: set[int]) -> str:
    if frame is None:
        return "unmapped-runtime-rect"
    if frame in expected_frames:
        return "covered-by-static-skill-helper"
    if 30 <= frame <= 37:
        return "runtime-extra-result-sparkle-candidate"
    if frame in {65, 66, 67, 173, 174, 175}:
        return "known-helper-family-but-not-current-static-row"
    return "runtime-extra-effect-frame"


def summarize_phase(
    phase: dict[str, Any],
    draws: list[dict[str, Any]],
    static_row: dict[str, Any] | None,
) -> dict[str, Any]:
    expected_frames = set(int(frame) for frame in (static_row or {}).get("expectedFrames", []))
    by_class: Counter[str] = Counter()
    by_frame: Counter[int | None] = Counter()
    by_runtime_frame: dict[int, Counter[int | None]] = defaultdict(Counter)
    for draw in draws:
        effect_frame = draw.get("effectFrame")
        by_frame[effect_frame] += 1
        by_runtime_frame[draw["frame"]][effect_frame] += 1
        by_class[classify_observed_frame(effect_frame, expected_frames)] += 1

    frame_rows = []
    for frame, count in by_frame.most_common():
        points = [draw.get("dstPoint") for draw in draws if draw.get("effectFrame") == frame]
        rects = [draw.get("rect") for draw in draws if draw.get("effectFrame") == frame]
        sample = next((draw for draw in draws if draw.get("effectFrame") == frame), {})
        frame_rows.append(
            {
                "effectFrame": frame,
                "drawCount": count,
                "classification": classify_observed_frame(frame, expected_frames),
                "firstFrame": min(draw["frame"] for draw in draws if draw.get("effectFrame") == frame),
                "lastFrame": max(draw["frame"] for draw in draws if draw.get("effectFrame") == frame),
                "dstBounds": bounds(points, rects),
                "sampleSrcRect": sample.get("srcRect"),
                "sampleDstPoint": sample.get("dstPoint"),
                "sampleCaller": sample.get("caller"),
            }
        )

    runtime_rows = []
    for runtime_frame in sorted(by_runtime_frame):
        counter = by_runtime_frame[runtime_frame]
        runtime_rows.append(
            {
                "runtimeFrame": runtime_frame,
                "drawCount": sum(counter.values()),
                "effectFrameCounts": [
                    {"effectFrame": frame, "count": count}
                    for frame, count in sorted(counter.items(), key=lambda item: (9999 if item[0] is None else int(item[0])))
                ],
            }
        )

    expected_covered = sorted(frame for frame in expected_frames if by_frame.get(frame))
    expected_missing = sorted(frame for frame in expected_frames if not by_frame.get(frame))
    runtime_extra = sorted(
        frame
        for frame in by_frame
        if frame is not None and frame not in expected_frames
    )

    return {
        "phaseLabel": phase["label"],
        "title": phase.get("title"),
        "recordKey": PHASE_TO_RECORD.get(phase["label"]),
        "firstFrame": phase["firstFrame"],
        "lastFrame": phase["lastFrame"],
        "runtimeDrawCount": len(draws),
        "staticRow": static_row,
        "expectedFramesCovered": expected_covered,
        "expectedFramesMissing": expected_missing,
        "runtimeExtraFrames": runtime_extra,
        "classificationCounts": by_class.most_common(),
        "observedFrameRows": frame_rows,
        "runtimeFrameRows": runtime_rows,
        "interpretation": phase_interpretation(phase["label"], static_row, expected_covered, expected_missing, runtime_extra),
    }


def phase_interpretation(
    label: str,
    static_row: dict[str, Any] | None,
    covered: list[int],
    missing: list[int],
    extra: list[int],
) -> list[str]:
    out = []
    if not static_row:
        out.append("정적 skill row를 찾지 못했으므로 runtime-only evidence로 둔다.")
        return out
    if covered:
        out.append(f"정적 helper frame {covered}가 런타임 btl_efc draw로 직접 관측됐다.")
    if missing:
        out.append(f"정적 helper frame {missing}는 이번 phase에서 관측되지 않았다. trace 구간/분기 또는 구현 매핑 재검토 대상이다.")
    if extra:
        if any(30 <= frame <= 37 for frame in extra):
            out.append("runtime extra #30..37은 공용 sparkle/result particle 후보다. 기술 자체 helper로 바로 승격하지 않는다.")
        other = [frame for frame in extra if not (30 <= frame <= 37)]
        if other:
            out.append(f"runtime extra frame {other}는 현재 정적 row에 없는 이펙트다.")
    if not static_row.get("helperIds") and extra:
        out.append("이 skill row에는 helperIds가 비어 있으므로 extra effect는 result/critical/presentation 경로 또는 아직 누락된 helper-call 후보로 분리해야 한다.")
    if label == "Smash 쾌진격" and set([30, 31, 32, 33, 34, 35, 36, 37]).intersection(set(covered)):
        out.append("쾌진격 4단계 helper 31의 sparkle child family와 런타임 source rect가 같은 frame range를 공유한다.")
    return out


def build() -> dict[str, Any]:
    trace = latest_intro_battle_trace()
    intro = read_json(OUT / "intro_battle_runtime_review.json")
    if not trace or not intro:
        return {
            "version": 1,
            "kind": "hwanse-intro-battle-helper-dictionary-review",
            "status": "missing-input",
        }
    _, jsonl_path = trace
    rect_map = build_btl_efc_rect_map()
    static_rows = expected_skill_rows()
    phases = intro.get("actionPhases", [])
    runtime_draws = load_runtime_effect_draws(jsonl_path, phases, rect_map)
    phase_rows = []
    for phase in phases:
        record_key = PHASE_TO_RECORD.get(phase["label"])
        phase_rows.append(summarize_phase(phase, runtime_draws.get(phase["label"], []), static_rows.get(record_key or "")))

    helper_dictionary = {
        "frameRange30to37": {
            "label": "sparkle/result particle family",
            "runtimeEvidence": [
                row["phaseLabel"]
                for row in phase_rows
                if any(frame in row["runtimeExtraFrames"] or frame in row["expectedFramesCovered"] for frame in range(30, 38))
            ],
            "staticEvidence": "쾌진격 2~4단 helper 29/30/31 spawn-tree frameScript에서 #30..37이 직접 나온다.",
            "promotion": "frame family confirmed; exact semantic role can be skill helper or result/presentation depending caller row.",
        },
        "frameRange65to67": {
            "label": "slash/through helper family",
            "runtimeEvidence": [
                row["phaseLabel"]
                for row in phase_rows
                if any(frame in row["expectedFramesCovered"] for frame in [65, 66, 67])
            ],
            "staticEvidence": "helper 12 frameScript에서 #65,#66,#67이 나온다.",
            "promotion": "confirmed for 쾌진격 phase when observed.",
        },
        "frameRange173to175": {
            "label": "선렬각 final streak helper family",
            "runtimeEvidence": [
                row["phaseLabel"]
                for row in phase_rows
                if any(frame in row["expectedFramesCovered"] for frame in [173, 174, 175])
            ],
            "staticEvidence": "선렬각 4단 helper 110 frameScript에서 #173,#174,#175가 나온다.",
            "promotion": "confirmed for 선렬각 phase.",
        },
    }

    return {
        "version": 1,
        "kind": "hwanse-intro-battle-helper-dictionary-review",
        "status": "runtime-static-helper-bridge",
        "source": {
            "runtime": "out/intro_battle_runtime_review.json",
            "rawTrace": str(jsonl_path.relative_to(ROOT)),
            "staticPatterns": "out/battle_effect_animation_pattern_review.json",
            "rects": "out/cns_rect_review_data.json",
        },
        "summary": {
            "phaseCount": len(phase_rows),
            "runtimeBtlEfcDraws": sum(row["runtimeDrawCount"] for row in phase_rows),
            "coveredStaticFrames": sum(len(row["expectedFramesCovered"]) for row in phase_rows),
            "missingStaticFrames": sum(len(row["expectedFramesMissing"]) for row in phase_rows),
            "runtimeExtraFrames": sorted({frame for row in phase_rows for frame in row["runtimeExtraFrames"]}),
            "classificationCounts": Counter(
                label
                for row in phase_rows
                for label, count in row["classificationCounts"]
                for _ in range(count)
            ).most_common(),
        },
        "helperDictionary": helper_dictionary,
        "phaseRows": phase_rows,
        "conclusions": [
            "intro-battle 런타임은 btl_efc draw source rect를 실제 frame index로 매핑할 수 있는 첫 근거다.",
            "쾌진격 4단계는 정적 helper 31(#30..37) 및 helper 12(#65..67)와 런타임 frame range가 맞물린다.",
            "선렬각 4단계는 helper 110(#173..175)이 런타임에서 관측되어 정적 helper 해석을 보강한다.",
            "맹호스페셜은 정적 skill helper가 비어 있는데 #30..37 계열이 관측되므로, 이는 skill-local helper가 아니라 result/critical/presentation 계열 후보로 보류한다.",
            "따라서 이 런타임은 모든 기술을 자동 확정하지는 않지만, #30..37, #65..67, #173..175 frame family의 실제 draw 경로를 확인하는 dictionary 기준점으로 쓸 수 있다.",
        ],
    }


def render_html(data: dict[str, Any]) -> str:
    summary = data.get("summary", {})
    conclusion_items = "".join(f"<li>{h(row)}</li>" for row in data.get("conclusions", []))
    dict_rows = "".join(
        "<tr>"
        f"<td><code>{h(key)}</code></td>"
        f"<td>{h(row.get('label'))}</td>"
        f"<td>{h(', '.join(row.get('runtimeEvidence', [])) or '-')}</td>"
        f"<td>{h(row.get('staticEvidence'))}</td>"
        f"<td>{h(row.get('promotion'))}</td>"
        "</tr>"
        for key, row in data.get("helperDictionary", {}).items()
    )
    phase_html = []
    for phase in data.get("phaseRows", []):
        interp = "".join(f"<li>{h(line)}</li>" for line in phase.get("interpretation", []))
        static = phase.get("staticRow") or {}
        observed_rows = "".join(
            "<tr>"
            f"<td><code>{h(row.get('effectFrame'))}</code></td>"
            f"<td>{h(row.get('drawCount'))}</td>"
            f"<td>{h(row.get('classification'))}</td>"
            f"<td>{h(row.get('firstFrame'))}..{h(row.get('lastFrame'))}</td>"
            f"<td><code>{h(row.get('sampleSrcRect'))}</code></td>"
            f"<td><code>{h(row.get('sampleDstPoint'))}</code></td>"
            f"<td>{h(row.get('dstBounds'))}</td>"
            "</tr>"
            for row in phase.get("observedFrameRows", [])
        )
        runtime_row_parts = []
        for row in phase.get("runtimeFrameRows", [])[:40]:
            effect_counts = ", ".join(
                f"#{item['effectFrame']} x{item['count']}"
                for item in row.get("effectFrameCounts", [])
            )
            runtime_row_parts.append(
                "<tr>"
                f"<td>{h(row.get('runtimeFrame'))}</td>"
                f"<td>{h(row.get('drawCount'))}</td>"
                f"<td>{h(effect_counts)}</td>"
                "</tr>"
            )
        runtime_rows = "".join(runtime_row_parts)
        phase_html.append(
            f"""
            <section>
              <h2>{h(phase.get('phaseLabel'))}</h2>
              <p class="muted">runtime frames {h(phase.get('firstFrame'))}..{h(phase.get('lastFrame'))}; record <code>{h(phase.get('recordKey'))}</code>; runtime btl_efc draws {h(phase.get('runtimeDrawCount'))}</p>
              <div class="grid">
                <div class="box"><strong>Static helper ids</strong><br>{h(static.get('helperIds', []))}</div>
                <div class="box"><strong>Expected frames</strong><br>{h(static.get('expectedFrames', []))}</div>
                <div class="box"><strong>Covered</strong><br>{h(phase.get('expectedFramesCovered'))}</div>
                <div class="box"><strong>Runtime extra</strong><br>{h(phase.get('runtimeExtraFrames'))}</div>
              </div>
              <ol>{interp}</ol>
              <h3>Observed btl_efc frames</h3>
              <div class="table-wrap"><table><thead><tr><th>frame</th><th>draws</th><th>class</th><th>runtime frames</th><th>srcRect</th><th>sample dst</th><th>bounds</th></tr></thead><tbody>{observed_rows}</tbody></table></div>
              <details><summary>Runtime frame timeline</summary><div class="table-wrap"><table><thead><tr><th>runtime frame</th><th>draws</th><th>effect frame counts</th></tr></thead><tbody>{runtime_rows}</tbody></table></div></details>
            </section>
            """
        )
    class_rows = "".join(
        f"<tr><td>{h(label)}</td><td>{h(count)}</td></tr>"
        for label, count in summary.get("classificationCounts", [])
    )
    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 Helper Dictionary Review</title>
  <style>
    :root {{ color-scheme: dark; --bg:#101316; --panel:#181d22; --line:#2c3740; --text:#e9eef3; --muted:#9aa8b3; --accent:#88d1c7; }}
    body {{ margin:0; background:var(--bg); color:var(--text); font-family:system-ui,-apple-system,Segoe UI,sans-serif; }}
    main {{ max-width:1240px; margin:0 auto; padding:24px; }}
    a {{ color:var(--accent); }}
    section {{ background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:16px; margin:14px 0; }}
    .muted {{ color:var(--muted); }}
    code {{ color:#d8ecff; }}
    .grid {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; margin:10px 0; }}
    .box {{ border:1px solid var(--line); background:#11171b; border-radius:8px; padding:10px; }}
    table {{ width:100%; border-collapse:collapse; font-size:13px; }}
    th,td {{ padding:7px 8px; border-bottom:1px solid var(--line); text-align:left; vertical-align:top; }}
    th {{ background:#14191e; }}
    .table-wrap {{ overflow:auto; }}
  </style>
</head>
<body>
<main>
  <h1>Intro Battle Helper Dictionary Review</h1>
  <p class="muted">intro-battle 런타임의 실제 <code>btl_efc.cns</code> draw source rect를 정적 helper frameScript와 연결한 브리지입니다.</p>
  <section>
    <h2>Summary</h2>
    <div class="grid">
      <div class="box"><strong>Phases</strong><br>{h(summary.get('phaseCount'))}</div>
      <div class="box"><strong>Runtime btl_efc draws</strong><br>{h(summary.get('runtimeBtlEfcDraws'))}</div>
      <div class="box"><strong>Covered static frames</strong><br>{h(summary.get('coveredStaticFrames'))}</div>
      <div class="box"><strong>Runtime extra frames</strong><br>{h(summary.get('runtimeExtraFrames'))}</div>
    </div>
    <ol>{conclusion_items}</ol>
  </section>
  <section>
    <h2>Classification Counts</h2>
    <table><thead><tr><th>class</th><th>draw count</th></tr></thead><tbody>{class_rows}</tbody></table>
  </section>
  <section>
    <h2>Helper Dictionary</h2>
    <div class="table-wrap"><table><thead><tr><th>range</th><th>label</th><th>runtime evidence</th><th>static evidence</th><th>promotion</th></tr></thead><tbody>{dict_rows}</tbody></table></div>
  </section>
  {''.join(phase_html)}
  <section>
    <h2>JSON</h2>
    <p><a href="../out/intro_battle_helper_dictionary_review.json">out/intro_battle_helper_dictionary_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()
