#!/usr/bin/env python3
"""Correlate battle display VM sound/effect calls with payload hit records.

This report is deliberately conservative.  It separates three things that were
previously easy to mix up while listening:

* opcode 0x24 `01 00 xx`: probable cast/effect WLK cue
* opcode 0xc2: raw VM sound with normal/alt WLK operands
* opcode 0xad mode=2 mask=0x0c: hit-result window used to distribute payload
  effect units in the browser preview
"""
from __future__ import annotations

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


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
DISPLAY_JSON = OUT / "battle_display_vm_static_decode.json"
MAPPING_JSON = OUT / "battle_action_mapping.json"


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


def hex_byte(value: int | None) -> str:
    if value is None:
        return ""
    return f"0x{value & 0xff:02x}"


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


def parse_unit(unit_hex: str) -> dict[str, Any]:
    values = []
    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": hex_byte(values[4]),
        "familyHex": hex_byte(values[5]),
        "statusHex": hex_byte(values[7]),
    }


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 []
    }


def compact_sound(sound: dict[str, Any]) -> dict[str, Any]:
    return {
        "vaHex": sound.get("vaHex") or "",
        "chosenWlkNo": sound.get("wlkNo"),
        "normalWlkNo": sound.get("normalWlkNo") or sound.get("wlkNo"),
        "altWlkNo": sound.get("altWlkNo") or sound.get("wlkNo"),
        "mode": sound.get("mode"),
        "summary": sound.get("summary") or "",
    }


def collect_hit_windows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    windows = []
    pending: dict[str, Any] | None = None
    for row in rows:
        if row.get("category") == "actor-flags":
            mode = row.get("mode")
            mask = row.get("mask")
            if mode == 2 and mask == 0x0C:
                pending = {"startVaHex": row.get("vaHex"), "endVaHex": ""}
            elif pending and mode == 1 and mask == 0x08:
                pending["clearVaHex"] = row.get("vaHex")
        elif pending and row.get("category") == "wait":
            pending["endVaHex"] = row.get("vaHex")
            windows.append(pending)
            pending = None
    if pending:
        windows.append(pending)
    return windows


def sound_role_summary(row: dict[str, Any]) -> dict[str, Any]:
    effect_sounds = row.get("effectSounds") or []
    raw_sounds = [compact_sound(sound) for sound in (row.get("sounds") or [])]
    alt_candidates = sorted({
        sound.get("altWlkNo")
        for sound in raw_sounds
        if sound.get("altWlkNo") and sound.get("altWlkNo") != sound.get("normalWlkNo")
    })
    normal_candidates = sorted({
        sound.get("normalWlkNo")
        for sound in raw_sounds
        if sound.get("normalWlkNo")
    })
    return {
        "effectWlkNos": [sound.get("wlkNo") for sound in effect_sounds],
        "rawNormalWlkNos": normal_candidates,
        "rawAltWlkNos": alt_candidates,
        "interpretation": (
            "0x24 cast/effect WLK plus 0xc2 normal/alt result WLK"
            if effect_sounds and raw_sounds
            else "0x24 cast/effect WLK only"
            if effect_sounds
            else "0xc2 raw VM sound only"
            if raw_sounds
            else "no WLK call in promoted static path"
        ),
    }


def child_effect_evidence(rows: list[dict[str, Any]]) -> dict[str, Any]:
    child = [row for row in rows if row.get("category") == "child-write"]
    helpers = [row for row in rows if row.get("category") == "spawn-child-vm"]
    writes = [row for row in rows if row.get("category") == "write"]
    child_targets = sorted({
        row.get("childTargetVaHex")
        for row in child
        if row.get("childTargetVaHex")
    })
    interesting_write_offsets = sorted({
        row.get("destHex")
        for row in writes
        if row.get("destHex") in {"0x28", "0x80", "0x84", "0x8c", "0x8e", "0x90", "0x92", "0x96", "0xa8"}
    })
    return {
        "childWriteCount": len(child),
        "childTargets": child_targets,
        "spawnChildVmCount": len(helpers),
        "interestingWriteOffsets": interesting_write_offsets,
        "hasChildEffectCandidate": bool(child or helpers or interesting_write_offsets),
        "helperArgs": [row.get("effectArgsHex") or row.get("bytes") for row in helpers],
    }


def build() -> dict[str, Any]:
    display = json.loads(DISPLAY_JSON.read_text(encoding="utf-8"))
    mapping = json.loads(MAPPING_JSON.read_text(encoding="utf-8"))
    by_record = mapping_index(mapping)
    rows = []
    effect_pattern_counter: Counter[str] = Counter()
    raw_mode_counter: Counter[str] = Counter()
    raw_alt_counter: Counter[str] = Counter()
    by_effect_wlk: defaultdict[int, list[str]] = defaultdict(list)
    for decoded in display.get("decodedRows") or []:
        key = (str(decoded.get("ownerKey")), str(decoded.get("skillIdHex")).lower())
        mapped = by_record.get(key) or {}
        units = [parse_unit(unit) for unit in (mapped.get("unitsHex") or [])]
        role = sound_role_summary(decoded)
        child = child_effect_evidence(decoded.get("rows") or [])
        hit_windows = collect_hit_windows(decoded.get("rows") or [])
        for sound in decoded.get("effectSounds") or []:
            effect_pattern_counter[str(sound.get("effectArgsHex") or "")] += 1
            if sound.get("wlkNo") is not None:
                by_effect_wlk[int(sound["wlkNo"])].append(
                    f"{decoded.get('ownerName')} {decoded.get('skillName')} {decoded.get('skillIdHex')}"
                )
        for sound in decoded.get("sounds") or []:
            raw_mode_counter[str(sound.get("mode"))] += 1
            if sound.get("altWlkNo") and sound.get("altWlkNo") != sound.get("normalWlkNo"):
                raw_alt_counter[f"normal id {int(sound.get('normalWlkNo')):02d} / alt id {int(sound.get('altWlkNo')):02d}"] += 1
        rows.append({
            "ownerKey": decoded.get("ownerKey"),
            "ownerName": decoded.get("ownerName"),
            "skillName": decoded.get("skillName"),
            "skillIdHex": decoded.get("skillIdHex"),
            "phaseHex": decoded.get("phaseHex"),
            "payloadVaHex": decoded.get("payloadVaHex"),
            "mpCost": decoded.get("mpCost"),
            "effectCount": decoded.get("effectCount"),
            "frameSequence": decoded.get("frameSequence") or [],
            "effectSounds": decoded.get("effectSounds") or [],
            "rawSounds": [compact_sound(sound) for sound in (decoded.get("sounds") or [])],
            "hitWindows": hit_windows,
            "hitWindowCount": len(hit_windows),
            "payloadUnits": units,
            "targetScopes": sorted({unit["targetScopeHex"] for unit in units}),
            "families": sorted({unit["familyHex"] for unit in units}),
            "statuses": sorted({unit["statusHex"] for unit in units}),
            **role,
            **child,
            "confidence": decoded.get("confidence"),
            "entryStartVaHex": decoded.get("entryStartVaHex"),
        })
    return {
        "version": 1,
        "kind": "hwanse-battle-sound-effect-review",
        "source": ["out/battle_display_vm_static_decode.json", "out/battle_action_mapping.json"],
        "status": "separates-0x24-effect-sound-from-0xc2-raw-result-sound",
        "summary": {
            "decodedSkillRows": len(rows),
            "rowsWithEffectSoundOpcode24": sum(1 for row in rows if row["effectSounds"]),
            "rowsWithRawSoundOpcodeC2": sum(1 for row in rows if row["rawSounds"]),
            "rowsWithAltResultSoundRows": sum(1 for row in rows if row["rawAltWlkNos"]),
            "rowsWithChildEffectCandidate": sum(1 for row in rows if row["hasChildEffectCandidate"]),
            "effectOpcode24Patterns": dict(effect_pattern_counter.most_common()),
            "rawSoundModeCounts": dict(raw_mode_counter.most_common()),
            "rawNormalAltPairs": dict(raw_alt_counter.most_common()),
            "effectWlkUsageSamples": {
                f"WLK id {wlk_no:02d}": samples[:10]
                for wlk_no, samples in sorted(by_effect_wlk.items())
            },
        },
        "interpretationNotes": [
            "0x24 with bytes 01 00 xx is now treated as a probable cast/effect WLK cue. User observation for 맹호의 울부짖음 supports this.",
            "0xc2 keeps separate normal/alt operands. The common normal WLK id 03/04/05 and alt WLK id 14 pattern is the raw source used by the promoted critical/alternate result sound role in battle_sound_role_review.",
            "Miss, knockback, and falling are not fully proven here. This report only exposes operands that feed the shared result layer; critical WLK id 14 is promoted in the dedicated sound-role report, while exact runtime timing remains open.",
            "Projectile/effect sprites are still unresolved. child-write, 0x07 child display spawns, and certain display field writes are listed as effect-object candidates rather than rendered as confirmed projectiles. 0x4b is display-list cleanup, not a projectile resource.",
        ],
        "rows": rows,
    }


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Sound / Effect Review",
        "",
        f"- status: `{report['status']}`",
        f"- decoded skill rows: `{report['summary']['decodedSkillRows']}`",
        f"- 0x24 effect sound rows: `{report['summary']['rowsWithEffectSoundOpcode24']}`",
        f"- 0xc2 raw sound rows: `{report['summary']['rowsWithRawSoundOpcodeC2']}`",
        f"- raw alt result rows: `{report['summary']['rowsWithAltResultSoundRows']}`",
        "",
        "## Interpretation",
        "",
    ]
    lines.extend(f"- {note}" for note in report["interpretationNotes"])
    lines.extend([
        "",
        "## Rows",
        "",
        "| actor | skill | id | hits | 0x24 effect WLK | 0xc2 normal | 0xc2 alt | hit windows | effect-object candidate |",
        "| --- | --- | ---: | ---: | --- | --- | --- | ---: | --- |",
    ])
    for row in report["rows"]:
        lines.append(
            f"| {row['ownerName']} | {row['skillName']} | `{row['skillIdHex']}` | {row['effectCount']} | "
            f"{', '.join(wlk_label(value) for value in row['effectWlkNos']) or '-'} | "
            f"{', '.join(wlk_label(value) for value in row['rawNormalWlkNos']) or '-'} | "
            f"{', '.join(wlk_label(value) for value in row['rawAltWlkNos']) or '-'} | "
            f"{row['hitWindowCount']} | `{row['hasChildEffectCandidate']}` |"
        )
    return "\n".join(lines) + "\n"


def html_page(report: dict[str, Any]) -> str:
    summary_rows = "".join(
        f"<tr><td>{esc(key)}</td><td><code>{esc(value)}</code></td></tr>"
        for key, value in report["summary"].items()
        if not isinstance(value, dict)
    )
    pattern_rows = "".join(
        f"<tr><td><code>{esc(key)}</code></td><td>{esc(value)}</td></tr>"
        for key, value in (report["summary"].get("effectOpcode24Patterns") or {}).items()
    )
    alt_rows = "".join(
        f"<tr><td>{esc(key)}</td><td>{esc(value)}</td></tr>"
        for key, value in (report["summary"].get("rawNormalAltPairs") or {}).items()
    )
    rows_html = []
    for row in report["rows"]:
        effect = ", ".join(wlk_label(value) for value in row["effectWlkNos"]) or "-"
        normal = ", ".join(wlk_label(value) for value in row["rawNormalWlkNos"]) or "-"
        alt = ", ".join(wlk_label(value) for value in row["rawAltWlkNos"]) or "-"
        units = "<br>".join(
            f"<code>{esc(unit['bytesHex'])}</code> scope {esc(unit['targetScopeHex'])} family {esc(unit['familyHex'])} status {esc(unit['statusHex'])}"
            for unit in row["payloadUnits"]
        ) or "-"
        sound_detail = "<br>".join(
            f"<code>{esc(sound['vaHex'])}</code> normal WLK id {int(sound['normalWlkNo']):02d} / alt WLK id {int(sound['altWlkNo']):02d} / mode {esc(sound['mode'])}"
            for sound in row["rawSounds"]
        ) or "-"
        effect_detail = "<br>".join(
            f"<code>{esc(sound.get('vaHex'))}</code> WLK id {int(sound.get('wlkNo')):02d} args <code>{esc(sound.get('effectArgsHex'))}</code>"
            for sound in row["effectSounds"]
        ) or "-"
        rows_html.append(
            "<tr>"
            f"<td>{esc(row['ownerName'])}</td>"
            f"<td>{esc(row['skillName'])}<br><code>{esc(row['skillIdHex'])}</code> phase <code>{esc(row['phaseHex'])}</code></td>"
            f"<td>{esc(row['effectCount'])}</td>"
            f"<td>{esc(', '.join(str(frame) for frame in row['frameSequence']))}</td>"
            f"<td>{esc(effect)}</td>"
            f"<td>{esc(normal)}</td>"
            f"<td>{esc(alt)}</td>"
            f"<td>{esc(row['hitWindowCount'])}</td>"
            f"<td>{esc(row['interpretation'])}</td>"
            f"<td>{esc(row['hasChildEffectCandidate'])}<br>{esc(', '.join(row['interestingWriteOffsets']) or '-')}<br>{esc(', '.join(row['helperArgs']) or '-')}</td>"
            f"<td>{effect_detail}<hr>{sound_detail}<hr>{units}</td>"
            "</tr>"
        )
    notes = "".join(f"<li>{esc(note)}</li>" for note in report["interpretationNotes"])
    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 / Effect Review</title>
  <style>
    body {{ margin: 20px; background: #101114; color: #f1f3f5; font-family: system-ui, sans-serif; }}
    a {{ color: #9ecbff; }} code {{ color: #ffd37a; }}
    table {{ width: 100%; border-collapse: collapse; margin: 14px 0 24px; }}
    th, td {{ border: 1px solid #30343d; padding: 6px 8px; font-size: 12px; vertical-align: top; }}
    th {{ background: #1a1d24; color: #bac2cf; position: sticky; top: 0; z-index: 2; }}
    tr:nth-child(even) td {{ background: #141820; }}
    .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; }}
    .panel {{ border: 1px solid #30343d; border-radius: 8px; padding: 12px; background: #151821; }}
    .wide {{ overflow: auto; max-height: 78vh; border: 1px solid #30343d; }}
    hr {{ border: 0; border-top: 1px solid #30343d; margin: 6px 0; }}
  </style>
</head>
<body>
  <h1>Battle Sound / Effect Review</h1>
  <p><a href="../web/index.html">홈</a> · <a href="../web/battle_simulator.html">전투 기술 실행</a> · <a href="battle_display_vm_static_decode.html">VM 정적 디코드</a> · <a href="battle_effect_object_review.html">이펙트 객체</a> · <a href="battle_sound_effect_review.json">JSON</a> · <a href="battle_sound_effect_review.md">MD</a></p>
  <div class="grid">
    <section class="panel">
      <h2>Summary</h2>
      <table><tbody>{summary_rows}</tbody></table>
    </section>
    <section class="panel">
      <h2>0x24 Patterns</h2>
      <table><thead><tr><th>args</th><th>count</th></tr></thead><tbody>{pattern_rows}</tbody></table>
    </section>
    <section class="panel">
      <h2>0xc2 normal/alt pairs</h2>
      <table><thead><tr><th>pair</th><th>count</th></tr></thead><tbody>{alt_rows}</tbody></table>
    </section>
  </div>
  <h2>Interpretation</h2>
  <ul>{notes}</ul>
  <h2>Skill Rows</h2>
  <div class="wide">
    <table>
      <thead><tr><th>actor</th><th>skill</th><th>payload hits</th><th>frames</th><th>0x24 effect WLK</th><th>0xc2 normal</th><th>0xc2 alt</th><th>windows</th><th>sound model</th><th>effect object evidence</th><th>details</th></tr></thead>
      <tbody>{''.join(rows_html)}</tbody>
    </table>
  </div>
</body>
</html>
"""


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


if __name__ == "__main__":
    main()
