#!/usr/bin/env python3
"""Build main actor battle VM event timelines.

The helper reports expose spawned display objects.  This report stays on the
main actor action VM and extracts the order of frames, result sounds, helper
calls, movement, and actor flag hit windows.  It does not turn gate units into
milliseconds and it does not infer damage formulas.
"""
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"
DISPLAY_JSON = OUT / "battle_display_vm_static_decode.json"
HELPER_OPCODE_JSON = OUT / "battle_helper_opcode_review.json"


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


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


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


def helper_id(instr: dict[str, Any]) -> int | None:
    raw = str(instr.get("bytes") or "")
    parts = raw.split()
    if len(parts) >= 3 and parts[0].lower() == "bd":
        try:
            return int(parts[1], 16) | (int(parts[2], 16) << 8)
        except ValueError:
            return None
    match = re.search(r"id=(\d+)", str(instr.get("summary") or ""))
    return int(match.group(1)) if match else None


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


def build_helper_index() -> dict[tuple[str, str], dict[str, Any]]:
    if not HELPER_OPCODE_JSON.exists():
        return {}
    report = json.loads(HELPER_OPCODE_JSON.read_text(encoding="utf-8"))
    return {key_for(row): row for row in report.get("rows") or []}


def lookahead_flags(rows: list[dict[str, Any]], index: int, limit: int = 20) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
    flags = []
    wait_row = None
    for row in rows[index + 1 : index + 1 + limit]:
        category = row.get("category")
        if category == "sound":
            break
        if category == "actor-flags":
            flags.append(
                {
                    "vaHex": row.get("vaHex"),
                    "mode": row.get("mode"),
                    "maskHex": row.get("maskHex"),
                    "summary": row.get("summary"),
                }
            )
            continue
        if category == "wait" or row.get("opcode") in {"0xbf", "0xc1"}:
            if wait_row is None:
                wait_row = {
                    "vaHex": row.get("vaHex"),
                    "opcode": row.get("opcode"),
                    "summary": row.get("summary"),
                }
            continue
    return flags, wait_row


def classify_hit_event(flags: list[dict[str, Any]]) -> str:
    has_set = any(flag.get("mode") == 2 and flag.get("maskHex") == "0x0000000c" for flag in flags)
    has_clear = any(flag.get("mode") == 1 and flag.get("maskHex") == "0x00000008" for flag in flags)
    if has_set and has_clear:
        return "confirmed-result-hit-window"
    if has_set:
        return "result-flag-set-only"
    return "result-sound-only"


def expand_rows_with_repeat_loops(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Expand finite 0x06 repeat-loop bodies for actor timing review.

    The static decoder leaves repeat-loop rows compact.  That is useful for raw
    bytecode review, but the actor timeline and browser runner need the repeated
    frames/sounds/flags as scheduled events.  The finite repeat count is the
    total number of segment executions; the body already seen before the loop row
    is the first pass, so expansion appends count-1 additional passes.  This
    matches the EXE action payload for 지옥 다리후리기: one setup unit followed by
    five attack units, not six hit executions.
    """
    source_rows = list(rows or [])
    expanded: list[dict[str, Any]] = []
    row_index_by_va: dict[str, int] = {}
    for index, row in enumerate(source_rows):
        va_hex = row.get("vaHex")
        if va_hex and va_hex not in row_index_by_va:
            row_index_by_va[str(va_hex)] = index

    for index, row in enumerate(source_rows):
        expanded.append(row)
        if row.get("category") != "repeat-loop":
            continue
        target_index = row_index_by_va.get(str(row.get("targetVaHex") or ""))
        repeat_total_count = max(0, min(80, int(row.get("repeatCount") or 0)))
        extra_repeat_count = max(0, repeat_total_count - 1)
        if target_index is None or target_index < 0 or target_index >= index or extra_repeat_count <= 0:
            continue
        segment = source_rows[target_index:index]
        for repeat_iteration in range(1, extra_repeat_count + 1):
            for segment_row in segment:
                if segment_row.get("category") == "repeat-loop":
                    continue
                expanded.append(
                    {
                        **segment_row,
                        "repeatIteration": repeat_iteration,
                        "repeatTotalCount": repeat_total_count,
                        "repeatSourceVaHex": row.get("vaHex"),
                    }
                )
    return expanded


def compact_event(row: dict[str, Any], tick: int, kind: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
    event = {
        "tick": tick,
        "kind": kind,
        "vaHex": row.get("vaHex"),
        "opcode": row.get("opcode"),
        "summary": row.get("summary"),
    }
    if extra:
        event.update(extra)
    return {key: value for key, value in event.items() if value not in (None, "", [])}


def build_timeline(row: dict[str, Any], helper_info: dict[str, Any] | None = None) -> dict[str, Any]:
    tick = 0
    events: list[dict[str, Any]] = []
    hit_events: list[dict[str, Any]] = []
    frame_events: list[dict[str, Any]] = []
    helper_calls: list[dict[str, Any]] = []
    effect_sounds: list[dict[str, Any]] = []
    result_sounds: list[dict[str, Any]] = []
    movement_events: list[dict[str, Any]] = []
    repeat_events: list[dict[str, Any]] = []

    rows = expand_rows_with_repeat_loops(row.get("rows") or [])
    for index, instr in enumerate(rows):
        category = instr.get("category")
        repeat = instr.get("repeatIteration")
        repeat_extra = {"repeatIteration": repeat, "repeatSourceVaHex": instr.get("repeatSourceVaHex")} if repeat else {}
        if category == "frame":
            gate = int(instr.get("gate") or 0)
            event = compact_event(
                instr,
                tick,
                "actor-frame",
                {"spriteHex": instr.get("spriteHex"), "frame": instr.get("frame"), "gate": gate, **repeat_extra},
            )
            events.append(event)
            frame_events.append(event)
            tick += gate
            continue
        if instr.get("directFrameSelector"):
            event = compact_event(
                instr,
                tick,
                "direct-frame-selector",
                {"spriteHex": instr.get("spriteHex"), "frame": instr.get("frame"), "destHex": instr.get("destHex"), **repeat_extra},
            )
            events.append(event)
            frame_events.append(event)
            continue
        if category == "sound":
            flags, wait_row = lookahead_flags(rows, index)
            event = compact_event(
                instr,
                tick,
                "result-sound",
                {
                    "wlkNo": instr.get("wlkNo"),
                    "normalWlkNo": instr.get("normalWlkNo"),
                    "altWlkNo": instr.get("altWlkNo"),
                    "mode": instr.get("mode"),
                    **repeat_extra,
                },
            )
            events.append(event)
            result_sounds.append(event)
            hit = {
                "tick": tick,
                "soundVaHex": instr.get("vaHex"),
                "normalWlkNo": instr.get("normalWlkNo"),
                "altWlkNo": instr.get("altWlkNo"),
                "mode": instr.get("mode"),
                "flags": flags,
                "waitAfter": wait_row,
                "classification": classify_hit_event(flags),
                **repeat_extra,
            }
            hit_events.append(hit)
            continue
        if category == "effect-sound":
            event = compact_event(instr, tick, "effect-sound", {"wlkNo": instr.get("wlkNo"), "effectArgsHex": instr.get("effectArgsHex"), **repeat_extra})
            events.append(event)
            effect_sounds.append(event)
            continue
        if category == "cleanup/helper":
            hid = helper_id(instr)
            event = compact_event(instr, tick, "helper-call", {"helperId": hid, **repeat_extra})
            events.append(event)
            helper_calls.append(event)
            continue
        if category == "movement":
            event = compact_event(
                instr,
                tick,
                "movement",
                {
                    "movementKind": instr.get("movementKind"),
                    "selector": instr.get("selector"),
                    "divisor": instr.get("divisor"),
                    "movementMode": instr.get("movementMode"),
                    **repeat_extra,
                },
            )
            events.append(event)
            movement_events.append(event)
            continue
        if category == "actor-flags":
            events.append(compact_event(instr, tick, "actor-flag", {"mode": instr.get("mode"), "maskHex": instr.get("maskHex"), **repeat_extra}))
            continue
        if category == "wait" or instr.get("opcode") in {"0xbf", "0xc1"}:
            events.append(compact_event(instr, tick, "wait", repeat_extra))
            continue
        if category == "repeat-loop":
            event = compact_event(
                instr,
                tick,
                "repeat-loop",
                {"targetVaHex": instr.get("targetVaHex"), "repeatCount": instr.get("repeatCount")},
            )
            events.append(event)
            repeat_events.append(event)
            continue
        if category == "write" and instr.get("destHex") in {"0x1c", "0x20", "0x28", "0x68", "0x6c", "0x74"}:
            events.append(compact_event(instr, tick, "actor-write", {"destHex": instr.get("destHex"), "immHex": instr.get("immHex"), "immFixed": instr.get("immFixed"), **repeat_extra}))

    return {
        "durationGateFromFrames": tick,
        "events": events,
        "frameEvents": frame_events,
        "effectSounds": effect_sounds,
        "resultSounds": result_sounds,
        "helperCalls": helper_calls,
        "movementEvents": movement_events,
        "repeatLoops": repeat_events,
        "hitEvents": hit_events,
        "helperInfo": helper_info or {},
    }


def build() -> dict[str, Any]:
    display = json.loads(DISPLAY_JSON.read_text(encoding="utf-8"))
    helper_by_key = build_helper_index()
    rows = []
    family_groups: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
    classification_counts: Counter[str] = Counter()

    for decoded in display.get("decodedRows") or []:
        helper_info = helper_by_key.get(key_for(decoded), {})
        timeline = build_timeline(decoded, helper_info)
        hit_classes = Counter(hit["classification"] for hit in timeline["hitEvents"])
        classification_counts.update(hit_classes)
        out_row = {
            "ownerKey": decoded.get("ownerKey"),
            "ownerName": decoded.get("ownerName"),
            "skillName": decoded.get("skillName"),
            "familyName": decoded.get("familyName"),
            "skillIdHex": decoded.get("skillIdHex"),
            "levelOrFixed": decoded.get("levelOrFixed"),
            "entryStartVaHex": decoded.get("entryStartVaHex"),
            "startSource": decoded.get("startSource"),
            "confidence": decoded.get("confidence"),
            "note": decoded.get("note"),
            "frameSequence": [event.get("frame") for event in timeline["frameEvents"] if event.get("frame") is not None],
            "soundWlkNos": [sound.get("wlkNo") for sound in decoded.get("sounds") or []],
            "effectWlkNos": [sound.get("wlkNo") for sound in decoded.get("effectSounds") or []],
            "helperIds": helper_info.get("helperIds") or [],
            "positionClass": helper_info.get("positionClass"),
            "helperRole": helper_info.get("helperRole"),
            "durationGateFromFrames": timeline["durationGateFromFrames"],
            "repeatLoopCount": len(timeline["repeatLoops"]),
            "hitEventCount": len(timeline["hitEvents"]),
            "hitClassCounts": dict(hit_classes),
            "timeline": timeline,
        }
        rows.append(out_row)
        family_groups[f"{out_row['ownerName']}::{out_row['familyName'] or out_row['skillName']}"].append(out_row)

    family_rows = []
    for family, items in sorted(family_groups.items()):
        if len(items) < 2:
            continue
        family_rows.append(
            {
                "family": family,
                "skillIds": [item["skillIdHex"] for item in items],
                "durations": [item["durationGateFromFrames"] for item in items],
                "hitEventCounts": [item["hitEventCount"] for item in items],
                "helperIds": [item["helperIds"] for item in items],
                "soundWlkNos": [item["soundWlkNos"] for item in items],
                "effectWlkNos": [item["effectWlkNos"] for item in items],
                "positionClasses": sorted({str(item.get("positionClass") or "") for item in items}),
            }
        )

    return {
        "version": 1,
        "kind": "hwanse-battle-action-event-timeline-review",
        "source": [
            "out/battle_display_vm_static_decode.json",
            "out/battle_helper_opcode_review.json",
        ],
        "status": "main-actor-vm-gate-order-hit-sound-helper-timeline",
        "summary": {
            "rows": len(rows),
            "rowsWithResultSound": sum(1 for row in rows if row["timeline"]["resultSounds"]),
            "rowsWithEffectSound": sum(1 for row in rows if row["timeline"]["effectSounds"]),
            "rowsWithHelperCall": sum(1 for row in rows if row["timeline"]["helperCalls"]),
            "rowsWithRepeatLoops": sum(1 for row in rows if row["timeline"]["repeatLoops"]),
            "rowsWithConfirmedHitWindow": sum(1 for row in rows if row["hitClassCounts"].get("confirmed-result-hit-window")),
            "hitClassCounts": dict(sorted(classification_counts.items())),
        },
        "interpretationNotes": [
            "Ticks are accumulated only from 0x21 frame gate fields. Busy waits and engine waits are shown as barriers, not converted to elapsed time.",
            "Finite 0x06 repeat-loop bodies are expanded in this report. The repeat count is interpreted as total body executions, so the pre-loop body is pass #1 and only count-1 extra passes are appended.",
            "A confirmed result hit window means a 0xc2 result sound is followed by 0xad mode=2 mask=0x0c and mode=1 mask=0x08 before the next frame/sound/helper.",
            "0x24 is a cast/effect sound cue. 0xc2 is result/hit sound selection with normal/alt WLK candidates.",
            "0xbd helper calls are listed at the actor timeline tick and should be resolved through battle_effect_object_review / battle_helper_child_script_review.",
            "This report still does not decode damage, miss, critical, or status formulas. It gives the VM ordering needed to schedule the browser runner without capture-derived timing.",
        ],
        "familyRows": family_rows,
        "rows": rows,
    }


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Action Event Timeline Review",
        "",
        f"- status: `{report['status']}`",
        f"- rows: `{report['summary']['rows']}`",
        f"- rows with confirmed hit window: `{report['summary']['rowsWithConfirmedHitWindow']}`",
        "",
        "## Interpretation",
        "",
    ]
    lines.extend(f"- {note}" for note in report["interpretationNotes"])
    lines.extend(
        [
            "",
            "## Skill Timelines",
            "",
            "| actor | skill | id | duration | frames | 0x24 WLK | 0xc2 WLK | helpers | hit events | position |",
            "| --- | --- | ---: | ---: | --- | --- | --- | --- | ---: | --- |",
        ]
    )
    for row in report["rows"]:
        lines.append(
            f"| {row['ownerName']} | {row['skillName']} | `{row['skillIdHex']}` | {row['durationGateFromFrames']} | "
            f"{vec(row['frameSequence'])} | {wlk_vec(row['effectWlkNos'])} | {wlk_vec(row['soundWlkNos'])} | "
            f"{vec(row['helperIds'], '#')} | {row['hitEventCount']} | `{row.get('positionClass') or '-'}` |"
        )
    return "\n".join(lines) + "\n"


def events_html(events: list[dict[str, Any]], limit: int = 60) -> str:
    if not events:
        return "-"
    rows = []
    for event in events[:limit]:
        detail = []
        for key in ("frame", "gate", "wlkNo", "normalWlkNo", "altWlkNo", "helperId", "destHex", "immFixed", "selector", "divisor", "maskHex", "repeatIteration", "repeatTotalCount", "repeatCount"):
            if event.get(key) not in (None, "", []):
                detail.append(f"{key}={event.get(key)}")
        rows.append(
            "<tr>"
            f"<td>{esc(event.get('tick'))}</td>"
            f"<td>{esc(event.get('kind'))}</td>"
            f"<td><code>{esc(event.get('vaHex'))}</code></td>"
            f"<td>{esc(', '.join(detail))}</td>"
            f"<td>{esc(event.get('summary'))}</td>"
            "</tr>"
        )
    if len(events) > limit:
        rows.append(f"<tr><td colspan='5'>... +{len(events) - limit}</td></tr>")
    return f"<table><thead><tr><th>tick</th><th>kind</th><th>VA</th><th>detail</th><th>summary</th></tr></thead><tbody>{''.join(rows)}</tbody></table>"


def hit_events_html(hit_events: list[dict[str, Any]]) -> str:
    if not hit_events:
        return "-"
    rows = []
    for hit in hit_events:
        flags = ", ".join(f"{flag.get('mode')}:{flag.get('maskHex')}" for flag in hit.get("flags") or []) or "-"
        rows.append(
            "<tr>"
            f"<td>{esc(hit.get('tick'))}</td>"
            f"<td><code>{esc(hit.get('soundVaHex'))}</code></td>"
            f"<td>WLK id {int(hit.get('normalWlkNo')):02d} / alt WLK id {int(hit.get('altWlkNo')):02d}</td>"
            f"<td>{esc(flags)}</td>"
            f"<td>{esc(hit.get('classification'))}</td>"
            "</tr>"
        )
    return f"<table><thead><tr><th>tick</th><th>sound VA</th><th>WLK</th><th>flags</th><th>class</th></tr></thead><tbody>{''.join(rows)}</tbody></table>"


def html_page(report: dict[str, Any]) -> str:
    summary_rows = "".join(f"<tr><td>{esc(k)}</td><td><code>{esc(v)}</code></td></tr>" for k, v in report["summary"].items())
    notes = "".join(f"<li>{esc(note)}</li>" for note in report["interpretationNotes"])
    skill_rows = []
    for row in report["rows"]:
        timeline = row["timeline"]
        skill_rows.append(
            "<tr>"
            f"<td>{esc(row['ownerName'])}</td>"
            f"<td>{esc(row['skillName'])}<br><code>{esc(row['skillIdHex'])}</code></td>"
            f"<td>{esc(row['durationGateFromFrames'])}</td>"
            f"<td>{esc(vec(row['frameSequence']))}</td>"
            f"<td>{esc(wlk_vec(row['effectWlkNos']))}</td>"
            f"<td>{esc(wlk_vec(row['soundWlkNos']))}</td>"
            f"<td>{esc(vec(row['helperIds'], '#'))}</td>"
            f"<td>{hit_events_html(timeline.get('hitEvents') or [])}</td>"
            f"<td><details><summary>events</summary>{events_html(timeline.get('events') or [])}</details></td>"
            "</tr>"
        )
    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 Action Event Timeline 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; }}
    details {{ margin: 4px 0; }} summary {{ cursor: pointer; color: #ffd37a; }}
    .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 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; }}
  </style>
</head>
<body>
  <h1>Battle Action Event Timeline Review</h1>
  <p><a href="../web/index.html">홈</a> · <a href="../web/battle_simulator.html">전투 기술 실행</a> · <a href="battle_effect_object_review.html">이펙트 객체</a> · <a href="battle_action_event_timeline_review.json">JSON</a> · <a href="battle_action_event_timeline_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>Interpretation</h2><ul>{notes}</ul></section>
  </div>
  <h2>Main Actor Timelines</h2>
  <div class="wide">
    <table>
      <thead><tr><th>actor</th><th>skill</th><th>duration</th><th>frames</th><th>0x24 WLK</th><th>0xc2 WLK</th><th>helpers</th><th>hit windows</th><th>detail</th></tr></thead>
      <tbody>{''.join(skill_rows)}</tbody>
    </table>
  </div>
</body>
</html>
"""


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


if __name__ == "__main__":
    main()
