#!/usr/bin/env python3
"""Classify battle WLK calls by probable runtime role.

The older sound/effect report proved that opcode 0x24 and opcode 0xc2 are
different sound paths.  This report adds a higher-level review layer:

* 0x24 effect/cast cue calls
* 0xc2 result normal operands
* 0xc2 result alternate operands
* status payload bytes that may affect result behavior but do not yet expose a
  dedicated WLK call in the action script
"""
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]
OUT = ROOT / "out"
TIMELINE_JSON = OUT / "battle_action_event_timeline_review.json"
SOUND_EFFECT_JSON = OUT / "battle_sound_effect_review.json"
MAPPING_JSON = OUT / "battle_action_mapping.json"
RESULT_DISPLAY_JSON = OUT / "battle_result_display_branch_review.json"
STATUS_SUCCESS_SOUND_JSON = OUT / "battle_status_success_sound_review.json"


STATUS_LABELS = {
    0x00: "none",
    0x01: "넘어짐",
    0x02: "휙날아감",
    0x03: "행동정지",
    0x04: "독",
    0x05: "마비",
}

ROLE_HINTS = {
    0: "0xc2 mode=2 result cue. 호격권/맹호의 울부짖음 계열에서 쓰이는 작은 결과음 후보.",
    3: "0xc2 normal light physical hit. 맹호스페셜/선렬각 일반 연타에서 EXE operand로 확인됨.",
    4: "0xc2 normal heavy/body hit. 맹호스페셜 후반부에서 EXE operand로 확인됨.",
    5: "0xc2 normal finisher/heavy hit. 맹호스페셜/선렬각 마지막 결과음에서 EXE operand로 확인됨.",
    6: "0x24 cast/effect cue. 호격권/맹호의 울부짖음 계열 시전음 후보.",
    7: "0xc2 mode=1 skill-specific result cue. 맹호유성각/취호염무/절사어면 등에서 사용.",
    8: "0xc2 mode=1 skill-specific result cue. 흑영권/환영각/투신의 춤 계열.",
    9: "0xc2 mode=1 skill-specific result cue. 암각·영상뢰화/창룡파 계열.",
    10: "0x24 recovery/guard effect cue. 기공회복/기공독치료/기공대회복/수경.",
    11: "target result branch에서 0x08 guard/glancing chip damage의 약한 '팅' 결과음으로 선택된다.",
    12: "target result branch에서 0x20 full miss / HP-apply skip의 MISS helper 0x05와 함께 선택된다.",
    14: "0xc2 alternate/critical result sound. attacker +0x62 bit 0x10이 켜질 때 stream operand +3으로 선택되는 대체 결과음이다. critical은 텍스트 없이 섬광/전용 사운드/높은 데미지로 인지된다는 관찰과도 맞는다.",
    15: "0x24 clone/cast cue. 인법·분신술 계열.",
    16: "0x24 movement/cast cue. 맹호비상각/맹호유성각 계열.",
    17: "0x24 beam/fire effect cue 또는 0xc2 mode=1 skill result cue. 광파참/열시섬/맹호열지조에서 사용.",
    18: "mixed. 0x24 visual/effect cue와 0xc2 result cue 모두에서 사용되어 세부 문맥 필요.",
    20: "0xc2 normal sword/slash result cue. 대타격/백인일섬/비검·시공단 계열.",
    21: "0x24/0xc2 mode=1 elemental/special cue. 영상승룡파/빙조권/염가열소 등.",
    22: "0x24 projectile/energy effect cue. 맹호광파참/호포권 계열.",
    23: "0x24 drinking/state effect cue. 마시기 계열.",
    24: "0xc2 normal blade/pierce result cue. 쾌진격 EXE operand로 확인됨.",
    27: "0x24 falling/star effect cue. 맹호유성각 계열.",
    29: "0x24/0xc2 quake/explosion cue. 대폭진/노익장 대폭발.",
    31: "0x24 mode/control cue. 주구격/도주 쪽에서 확인.",
    32: "0x24 guard/recovery cue. 신격방어.",
    35: "0xc2 mode=1 special result cue. 고양이달래기 결과음 후보.",
    53: "0x24 rare effect cue. 맹호난무 신기에서만 확인.",
}


def esc(value: Any) -> str:
    return html.escape(str(value if value is not None else ""))


def wlk_label(value: Any) -> str:
    if value is None or value == "":
        return "-"
    return f"WLK id {int(value):02d}"


def normal_alt_label(normal: Any, alt: Any, mode: Any | None = None) -> str:
    suffix = f" / mode {mode}" if mode is not None else ""
    return f"normal {wlk_label(normal)} / alt {wlk_label(alt)}{suffix}"


def parse_unit(unit_hex: str) -> dict[str, Any]:
    values: list[int] = []
    for part in str(unit_hex).split():
        try:
            values.append(int(part, 16))
        except ValueError:
            values.append(0)
    while len(values) < 8:
        values.append(0)
    return {
        "bytesHex": unit_hex,
        "powerA": values[0],
        "powerB": values[1],
        "powerC": values[2],
        "aux": values[3],
        "targetScope": values[4],
        "family": values[5],
        "unknown6": values[6],
        "status": values[7],
        "targetScopeHex": f"0x{values[4]:02x}",
        "familyHex": f"0x{values[5]:02x}",
        "statusHex": f"0x{values[7]:02x}",
        "statusLabel": STATUS_LABELS.get(values[7], f"unknown 0x{values[7]:02x}"),
    }


def mapping_index(mapping: dict[str, Any]) -> dict[tuple[str, str], dict[str, Any]]:
    return {
        (str(row.get("ownerKey")), str(row.get("skillIdHex")).lower()): row
        for row in (mapping.get("playerRows") or []) + (mapping.get("sharedRows") or [])
    }


def sample_label(row: dict[str, Any], tick: Any | None = None) -> str:
    base = f"{row.get('ownerName')} {row.get('skillName')} {row.get('skillIdHex')}"
    if tick is not None:
        return f"{base} t{tick}"
    return base


def wlk_url(wlk_no: int) -> str:
    return f"../extract_wlk/{max(0, wlk_no):02}.wav"


def add_sample(bucket: dict[str, Any], value: str, limit: int = 12) -> None:
    samples = bucket.setdefault("samples", [])
    if value not in samples and len(samples) < limit:
        samples.append(value)


def status_set_for(row: dict[str, Any], mapped: dict[str, Any]) -> list[dict[str, Any]]:
    units = [parse_unit(unit) for unit in mapped.get("unitsHex") or []]
    if not units:
        return []
    by_status = {}
    for unit in units:
        by_status[unit["statusHex"]] = {
            "statusHex": unit["statusHex"],
            "statusLabel": unit["statusLabel"],
        }
    return [by_status[key] for key in sorted(by_status)]


def result_display_branches() -> list[dict[str, Any]]:
    if not RESULT_DISPLAY_JSON.exists():
        return []
    report = json.loads(RESULT_DISPLAY_JSON.read_text(encoding="utf-8"))
    branches = []
    for row in report.get("branchRows") or []:
        match = re.search(r"WLK id (\d+)", str(row.get("wlk") or ""))
        if not match:
            continue
        branches.append(
            {
                "case": row.get("case"),
                "flag": row.get("flag"),
                "scriptVa": row.get("scriptVa"),
                "wlkNo": int(match.group(1)),
                "effect": row.get("effect"),
                "runnerAction": row.get("runnerAction"),
            }
        )
    return branches


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


def role_for_call(call: dict[str, Any]) -> str:
    if call["opcode"] == "0x24":
        return "effect/cast cue"
    if call["opcode"] != "0xc2":
        return "unknown"
    normal = call.get("normalWlkNo")
    alt = call.get("altWlkNo")
    mode = call.get("mode")
    if alt and alt != normal:
        return "result normal with alternate operand"
    if mode == 1:
        return "skill-specific result cue"
    if mode == 2:
        return "mode=2 result cue"
    return "result normal cue"


def build() -> dict[str, Any]:
    timeline = json.loads(TIMELINE_JSON.read_text(encoding="utf-8"))
    sound_effect = json.loads(SOUND_EFFECT_JSON.read_text(encoding="utf-8"))
    mapping = json.loads(MAPPING_JSON.read_text(encoding="utf-8"))
    mapped_by_key = mapping_index(mapping)
    effect_by_key = {
        (str(row.get("ownerKey")), str(row.get("skillIdHex")).lower()): row
        for row in sound_effect.get("rows") or []
    }
    display_branches = result_display_branches()
    status_success_sound = status_success_sound_summary()

    wlk_roles: dict[int, dict[str, Any]] = {}
    result_pair_counter: Counter[tuple[int, int, int]] = Counter()
    result_pair_samples: defaultdict[tuple[int, int, int], list[str]] = defaultdict(list)
    effect_arg_counter: Counter[tuple[str, int]] = Counter()
    effect_arg_samples: defaultdict[tuple[str, int], list[str]] = defaultdict(list)
    skill_rows = []
    resolved_presentation = {
        "miss": "Grounded outside the attacker action VM: target result script 0x00454578 tests target +0x62 bit 0x20, suppresses the normal 0xc2 result sound through mask 0x28, then chooses WLK id 12 plus helper 0x05. battle_result_helper_label_review binds helper 0x05 to btl_etc frame 81 MISS.",
        "guardGlancing": "Grounded outside the attacker action VM: target +0x62 bit 0x08 chooses WLK id 11 plus helper 0x16, keeps the target on normal frame, and displays chip damage from +0x6e. This matches the no-label weak '팅' result.",
        "critical": "Grounded as attacker +0x62 bit 0x10: 0x40ef1b selects stream operand +3 as alternate WLK id 14, spawns helper runner 0x402321 with child script [0x442da1+0x150], and clears +0x10. The child script is decoded as palette flash/fade rather than text.",
        "statusSuccess": "The status success gate/applier routines contain no direct WLK/audio playback helper call and do not spawn a separate status-success display object. Status success rides on the existing hit/result presentation unless an outer caller is later proven.",
    }
    unresolved = {
        "browserTiming": "Critical palette flash and result sounds are grounded, but browser wall-clock ms conversion is still approximate.",
    }

    def role_bucket(wlk_no: int) -> dict[str, Any]:
        return wlk_roles.setdefault(wlk_no, {
            "wlkNo": wlk_no,
            "url": wlk_url(wlk_no),
            "roleHint": ROLE_HINTS.get(wlk_no, "unclassified battle WLK usage"),
            "effectCueCount": 0,
            "resultNormalCount": 0,
            "resultAltCount": 0,
            "resultDisplayBranchCount": 0,
            "runtimeObservedCount": 0,
            "modeCounts": Counter(),
            "pairCounts": Counter(),
            "statusCounts": Counter(),
            "samples": [],
            "displayBranchSamples": [],
            "runtimeSamples": [],
        })

    for row in timeline.get("rows") or []:
        key = (str(row.get("ownerKey")), str(row.get("skillIdHex")).lower())
        mapped = mapped_by_key.get(key) or {}
        effect_row = effect_by_key.get(key) or {}
        statuses = status_set_for(row, mapped)
        status_hexes = [status["statusHex"] for status in statuses] or ["0x00"]
        calls = []
        for event in (row.get("timeline") or {}).get("events") or []:
            kind = event.get("kind")
            if kind == "effect-sound" and event.get("wlkNo"):
                wlk_no = int(event["wlkNo"])
                call = {
                    "tick": event.get("tick"),
                    "opcode": "0x24",
                    "wlkNo": wlk_no,
                    "effectArgsHex": event.get("effectArgsHex"),
                    "role": "effect/cast cue",
                    "vaHex": event.get("vaHex"),
                }
                calls.append(call)
                bucket = role_bucket(wlk_no)
                bucket["effectCueCount"] += 1
                for status_hex in status_hexes:
                    bucket["statusCounts"][status_hex] += 1
                add_sample(bucket, sample_label(row, event.get("tick")))
                arg_key = (str(event.get("effectArgsHex") or ""), wlk_no)
                effect_arg_counter[arg_key] += 1
                if len(effect_arg_samples[arg_key]) < 10:
                    effect_arg_samples[arg_key].append(sample_label(row, event.get("tick")))
            elif kind == "result-sound" and event.get("wlkNo"):
                normal = int(event.get("normalWlkNo") or event.get("wlkNo"))
                alt = int(event.get("altWlkNo") or normal)
                mode = int(event.get("mode") or 0)
                call = {
                    "tick": event.get("tick"),
                    "opcode": "0xc2",
                    "wlkNo": int(event["wlkNo"]),
                    "normalWlkNo": normal,
                    "altWlkNo": alt,
                    "mode": mode,
                    "role": role_for_call({"opcode": "0xc2", "normalWlkNo": normal, "altWlkNo": alt, "mode": mode}),
                    "vaHex": event.get("vaHex"),
                }
                calls.append(call)
                normal_bucket = role_bucket(normal)
                normal_bucket["resultNormalCount"] += 1
                normal_bucket["modeCounts"][str(mode)] += 1
                normal_bucket["pairCounts"][normal_alt_label(normal, alt, mode)] += 1
                for status_hex in status_hexes:
                    normal_bucket["statusCounts"][status_hex] += 1
                add_sample(normal_bucket, sample_label(row, event.get("tick")))
                if alt != normal:
                    alt_bucket = role_bucket(alt)
                    alt_bucket["resultAltCount"] += 1
                    alt_bucket["modeCounts"][str(mode)] += 1
                    alt_bucket["pairCounts"][normal_alt_label(normal, alt, mode)] += 1
                    for status_hex in status_hexes:
                        alt_bucket["statusCounts"][status_hex] += 1
                    add_sample(alt_bucket, sample_label(row, event.get("tick")))
                pair = (normal, alt, mode)
                result_pair_counter[pair] += 1
                if len(result_pair_samples[pair]) < 12:
                    result_pair_samples[pair].append(sample_label(row, event.get("tick")))
        skill_rows.append({
            "ownerKey": row.get("ownerKey"),
            "ownerName": row.get("ownerName"),
            "skillName": row.get("skillName"),
            "skillIdHex": row.get("skillIdHex"),
            "familyName": row.get("familyName"),
            "frameSequence": row.get("frameSequence") or [],
            "effectCount": mapped.get("effectCount"),
            "targetScopes": sorted({unit["targetScopeHex"] for unit in (parse_unit(unit) for unit in mapped.get("unitsHex") or [])}),
            "families": sorted({unit["familyHex"] for unit in (parse_unit(unit) for unit in mapped.get("unitsHex") or [])}),
            "statuses": statuses,
            "calls": calls,
            "soundModel": (
                "effect+result"
                if any(call["opcode"] == "0x24" for call in calls) and any(call["opcode"] == "0xc2" for call in calls)
                else "effect-only"
                if any(call["opcode"] == "0x24" for call in calls)
                else "result-only"
                if any(call["opcode"] == "0xc2" for call in calls)
                else "no-action-wlk"
            ),
            "hitClassCounts": row.get("hitClassCounts") or {},
            "hasChildEffectCandidate": bool(effect_row.get("hasChildEffectCandidate")),
        })

    for branch in display_branches:
        wlk_no = branch.get("wlkNo")
        if not isinstance(wlk_no, int):
            continue
        bucket = role_bucket(wlk_no)
        bucket["resultDisplayBranchCount"] += 1
        label = f"{branch.get('case')} {branch.get('flag')} {branch.get('scriptVa')}"
        if label not in bucket["displayBranchSamples"] and len(bucket["displayBranchSamples"]) < 10:
            bucket["displayBranchSamples"].append(label)

    wlk_role_rows = []
    for wlk_no in sorted(wlk_roles):
        bucket = wlk_roles[wlk_no]
        row = {
            **bucket,
            "modeCounts": dict(bucket["modeCounts"].most_common()),
            "pairCounts": dict(bucket["pairCounts"].most_common()),
            "statusCounts": dict(bucket["statusCounts"].most_common()),
        }
        wlk_role_rows.append(row)

    result_pair_rows = []
    for (normal, alt, mode), count in result_pair_counter.most_common():
        result_pair_rows.append({
            "normalWlkNo": normal,
            "altWlkNo": alt,
            "mode": mode,
            "count": count,
            "role": role_for_call({"opcode": "0xc2", "normalWlkNo": normal, "altWlkNo": alt, "mode": mode}),
            "samples": result_pair_samples[(normal, alt, mode)],
        })

    effect_arg_rows = []
    for (args, wlk_no), count in effect_arg_counter.most_common():
        effect_arg_rows.append({
            "effectArgsHex": args,
            "wlkNo": wlk_no,
            "count": count,
            "roleHint": ROLE_HINTS.get(wlk_no, "effect/cast cue"),
            "samples": effect_arg_samples[(args, wlk_no)],
        })

    return {
        "version": 1,
        "kind": "hwanse-battle-sound-role-review",
        "source": [
            "out/battle_action_event_timeline_review.json",
            "out/battle_sound_effect_review.json",
            "out/battle_action_mapping.json",
            "out/battle_result_display_branch_review.json",
            "out/battle_status_success_sound_review.json",
        ],
        "status": "sound-roles-separated-by-opcode-and-result-operands",
        "summary": {
            "skillRows": len(skill_rows),
            "uniqueBattleWlkNos": len(wlk_role_rows),
            "effectCueCalls0x24": sum(row["effectCueCount"] for row in wlk_role_rows),
            "resultNormalCalls0xc2": sum(row["resultNormalCount"] for row in wlk_role_rows),
            "resultAltOperandCalls0xc2": sum(row["resultAltCount"] for row in wlk_role_rows),
            "resultDisplayBranchCalls": sum(row["resultDisplayBranchCount"] for row in wlk_role_rows),
            "resultPairCount": len(result_pair_rows),
            "effectArgPatternCount": len(effect_arg_rows),
        },
        "interpretationNotes": [
            "0x24 is separated as cast/effect cue. It should not be used as the main hit sound unless the row has no 0xc2 result path.",
            "0xc2 is separated as result sound. normalWlkNo is the observed/default result WLK; altWlkNo is an alternate operand, commonly WLK id 14.",
            "Target result display scripts can choose their own WLK outside the attacker action VM. The grounded branches are WLK id 12 for 0x20 MISS/full-miss and WLK id 11 for 0x08 guard/glancing chip damage.",
            "Mode 0 commonly appears as normal physical result with alt WLK id 14. Mode 1 often has normal==alt and behaves like a skill-specific result cue. Mode 2 currently appears in 호격권/맹호의 울부짖음 with WLK id 00.",
            "MISS has a text/helper display branch, but critical is not expected to have text. Critical should be implemented as the 0x10 alternate result path: palette flash/fade, dedicated WLK id 14 result sound, and higher damage. The WLK id 14 playback and palette child script are grounded; browser ms conversion remains approximate.",
        ],
        "resolvedPresentation": resolved_presentation,
        "unresolved": unresolved,
        "statusSuccessSoundReview": {
            "status": status_success_sound.get("status"),
            "summary": status_success_sound.get("summary") or {},
            "conclusions": status_success_sound.get("conclusions") or [],
        },
        "criticalStaticPath": {
            "consumerVaHex": "0x0040ef16",
            "flag": "attacker +0x62 bit 0x10",
            "alternateWlkOperand": "stream +3",
            "helperRunnerVaHex": "0x00402321",
            "childScriptPointer": "[0x442da1 + 0x150]",
            "commonAltWlkNo": 14,
            "status": "promoted-static-role; WLK id 14 alternate operand and palette flash child script are grounded; browser timing approximate",
        },
        "wlkRoleRows": wlk_role_rows,
        "resultPairRows": result_pair_rows,
        "effectArgRows": effect_arg_rows,
        "resultDisplayBranches": display_branches,
        "skillRows": skill_rows,
    }


def compact(values: list[Any], prefix: str = "") -> str:
    if not values:
        return "-"
    return ", ".join(f"{prefix}{value}" for value in values)


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Sound Role Review",
        "",
        f"- status: `{report['status']}`",
        f"- skill rows: {report['summary']['skillRows']}",
        f"- unique battle WLKs: {report['summary']['uniqueBattleWlkNos']}",
        f"- 0x24 effect/cast calls: {report['summary']['effectCueCalls0x24']}",
        f"- 0xc2 result normal calls: {report['summary']['resultNormalCalls0xc2']}",
        f"- 0xc2 result alt operand calls: {report['summary']['resultAltOperandCalls0xc2']}",
        f"- target result display branch calls: {report['summary']['resultDisplayBranchCalls']}",
        "",
        "## Notes",
        "",
    ]
    lines.extend(f"- {note}" for note in report["interpretationNotes"])
    lines += [
        "",
        "## WLK Role Rows",
        "",
        "| WLK | effect 0x24 | result normal | result alt | display branch | role hint | samples |",
        "| ---: | ---: | ---: | ---: | ---: | --- | --- |",
    ]
    for row in report["wlkRoleRows"]:
        samples = "; ".join(row["samples"][:5])
        if len(row["samples"]) > 5:
            samples += f"; ... +{len(row['samples']) - 5}"
        lines.append(
            f"| {wlk_label(row['wlkNo'])} | {row['effectCueCount']} | {row['resultNormalCount']} | {row['resultAltCount']} | {row['resultDisplayBranchCount']} | {row['roleHint']} | {samples} |"
        )
    lines += [
        "",
        "## Result Pairs",
        "",
        "| normal | alt | mode | count | role | samples |",
        "| ---: | ---: | ---: | ---: | --- | --- |",
    ]
    for row in report["resultPairRows"]:
        lines.append(
            f"| {wlk_label(row['normalWlkNo'])} | {wlk_label(row['altWlkNo'])} | {row['mode']} | {row['count']} | {row['role']} | {'; '.join(row['samples'][:6])} |"
        )
    lines += ["", "## Unresolved", ""]
    for key, value in report["unresolved"].items():
        lines.append(f"- `{key}`: {value}")
    lines += ["", "## Resolved Presentation Sounds", ""]
    for key, value in report["resolvedPresentation"].items():
        lines.append(f"- `{key}`: {value}")
    return "\n".join(lines) + "\n"


def html_audio(wlk_no: int) -> str:
    return f"<audio preload=\"none\" controls src=\"{esc(wlk_url(wlk_no))}\"></audio>"


def html_page(report: dict[str, Any]) -> str:
    summary = "".join(
        f"<tr><td>{esc(key)}</td><td><code>{esc(value)}</code></td></tr>"
        for key, value in report["summary"].items()
    )
    wlk_rows = ""
    for row in report["wlkRoleRows"]:
        samples = "<br>".join(esc(sample) for sample in row["samples"][:8]) or "-"
        display_branches = "<br>".join(esc(sample) for sample in row["displayBranchSamples"][:6]) or "-"
        pairs = "<br>".join(f"{esc(pair)} x{esc(count)}" for pair, count in row["pairCounts"].items()) or "-"
        statuses = "<br>".join(f"{esc(status)} x{esc(count)}" for status, count in row["statusCounts"].items()) or "-"
        wlk_rows += (
            "<tr>"
            f"<td><strong>{esc(wlk_label(row['wlkNo']))}</strong><br>{html_audio(int(row['wlkNo']))}</td>"
            f"<td>{esc(row['effectCueCount'])}</td>"
            f"<td>{esc(row['resultNormalCount'])}</td>"
            f"<td>{esc(row['resultAltCount'])}</td>"
            f"<td>{esc(row['resultDisplayBranchCount'])}<br>{display_branches}</td>"
            f"<td>{esc(row['roleHint'])}</td>"
            f"<td>{pairs}</td>"
            f"<td>{statuses}</td>"
            f"<td>{samples}</td>"
            "</tr>"
        )
    pair_rows = "".join(
        "<tr>"
        f"<td>{esc(wlk_label(row['normalWlkNo']))}</td>"
        f"<td>{esc(wlk_label(row['altWlkNo']))}</td>"
        f"<td>{esc(row['mode'])}</td>"
        f"<td>{esc(row['count'])}</td>"
        f"<td>{esc(row['role'])}</td>"
        f"<td>{'<br>'.join(esc(sample) for sample in row['samples'][:10])}</td>"
        "</tr>"
        for row in report["resultPairRows"]
    )
    effect_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['effectArgsHex'])}</code></td>"
        f"<td>{esc(wlk_label(row['wlkNo']))}<br>{html_audio(int(row['wlkNo']))}</td>"
        f"<td>{esc(row['count'])}</td>"
        f"<td>{esc(row['roleHint'])}</td>"
        f"<td>{'<br>'.join(esc(sample) for sample in row['samples'][:8])}</td>"
        "</tr>"
        for row in report["effectArgRows"]
    )
    skill_rows = ""
    for row in report["skillRows"]:
        calls = []
        for call in row["calls"]:
            if call["opcode"] == "0x24":
                calls.append(f"t{call['tick']} 0x24 {wlk_label(call['wlkNo'])} {call.get('effectArgsHex') or ''}")
            else:
                calls.append(f"t{call['tick']} 0xc2 {normal_alt_label(call['normalWlkNo'], call['altWlkNo'], call['mode'])}")
        statuses = ", ".join(f"{status['statusHex']} {status['statusLabel']}" for status in row["statuses"]) or "0x00 none"
        skill_rows += (
            "<tr>"
            f"<td>{esc(row['ownerName'])}</td>"
            f"<td>{esc(row['skillName'])}<br><code>{esc(row['skillIdHex'])}</code></td>"
            f"<td>{esc(row['soundModel'])}</td>"
            f"<td>{esc(statuses)}</td>"
            f"<td>{'<br>'.join(esc(call) for call in calls) or '-'}</td>"
            f"<td>{esc(row['hasChildEffectCandidate'])}</td>"
            "</tr>"
        )
    notes = "".join(f"<li>{esc(note)}</li>" for note in report["interpretationNotes"])
    unresolved = "".join(f"<li><code>{esc(key)}</code>: {esc(value)}</li>" for key, value in report["unresolved"].items())
    resolved = "".join(f"<li><code>{esc(key)}</code>: {esc(value)}</li>" for key, value in report["resolvedPresentation"].items())
    return f"""<!doctype html>
<html lang=\"ko\">
<head>
  <meta charset=\"utf-8\">
  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
  <link rel=\"icon\" href=\"../favicon.ico\">
  <title>Battle Sound Role Review</title>
  <style>
    body {{ margin: 20px; font-family: system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif; background: #101114; color: #eef1f5; }}
    a {{ color: #9ecbff; }} code {{ color: #ffd37a; }}
    table {{ width: 100%; border-collapse: collapse; margin: 12px 0 24px; font-size: 12px; }}
    th, td {{ border: 1px solid #30343d; padding: 7px 8px; vertical-align: top; text-align: left; }}
    th {{ background: #1b1f27; color: #c7d0dc; position: sticky; top: 0; z-index: 2; }}
    tr:nth-child(even) td {{ background: #151922; }}
    .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; }}
    .panel {{ border: 1px solid #30343d; background: #151821; border-radius: 8px; padding: 12px; }}
    .wide {{ overflow: auto; max-height: 78vh; border: 1px solid #30343d; }}
    audio {{ width: 160px; height: 28px; margin-top: 4px; }}
    h1, h2 {{ margin-bottom: 10px; }}
  </style>
</head>
<body>
  <h1>Battle Sound Role Review</h1>
  <p><a href=\"../web/index.html\">홈</a> · <a href=\"../web/battle_simulator.html\">전투 기술 실행</a> · <a href=\"battle_sound_effect_review.html\">기존 사운드/이펙트 근거</a> · <a href=\"battle_sound_role_review.json\">JSON</a> · <a href=\"battle_sound_role_review.md\">MD</a></p>
  <div class=\"grid\">
    <section class=\"panel\"><h2>Summary</h2><table><tbody>{summary}</tbody></table></section>
    <section class=\"panel\"><h2>Interpretation</h2><ul>{notes}</ul></section>
    <section class=\"panel\"><h2>Resolved Presentation</h2><ul>{resolved}</ul></section>
    <section class=\"panel\"><h2>Unresolved</h2><ul>{unresolved}</ul></section>
  </div>
  <h2>WLK Role Rows</h2>
  <div class=\"wide\">
    <table>
      <thead><tr><th>WLK</th><th>0x24 effect</th><th>0xc2 normal</th><th>0xc2 alt</th><th>display branch</th><th>role hint</th><th>pairs</th><th>statuses</th><th>samples</th></tr></thead>
      <tbody>{wlk_rows}</tbody>
    </table>
  </div>
  <h2>0xc2 Result Pairs</h2>
  <div class=\"wide\"><table><thead><tr><th>normal</th><th>alt</th><th>mode</th><th>count</th><th>role</th><th>samples</th></tr></thead><tbody>{pair_rows}</tbody></table></div>
  <h2>0x24 Effect/Cast Patterns</h2>
  <div class=\"wide\"><table><thead><tr><th>args</th><th>WLK</th><th>count</th><th>role hint</th><th>samples</th></tr></thead><tbody>{effect_rows}</tbody></table></div>
  <h2>Skill Sound Calls</h2>
  <div class=\"wide\"><table><thead><tr><th>actor</th><th>skill</th><th>model</th><th>payload status</th><th>calls</th><th>child effect</th></tr></thead><tbody>{skill_rows}</tbody></table></div>
</body>
</html>
"""


def main() -> None:
    report = build()
    (OUT / "battle_sound_role_review.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    (OUT / "battle_sound_role_review.md").write_text(markdown(report), encoding="utf-8")
    (OUT / "battle_sound_role_review.html").write_text(html_page(report), encoding="utf-8")
    print(f"wrote {OUT / 'battle_sound_role_review.json'}")
    print(f"wrote {OUT / 'battle_sound_role_review.md'}")
    print(f"wrote {OUT / 'battle_sound_role_review.html'}")


if __name__ == "__main__":
    main()
