#!/usr/bin/env python3
"""Review battle action hit-window coverage and remaining sound-only gaps."""
from __future__ import annotations

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


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

KNOWN_CONTEXTS: dict[tuple[str, str], dict[str, str]] = {
    ("ataho", "0x21"): {"payloadKind": "weapon-granted-fire-sake-single", "context": "화주 장비 전용 개인기"},
    ("ataho", "0x38"): {"payloadKind": "weapon-granted-fire-sake-all", "context": "화주 장비 전용 전체기"},
    ("rinshan", "0x05"): {"payloadKind": "self-random-mode-buff", "context": "도발: 위압/피곤함/노발충천/여왕님/무뚝뚝한 표정 중 랜덤 모드"},
    ("rinshan", "0x06"): {"payloadKind": "weapon-granted-claw-basic-effect-only", "context": "꼬챙이 꿰기 장비 기본기 변형 1. 빙마조/팬톰크로우/호랑이발톱 중 하나이며 main result sound 대신 WLK id 19 effect 경로를 쓴다."},
    ("rinshan", "0x07"): {"payloadKind": "weapon-granted-claw-basic", "context": "꼬챙이 꿰기 장비 기본기 변형 2. 빙마조/팬톰크로우/호랑이발톱 중 하나."},
    ("rinshan", "0x08"): {"payloadKind": "weapon-granted-claw-basic", "context": "꼬챙이 꿰기 장비 기본기 변형 3. 빙마조/팬톰크로우/호랑이발톱 중 하나."},
    ("rinshan", "0x1a"): {"payloadKind": "weapon-granted-phantom-claw-single", "context": "팬톰크로우 장비 전용 개인기"},
    ("rinshan", "0x1b"): {"payloadKind": "weapon-granted-tiger-claw-single", "context": "호랑이발톱 장비 전용 개인기"},
    ("rinshan", "0x2d"): {"payloadKind": "weapon-granted-phantom-claw-all", "context": "팬톰크로우 장비 전용 전체기"},
    ("rinshan", "0x31"): {"payloadKind": "recovery-single", "context": "기공회복: 회복계, 적 타격 없음"},
    ("rinshan", "0x32"): {"payloadKind": "recovery-status", "context": "기공독치료: 상태 회복계, 적 타격 없음"},
    ("rinshan", "0x33"): {"payloadKind": "recovery-all", "context": "기공대회복: 전체 회복계, 적 타격 없음"},
    ("rinshan", "0x34"): {"payloadKind": "self-element-guard", "context": "수경: 속성 방어 버프, 적 타격 없음"},
    ("smashu", "0x09"): {"payloadKind": "fast-piercing-single", "context": "쾌진격 숙련도 1: 빠르게 적에게 날아가는 1타성 공격"},
    ("smashu", "0x0a"): {"payloadKind": "fast-piercing-single", "context": "쾌진격 숙련도 2: 빠르게 적에게 날아가는 1타성 공격"},
    ("smashu", "0x0b"): {"payloadKind": "fast-piercing-single", "context": "쾌진격 숙련도 3: 빠르게 적에게 날아가는 1타성 공격"},
    ("smashu", "0x0c"): {"payloadKind": "fast-piercing-single", "context": "쾌진격 숙련도 4: 빠르게 적에게 날아가는 1타성 공격"},
    ("smashu", "0x12"): {"payloadKind": "weapon-granted-rakshasa-blade-single", "context": "나찰의 흉인 장비 전용 개인기. 쾌진격 유사 돌진 후 추가타"},
    ("smashu", "0x14"): {"payloadKind": "slash-effect-all", "context": "백인일섬 숙련도 1: 베는 모션 뒤 칼자국 이펙트로 타격"},
    ("smashu", "0x15"): {"payloadKind": "slash-effect-all", "context": "백인일섬 숙련도 2: 베는 모션 뒤 칼자국 이펙트로 타격"},
    ("smashu", "0x16"): {"payloadKind": "slash-effect-all", "context": "백인일섬 숙련도 3: 베는 모션 뒤 칼자국 이펙트로 타격"},
    ("smashu", "0x17"): {"payloadKind": "slash-effect-all", "context": "백인일섬 숙련도 4: 베는 모션 뒤 칼자국 이펙트로 타격"},
    ("smashu", "0x18"): {"payloadKind": "clone-multi-hit-all", "context": "인법·분신술 숙련도 1: 분신 수/타격 수 변화"},
    ("smashu", "0x19"): {"payloadKind": "clone-multi-hit-all", "context": "인법·분신술 숙련도 2: 분신 수/타격 수 변화"},
    ("smashu", "0x1a"): {"payloadKind": "clone-multi-hit-all", "context": "인법·분신술 숙련도 3: 분신 수/타격 수 변화"},
    ("smashu", "0x1b"): {"payloadKind": "clone-multi-hit-all", "context": "인법·분신술 숙련도 4: 분신 수/타격 수 변화"},
    ("smashu", "0x22"): {"payloadKind": "weapon-granted-asura-dance-all", "context": "마인아수라 장비 전용 전체기"},
}


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


def wlk_labels(values: list[Any]) -> str:
    return ", ".join(f"WLK id {int(value):02d}" for value in values) if values else "-"


def key(row: dict[str, Any]) -> tuple[str, str]:
    return str(row.get("ownerKey")), str(row.get("skillIdHex")).lower()


def payload_kind(mapped: dict[str, Any]) -> str:
    known = KNOWN_CONTEXTS.get(key(mapped))
    if known:
        return known["payloadKind"]
    name = str(mapped.get("name") or "")
    scopes = set(mapped.get("targetScopes") or [])
    families = set(mapped.get("families") or [])
    effect_count = int(mapped.get("effectCount") or 0)
    mp_cost = int(mapped.get("mpCost") or 0)
    if name in {"도주", "방어"}:
        return "mode-command"
    if scopes == {1}:
        return "self/special"
    if name == "비기·맹호유성각":
        return "child/full-screen-effect"
    if scopes == {10} and mp_cost == 0:
        return "basic"
    if scopes == {10}:
        return "single-target"
    if scopes == {6}:
        return "all-target"
    if effect_count > 1 or len(scopes) > 1 or len(families) > 1:
        return "composite"
    return "unknown-payload"


def classify_row(timeline_row: dict[str, Any], mapped: dict[str, Any]) -> str:
    hit_counts = timeline_row.get("hitClassCounts") or {}
    if hit_counts.get("confirmed-result-hit-window"):
        return "confirmed-main-actor-hit-window"
    if hit_counts.get("result-sound-only"):
        return "result-sound-without-main-actor-hit-flag"
    kind = payload_kind(mapped)
    if kind in {"self/special", "mode-command"} or kind.startswith("self-") or kind.startswith("recovery-"):
        return "no-hit-expected-self-or-mode"
    if kind == "weapon-granted-claw-basic-effect-only":
        return "no-result-sound-weapon-basic-effect-path"
    return "no-main-actor-hit-event"


def build() -> dict[str, Any]:
    mapping = json.loads(MAPPING_JSON.read_text(encoding="utf-8"))
    timeline = json.loads(TIMELINE_JSON.read_text(encoding="utf-8"))
    mapped_by_key = {key(row): row for row in mapping.get("playerRows") or []}
    rows: list[dict[str, Any]] = []
    hit_event_rows: list[dict[str, Any]] = []

    for row in timeline.get("rows") or []:
        mapped = mapped_by_key.get(key(row), {})
        hit_events = ((row.get("timeline") or {}).get("hitEvents")) or []
        confirmed = [hit for hit in hit_events if hit.get("classification") == "confirmed-result-hit-window"]
        sound_only = [hit for hit in hit_events if hit.get("classification") == "result-sound-only"]
        row_class = classify_row(row, mapped)
        out_row = {
            "ownerKey": row.get("ownerKey"),
            "ownerName": row.get("ownerName"),
            "skillIdHex": row.get("skillIdHex"),
            "skillName": row.get("skillName"),
            "familyName": row.get("familyName"),
            "payloadKind": payload_kind(mapped),
            "rowClass": row_class,
            "hitEventCount": len(hit_events),
            "confirmedHitEventCount": len(confirmed),
            "soundOnlyHitEventCount": len(sound_only),
            "frameSequence": row.get("frameSequence") or [],
            "resultWlkNos": row.get("soundWlkNos") or [],
            "effectWlkNos": row.get("effectWlkNos") or [],
            "targetScopes": mapped.get("targetScopes") or [],
            "families": mapped.get("families") or [],
            "statuses": mapped.get("statuses") or [],
            "note": "",
            "contextNote": KNOWN_CONTEXTS.get(key(row), {}).get("context", ""),
        }
        if row_class == "result-sound-without-main-actor-hit-flag":
            out_row["note"] = "0xc2 result sound is present, but the immediate main actor AD flag set/clear hit window pattern is not present. This may be child/helper-driven, a non-damage result sound, or a different flag path."
        elif row_class == "no-hit-expected-self-or-mode":
            out_row["note"] = "Self/mode command; no enemy hit window is expected at the main actor event level."
        rows.append(out_row)
        for hit in hit_events:
            hit_event_rows.append({
                "ownerKey": row.get("ownerKey"),
                "ownerName": row.get("ownerName"),
                "skillIdHex": row.get("skillIdHex"),
                "skillName": row.get("skillName"),
                "classification": hit.get("classification"),
                "tick": hit.get("tick"),
                "normalWlkNo": hit.get("normalWlkNo"),
                "altWlkNo": hit.get("altWlkNo"),
                "soundVaHex": hit.get("soundVaHex"),
                "flags": hit.get("flags") or [],
                "waitAfter": hit.get("waitAfter") or {},
            })

    row_class_counts = Counter(row["rowClass"] for row in rows)
    payload_kind_counts = Counter(row["payloadKind"] for row in rows)
    hit_class_counts = Counter(row["classification"] for row in hit_event_rows)
    return {
        "version": 1,
        "kind": "hwanse-battle-hit-window-gap-review",
        "source": ["out/battle_action_mapping.json", "out/battle_action_event_timeline_review.json"],
        "status": "main-actor-hit-window-coverage-and-sound-only-gaps",
        "summary": {
            "rows": len(rows),
            "hitEvents": len(hit_event_rows),
            "confirmedHitEvents": hit_class_counts.get("confirmed-result-hit-window", 0),
            "resultSoundOnlyEvents": hit_class_counts.get("result-sound-only", 0),
            "rowsWithConfirmedHitWindow": sum(1 for row in rows if row["confirmedHitEventCount"]),
            "rowsWithoutConfirmedHitWindow": sum(1 for row in rows if not row["confirmedHitEventCount"]),
            "rowClassCounts": dict(row_class_counts),
            "payloadKindCounts": dict(payload_kind_counts),
            "hitClassCounts": dict(hit_class_counts),
        },
        "interpretationNotes": [
            "The older 80/130-style count referred to main actor hit-window coverage before fallback rows were promoted and before delayed flag scanning.",
            "Current delayed scanning follows the resolved actor VM rows until the next result sound, so helper/wait/frame-separated skills such as 쾌진격, 백인일섬, 분신술, 염가열소, 취호염무, 절사어면 are now classified as confirmed.",
            "A confirmed hit window means a 0xc2 result sound is followed later in the same resolved main actor VM slice by AD flag set mask 0x0000000c and clear mask 0x00000008.",
            "There are currently no result-sound-only events in the player action set. Remaining rows without confirmed hit windows are no-result-sound self/mode/recovery rows plus 린샹 꼬챙이 꿰기 0x06, a weapon-basic variant that uses WLK id 19 effect instead of a main result sound.",
            "린샹 꼬챙이 꿰기 0x06/0x07/0x08 are one equipment-granted basic-action family, analogous to 스마슈 마구베기 variants for 불타는 마검/나찰의 흉인/마인아수라. The 0x06 variant is represented as an effect-only path, not as a missing skill.",
            "Self/mode commands such as drink, guard, run, recovery, hide, and buff are separated so they do not look like missing enemy hit windows.",
        ],
        "rows": rows,
        "hitEvents": hit_event_rows,
    }


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Hit Window Gap Review",
        "",
        f"- status: `{report['status']}`",
        f"- rows: {report['summary']['rows']}",
        f"- hit events: {report['summary']['hitEvents']}",
        f"- confirmed hit events: {report['summary']['confirmedHitEvents']}",
        f"- result-sound-only events: {report['summary']['resultSoundOnlyEvents']}",
        "",
        "## Row Classes",
        "",
    ]
    for name, count in sorted(report["summary"]["rowClassCounts"].items()):
        lines.append(f"- `{name}`: {count}")
    lines += [
        "",
        "## Rows Without Confirmed Hit Window",
        "",
        "| actor | skill | id | row class | payload | hit | WLK | frames | context | note |",
        "| --- | --- | ---: | --- | --- | --- | --- | --- | --- | --- |",
    ]
    for row in report["rows"]:
        if row["confirmedHitEventCount"]:
            continue
        payload = f"scope={row['targetScopes']} family={row['families']} status={row['statuses']}"
        wlks = wlk_labels(row["resultWlkNos"])
        frames = ", ".join(str(value) for value in row["frameSequence"]) or "-"
        lines.append(f"| {row['ownerName']} | {row['skillName']} | `{row['skillIdHex']}` | `{row['rowClass']}` | {payload} | sound-only={row['soundOnlyHitEventCount']} | {wlks} | {frames} | {row['contextNote']} | {row['note']} |")
    lines += ["", "## Notes", ""]
    lines.extend(f"- {note}" for note in report["interpretationNotes"])
    lines.append("")
    return "\n".join(lines)


def html_page(report: dict[str, Any]) -> str:
    summary = report["summary"]
    class_rows = "".join(
        f"<tr><td><code>{esc(name)}</code></td><td>{esc(count)}</td></tr>"
        for name, count in sorted(summary["rowClassCounts"].items())
    )
    gap_rows = "".join(
        "<tr>"
        f"<td>{esc(row['ownerName'])}</td>"
        f"<td>{esc(row['skillName'])}</td>"
        f"<td><code>{esc(row['skillIdHex'])}</code></td>"
        f"<td><code>{esc(row['rowClass'])}</code></td>"
        f"<td><code>{esc(row['payloadKind'])}</code></td>"
        f"<td>{esc(row['soundOnlyHitEventCount'])}</td>"
        f"<td>{esc(wlk_labels(row['resultWlkNos']))}</td>"
        f"<td>{esc(', '.join(str(value) for value in row['frameSequence']) or '-')}</td>"
        f"<td>{esc(row['contextNote'])}</td>"
        f"<td>{esc(row['note'])}</td>"
        "</tr>"
        for row in report["rows"]
        if not row["confirmedHitEventCount"]
    )
    event_rows = "".join(
        "<tr>"
        f"<td>{esc(row['ownerName'])}</td>"
        f"<td>{esc(row['skillName'])}</td>"
        f"<td><code>{esc(row['skillIdHex'])}</code></td>"
        f"<td><code>{esc(row['classification'])}</code></td>"
        f"<td>{esc(row['tick'])}</td>"
        f"<td>{esc(row['normalWlkNo'])}/{esc(row['altWlkNo'])}</td>"
        f"<td><code>{esc(row['soundVaHex'])}</code></td>"
        "</tr>"
        for row in report["hitEvents"]
        if row["classification"] == "result-sound-only"
    )
    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 Hit Window Gap 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; }}
    .summary {{ display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }}
    .pill {{ border: 1px solid #394150; border-radius: 999px; padding: 5px 10px; background: #171a21; }}
  </style>
</head>
<body>
  <h1>Battle Hit Window Gap Review</h1>
  <p><a href="../web/index.html">홈</a> · <a href="../web/battle_simulator.html">전투 기술 실행</a> · <a href="battle_action_event_timeline_review.html">actor hit timeline</a> · <a href="battle_hit_window_gap_review.json">JSON</a></p>
  <div class="summary">
    <span class="pill">rows {esc(summary['rows'])}</span>
    <span class="pill">hit events {esc(summary['hitEvents'])}</span>
    <span class="pill">confirmed {esc(summary['confirmedHitEvents'])}</span>
    <span class="pill">sound-only {esc(summary['resultSoundOnlyEvents'])}</span>
    <span class="pill">rows without confirmed {esc(summary['rowsWithoutConfirmedHitWindow'])}</span>
  </div>
  <h2>Row Classes</h2>
  <table><thead><tr><th>class</th><th>count</th></tr></thead><tbody>{class_rows}</tbody></table>
  <h2>Rows Without Confirmed Hit Window</h2>
  <table><thead><tr><th>actor</th><th>skill</th><th>id</th><th>row class</th><th>payload kind</th><th>sound-only hits</th><th>WLK</th><th>frames</th><th>context</th><th>note</th></tr></thead><tbody>{gap_rows}</tbody></table>
  <h2>Result-Sound-Only Events</h2>
  <table><thead><tr><th>actor</th><th>skill</th><th>id</th><th>classification</th><th>tick</th><th>normal/alt WLK</th><th>sound VA</th></tr></thead><tbody>{event_rows}</tbody></table>
  <h2>Notes</h2>
  <ul>{notes}</ul>
</body>
</html>
"""


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


if __name__ == "__main__":
    main()
