#!/usr/bin/env python3
"""Build conservative gate-order timing for battle helper effect synchronization.

This report does not convert VM gate fields into browser milliseconds.  It
keeps the executable's own gate units and answers a narrower question:

* when does a helper child script attach a frameScript?
* when do frameScript frames advance?
* when does opcode 0xad set/clear actor flags relative to those frames?

That is enough to expose confirmed hit/effect sync points without inventing
visual semantics that are not present in the static data.
"""
from __future__ import annotations

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

from build_battle_display_vm_static_decode import EXE, hex32, read_sections
from build_battle_helper_child_script_review import walk_child_script


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
HELPER_CHILD_JSON = OUT / "battle_helper_child_script_review.json"
HELPER_FRAME_JSON = OUT / "battle_helper_frame_script_review.json"
HELPER_RUNTIME_JSON = OUT / "battle_helper_effect_runtime_review.json"
HELPER_POSITION_MOTION_JSON = OUT / "battle_helper_position_motion_review.json"
EFFECT_SPRITE_ASSET = {
    "0x1a": "btl_efc",
}


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 compact_skills(skills: list[dict[str, Any]], limit: int = 8) -> str:
    values = [
        f"{skill.get('ownerName')} {skill.get('skillName')} {skill.get('skillIdHex')}"
        for skill in skills
    ]
    if len(values) > limit:
        values = values[:limit] + [f"... +{len(values) - limit}"]
    return "; ".join(values) or "-"


def append_event(events: list[dict[str, Any]], tick: int, row: dict[str, Any], kind: str, detail: dict[str, Any] | None = None) -> None:
    event = {
        "tick": tick,
        "kind": kind,
        "vaHex": row.get("vaHex"),
        "opcode": row.get("opcode"),
        "summary": row.get("summary"),
    }
    if detail:
        event.update(detail)
    events.append(event)


def child_timeline(decoded: dict[str, Any]) -> dict[str, Any]:
    tick = 0
    events: list[dict[str, Any]] = []
    frame_script_starts: list[dict[str, Any]] = []
    spawn_events: list[dict[str, Any]] = []
    flag_events: list[dict[str, Any]] = []
    wait_events: list[dict[str, Any]] = []
    repeat_events: list[dict[str, Any]] = []
    control_events: list[dict[str, Any]] = []
    for row in decoded.get("rows") or []:
        category = row.get("category")
        if category == "init-block":
            for frame in row.get("initFrames") or []:
                append_event(events, tick, row, "init-frame", {"frame": frame.get("frame"), "spriteHex": frame.get("spriteHex")})
        elif category == "frame":
            gate = int(row.get("gate") or 0)
            append_event(events, tick, row, "frame", {"frame": row.get("frame"), "spriteHex": row.get("spriteHex"), "gate": gate})
            tick += gate
        elif category == "frame-script-pointer":
            target = row.get("targetVaHex")
            event = {"targetVaHex": target}
            append_event(events, tick, row, "frame-script-start", event)
            frame_script_starts.append({"tick": tick, "targetVaHex": target, "vaHex": row.get("vaHex")})
        elif category == "spawn-child-vm":
            event = {
                "targetVaHex": row.get("targetVaHex"),
                "targetVa": row.get("targetVa"),
                "childObjectType": row.get("childObjectType"),
            }
            append_event(events, tick, row, "spawn-child-vm", event)
            spawn_events.append({"tick": tick, "vaHex": row.get("vaHex"), **event})
        elif category == "actor-flags":
            event = {"maskHex": row.get("maskHex"), "mode": row.get("mode")}
            append_event(events, tick, row, "actor-flag", event)
            flag_events.append({"tick": tick, **event, "vaHex": row.get("vaHex")})
        elif category == "countdown-wait":
            wait = int(row.get("waitFrames") or 0)
            append_event(events, tick, row, "wait", {"waitFrames": wait})
            wait_events.append({"tick": tick, "waitFrames": wait, "vaHex": row.get("vaHex")})
            tick += wait
        elif category == "yield":
            append_event(events, tick, row, "yield", {"waitFrames": 1})
            wait_events.append({"tick": tick, "waitFrames": 1, "vaHex": row.get("vaHex"), "kind": "yield"})
            tick += 1
        elif category == "random-range":
            append_event(events, tick, row, "random-range", {"randomRange": row.get("randomRange"), "randomRangeHex": row.get("randomRangeHex")})
        elif category in {"parent-actor", "parent-actor-bind", "global-parent-anchor", "link-child-parent"}:
            append_event(events, tick, row, category.replace("-", "_"))
        elif category in {"placement-expr", "motion-step", "write", "child-write"}:
            append_event(events, tick, row, category)
        elif category in {"clear-display-list", "screen-helper"}:
            append_event(events, tick, row, category)
        elif category in {"jump", "repeat-loop", "call-subscript", "indexed-jump-table", "switch/control"}:
            event = {
                "targetVaHex": row.get("targetVaHex"),
                "repeatCount": row.get("repeatCount"),
                "tableCount": row.get("tableCount"),
                "staticPathChoice": row.get("staticPathChoice"),
            }
            append_event(events, tick, row, "control", event)
            control_events.append({"tick": tick, "vaHex": row.get("vaHex"), "category": category, **event})
            if category == "repeat-loop":
                repeat_events.append({"tick": tick, "vaHex": row.get("vaHex"), **event})
        elif category == "destroy/end":
            append_event(events, tick, row, "destroy")
        else:
            append_event(events, tick, row, category or "unknown")
    return {
        "durationGate": tick,
        "events": events,
        "frameScriptStarts": frame_script_starts,
        "spawnEvents": spawn_events,
        "flagEvents": flag_events,
        "waitEvents": wait_events,
        "repeatEvents": repeat_events,
        "controlEvents": control_events,
    }


def init_frame_events_for_target(
    data: bytes,
    sections: list[dict[str, Any]],
    cache: dict[str, dict[str, Any]],
    target_hex: str | None,
) -> list[dict[str, Any]]:
    if not target_hex:
        return []
    if target_hex not in cache:
        try:
            cache[target_hex] = walk_child_script(data, sections, int(target_hex, 16), max_steps=180)
        except Exception as exc:  # pragma: no cover - report generation should continue with partial data.
            cache[target_hex] = {"startVaHex": target_hex, "error": str(exc), "initFrames": [], "rows": []}
    decoded = cache[target_hex]
    frames = []
    for frame in decoded.get("initFrames") or []:
        sprite_hex = frame.get("spriteHex")
        frames.append(
            {
                "source": "spawn-child-direct-init-frame",
                "targetVaHex": target_hex,
                "targetStopReason": decoded.get("stopReason"),
                "initVaHex": frame.get("vaHex"),
                "spriteHex": sprite_hex,
                "asset": EFFECT_SPRITE_ASSET.get(sprite_hex),
                "frame": frame.get("frame"),
                "selectorHex": frame.get("selectorHex"),
            }
        )
    return frames


def transform_scope_for_target(position_row: dict[str, Any] | None, target_hex: str | None) -> dict[str, Any] | None:
    if not position_row or not target_hex:
        return None
    target = target_hex.lower()
    for scope in position_row.get("scopes") or []:
        label = str(scope.get("scope") or "").lower()
        if target in label:
            return scope
    return None


def transform_scope_ref(scope: dict[str, Any] | None) -> dict[str, Any] | None:
    if not scope:
        return None
    return {
        "scope": scope.get("scope"),
        "coordinateSummary": scope.get("coordinateSummary"),
        "patternSummary": vec(scope.get("patterns") or []),
    }


def attach_transform_summaries(
    direct_spawn: list[dict[str, Any]],
    position_row: dict[str, Any] | None,
) -> None:
    for spawn in direct_spawn:
        transform = transform_scope_ref(transform_scope_for_target(position_row, spawn.get("targetVaHex")))
        if transform:
            spawn["transformScope"] = transform
        for frame in spawn.get("frameEvents") or []:
            frame_transform = transform_scope_ref(transform_scope_for_target(position_row, frame.get("targetVaHex"))) or transform
            if frame_transform:
                frame["transformScope"] = frame_transform
                frame["transformStatus"] = "matched-position-motion-scope"
            else:
                frame["transformStatus"] = "unmatched-position-motion-scope"


def direct_spawn_timelines(
    data: bytes,
    sections: list[dict[str, Any]],
    decoded: dict[str, Any],
    child: dict[str, Any],
    cache: dict[str, dict[str, Any]],
) -> list[dict[str, Any]]:
    spawn_by_va = {event.get("vaHex"): event for event in child.get("spawnEvents") or [] if event.get("vaHex")}
    timelines: list[dict[str, Any]] = []
    seen: set[tuple[str, str, int]] = set()

    def add_spawn(spawn: dict[str, Any], tick: int, reason: str, repeat_index: int | None = None) -> None:
        target_hex = spawn.get("targetVaHex")
        frame_events = []
        for frame in init_frame_events_for_target(data, sections, cache, target_hex):
            event = {
                **frame,
                "tick": tick,
                "absoluteTick": tick,
                "spawnVaHex": spawn.get("vaHex"),
                "childObjectType": spawn.get("childObjectType"),
                "reason": reason,
            }
            if repeat_index is not None:
                event["repeatIndex"] = repeat_index
            frame_events.append(event)
        if not frame_events:
            return
        key = (str(spawn.get("vaHex")), str(target_hex), int(tick))
        if key in seen:
            return
        seen.add(key)
        timelines.append(
            {
                "spawnVaHex": spawn.get("vaHex"),
                "targetVaHex": target_hex,
                "startTick": tick,
                "source": reason,
                "frameEvents": frame_events,
            }
        )

    for spawn in child.get("spawnEvents") or []:
        add_spawn(spawn, int(spawn.get("tick") or 0), "linear-spawn")

    for repeat in child.get("repeatEvents") or []:
        spawn = spawn_by_va.get(repeat.get("targetVaHex"))
        if not spawn:
            continue
        count = max(0, int(repeat.get("repeatCount") or 0))
        base_tick = int(repeat.get("tick") or spawn.get("tick") or 0)
        wait_step = 1
        prior_waits = [
            int(wait.get("waitFrames") or 0)
            for wait in child.get("waitEvents") or []
            if int(wait.get("tick") or 0) < base_tick
            and not wait.get("kind")
            and int(wait.get("waitFrames") or 0) > 0
        ]
        if not prior_waits:
            prior_waits = [
                int(wait.get("waitFrames") or 0)
                for wait in child.get("waitEvents") or []
                if int(wait.get("tick") or 0) < base_tick and int(wait.get("waitFrames") or 0) > 0
            ]
        if prior_waits:
            wait_step = prior_waits[-1]
        for idx in range(count):
            add_spawn(spawn, base_tick + idx * wait_step, "repeat-loop-expanded", idx)

    # Some helpers use a dynamic indexed jump and then jump back to the selector
    # after one yield.  Static review follows one cycle only; show a bounded
    # second-cycle preview using the loop-control fields (+0x94/+0x96) when they
    # are visible in the decoded rows.  This keeps dragon/projectile effects on
    # the page without pretending to solve unbounded runtime iteration.
    loop_jumps = [
        row for row in decoded.get("rows") or []
        if row.get("category") == "jump" and row.get("targetVaHex")
    ]
    max_loop_count = 0
    for row in decoded.get("rows") or []:
        if row.get("category") == "child-write" and row.get("destHex") in {"0x94", "0x96"} and row.get("immediate"):
            max_loop_count = max(max_loop_count, int(row.get("imm") or 0))
    if loop_jumps and max_loop_count:
        later_spawns = [event for event in child.get("spawnEvents") or [] if int(event.get("tick") or 0) > 0]
        for spawn in later_spawns:
            base_tick = int(spawn.get("tick") or 0)
            for idx in range(1, min(max_loop_count, 16)):
                add_spawn(spawn, base_tick + idx, "bounded-jump-loop-preview", idx)

    return timelines


def frame_timeline(frame_row: dict[str, Any]) -> dict[str, Any]:
    tick = 0
    events: list[dict[str, Any]] = []
    flag_events: list[dict[str, Any]] = []
    frame_events: list[dict[str, Any]] = []
    loop_events: list[dict[str, Any]] = []
    for row in (frame_row.get("decoded") or {}).get("rows") or []:
        category = row.get("category")
        if category == "frame":
            gate = int(row.get("gate") or 0)
            event = {"frame": row.get("frame"), "spriteHex": row.get("spriteHex"), "gate": gate}
            append_event(events, tick, row, "frame", event)
            frame_events.append({"tick": tick, **event, "vaHex": row.get("vaHex")})
            tick += gate
        elif category == "actor-flags":
            event = {"maskHex": row.get("maskHex"), "mode": row.get("mode")}
            append_event(events, tick, row, "actor-flag", event)
            flag_events.append({"tick": tick, **event, "vaHex": row.get("vaHex")})
        elif category in {"jump", "repeat-loop"}:
            append_event(events, tick, row, "loop/control", {"targetVaHex": row.get("targetVaHex"), "repeatCount": row.get("repeatCount")})
            loop_events.append({"tick": tick, "targetVaHex": row.get("targetVaHex"), "repeatCount": row.get("repeatCount"), "vaHex": row.get("vaHex")})
        elif category == "write":
            append_event(events, tick, row, "write", {"destHex": row.get("destHex"), "immHex": row.get("immHex"), "opName": row.get("opName")})
        elif category == "placement-expr":
            append_event(events, tick, row, "placement-expr")
        elif category == "parent-actor":
            append_event(events, tick, row, "parent-actor")
        elif category == "destroy/end":
            append_event(events, tick, row, "destroy")
        else:
            append_event(events, tick, row, category or "unknown")
    return {
        "targetVaHex": frame_row.get("targetVaHex"),
        "durationGate": tick,
        "stopReason": frame_row.get("stopReason"),
        "events": events,
        "frameEvents": frame_events,
        "flagEvents": flag_events,
        "loopEvents": loop_events,
    }


def build() -> dict[str, Any]:
    data = EXE.read_bytes()
    sections = read_sections(data)
    child_report = json.loads(HELPER_CHILD_JSON.read_text(encoding="utf-8"))
    frame_report = json.loads(HELPER_FRAME_JSON.read_text(encoding="utf-8"))
    runtime_report = json.loads(HELPER_RUNTIME_JSON.read_text(encoding="utf-8"))
    position_report = json.loads(HELPER_POSITION_MOTION_JSON.read_text(encoding="utf-8"))

    frame_by_target = {
        row.get("targetVaHex"): row
        for row in frame_report.get("frameScriptRows") or []
        if row.get("targetVaHex")
    }
    runtime_by_helper = {
        int(row.get("helperId")): row
        for row in runtime_report.get("runtimeRows") or []
        if row.get("helperId") is not None
    }
    position_by_helper = {
        int(row.get("helperId")): row
        for row in position_report.get("helperRows") or []
        if row.get("helperId") is not None
    }

    rows = []
    signal_counts: Counter[str] = Counter()
    flag_tick_counts: Counter[int] = Counter()
    direct_spawn_cache: dict[str, dict[str, Any]] = {}
    for helper in child_report.get("helperRows") or []:
        helper_id = int(helper["helperId"])
        decoded = helper.get("decoded") or {}
        child = child_timeline(decoded)
        direct_spawn = direct_spawn_timelines(data, sections, decoded, child, direct_spawn_cache)
        runtime = runtime_by_helper.get(helper_id, {})
        position_motion = position_by_helper.get(helper_id)
        attach_transform_summaries(direct_spawn, position_motion)
        target_timelines = []
        combined_flags = []
        for start in child["frameScriptStarts"]:
            target_hex = start.get("targetVaHex")
            target_row = frame_by_target.get(target_hex)
            if not target_row:
                continue
            timeline = frame_timeline(target_row)
            absolute_flags = [
                {
                    **flag,
                    "absoluteTick": int(start.get("tick") or 0) + int(flag.get("tick") or 0),
                    "targetVaHex": target_hex,
                }
                for flag in timeline["flagEvents"]
            ]
            absolute_frames = [
                {
                    **frame,
                    "absoluteTick": int(start.get("tick") or 0) + int(frame.get("tick") or 0),
                    "targetVaHex": target_hex,
                }
                for frame in timeline["frameEvents"]
            ]
            target_timelines.append(
                {
                    "targetVaHex": target_hex,
                    "startTick": start.get("tick"),
                    "durationGate": timeline["durationGate"],
                    "stopReason": timeline["stopReason"],
                    "frameEvents": timeline["frameEvents"],
                    "absoluteFrameEvents": absolute_frames,
                    "flagEvents": timeline["flagEvents"],
                    "absoluteFlagEvents": absolute_flags,
                    "loopEvents": timeline["loopEvents"],
                    "events": timeline["events"],
                }
            )
            combined_flags.extend(absolute_flags)
        combined_flags.extend(child["flagEvents"])
        for flag in combined_flags:
            tick = flag.get("absoluteTick", flag.get("tick"))
            if isinstance(tick, int):
                flag_tick_counts[tick] += 1

        signals = []
        if child["frameScriptStarts"]:
            signals.append("frame-script-sync")
        if child["flagEvents"]:
            signals.append("child-actor-flag")
        if combined_flags:
            signals.append("actor-flag-sync")
        if any(target["loopEvents"] for target in target_timelines):
            signals.append("looping-frame-script")
        if child["waitEvents"]:
            signals.append("wait/yield")
        if target_timelines:
            signals.append("frame-gate-timeline")
        if direct_spawn:
            signals.append("spawn-tree-direct-init-frame")
        signal_counts.update(signals)

        rows.append(
            {
                "helperId": helper_id,
                "childScriptVaHex": helper.get("childScriptVaHex"),
                "skillRows": helper.get("skillRows") or [],
                "runtimeSignals": runtime.get("signals") or [],
                "syncSignals": signals,
                "childTimeline": child,
                "frameScriptTimelines": target_timelines,
                "directSpawnTimelines": direct_spawn,
                "directSpawnFrameEvents": [
                    frame
                    for spawn in direct_spawn
                    for frame in spawn.get("frameEvents", [])
                ],
                "combinedActorFlagEvents": sorted(combined_flags, key=lambda item: (item.get("absoluteTick", item.get("tick", 0)), str(item.get("targetVaHex", "")))),
            }
        )

    return {
        "version": 1,
        "kind": "hwanse-battle-helper-sync-timing-review",
        "source": [
            "out/battle_helper_child_script_review.json",
            "out/battle_helper_frame_script_review.json",
            "out/battle_helper_effect_runtime_review.json",
            "out/battle_helper_position_motion_review.json",
        ],
        "status": "confirmed-gate-order-sync",
        "runtimeUsed": False,
        "summary": {
            "helpers": len(rows),
            "helpersWithFrameScriptTimeline": sum(1 for row in rows if row["frameScriptTimelines"]),
            "helpersWithCombinedActorFlags": sum(1 for row in rows if row["combinedActorFlagEvents"]),
            "helpersWithChildActorFlags": sum(1 for row in rows if row["childTimeline"]["flagEvents"]),
            "helpersWithLoopingFrameScript": sum(1 for row in rows if any(target["loopEvents"] for target in row["frameScriptTimelines"])),
            "helpersWithDirectSpawnFrames": sum(1 for row in rows if row["directSpawnFrameEvents"]),
            "directSpawnFrameEvents": sum(len(row["directSpawnFrameEvents"]) for row in rows),
            "directSpawnFrameEventsWithTransform": sum(
                1
                for row in rows
                for item in row["directSpawnFrameEvents"]
                if item.get("transformStatus") == "matched-position-motion-scope"
            ),
            "helpersWithPositionMotion": len(position_by_helper),
            "signalCounts": dict(sorted(signal_counts.items())),
            "actorFlagTickHistogram": dict(sorted(flag_tick_counts.items())),
        },
        "interpretationNotes": [
            "Ticks here are EXE gate units, not milliseconds.",
            "For opcode 0x21, the report advances the local tick by the gate field after recording the frame event.",
            "For opcode 0x02, the report advances the child-script tick by waitFrames. Opcode 0x01 yield advances by one unit.",
            "FrameScript timelines are shown relative to their own start and as absolute child-script gate offsets from the 0x20 attachment point.",
            "Some helpers do not attach a 0x20 frameScript. They spawn child display VMs whose 0x08 init block writes display.spriteFrame(+0x28) directly. Those direct spawn frames are exposed separately as directSpawnTimelines.",
            "Direct spawn loop expansion is bounded and conservative: finite 0x06 repeat loops are expanded by their EXE count, while jump-back loops are shown as a preview of one bounded cycle using visible loop-count fields.",
            "Direct spawn frame events are cross-linked with battle_helper_position_motion_review scopes when the spawned child script VA matches a position/motion scope. This exposes confirmed display.x/y and motion opcode context without inventing final screen coordinates.",
            "Opcode 0xad is exposed as actor-flag sync only. Exact damage, hit, miss, and critical semantics are handled elsewhere in the battle engine and are not inferred here.",
            "Looping frameScripts are not expanded indefinitely. One decoded cycle is shown with the loop/control event.",
        ],
        "rows": rows,
    }


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Helper Sync Timing Review",
        "",
        f"- status: `{report['status']}`",
        f"- helpers: `{report['summary']['helpers']}`",
        f"- helpers with frameScript timeline: `{report['summary']['helpersWithFrameScriptTimeline']}`",
        f"- helpers with actor flags: `{report['summary']['helpersWithCombinedActorFlags']}`",
        "",
        "## Interpretation",
        "",
    ]
    lines.extend(f"- {note}" for note in report["interpretationNotes"])
    lines.extend(
        [
            "",
            "## Helper Sync",
            "",
            "| helper | signals | skills | frameScript starts | direct spawn frames | transform scopes | actor flags | frames | loops |",
            "| ---: | --- | --- | --- | --- | --- | --- | --- | --- |",
        ]
    )
    for row in report["rows"]:
        starts = [f"{item['targetVaHex']}@{item['tick']}" for item in row["childTimeline"]["frameScriptStarts"]]
        direct_frames = [
            f"{item.get('asset') or item.get('spriteHex')}#{item.get('frame')}@{item.get('absoluteTick', item.get('tick'))} {item.get('targetVaHex')}"
            for item in row.get("directSpawnFrameEvents") or []
        ]
        transforms = [
            f"{item.get('targetVaHex')} {((item.get('transformScope') or {}).get('scope') or '-')} {((item.get('transformScope') or {}).get('coordinateSummary') or '-')}"
            for item in row.get("directSpawnFrameEvents") or []
            if item.get("transformScope")
        ]
        flags = [
            f"{flag.get('targetVaHex', 'child')}@{flag.get('absoluteTick', flag.get('tick'))}:{flag.get('maskHex')}"
            for flag in row["combinedActorFlagEvents"]
        ]
        frames = []
        loops = []
        for target in row["frameScriptTimelines"]:
            seq = [f"{item['frame']}@{item['tick']}+{item['gate']}" for item in target["frameEvents"]]
            frames.append(f"{target['targetVaHex']} [{vec(seq)}]")
            loops.extend(f"{target['targetVaHex']}@{item.get('tick')}->{item.get('targetVaHex')}" for item in target["loopEvents"])
        lines.append(
            f"| {row['helperId']} | {vec(row['syncSignals'])} | {compact_skills(row['skillRows'])} | "
            f"{vec(starts)} | {vec(direct_frames)} | {vec(transforms)} | {vec(flags)} | {'; '.join(frames) or '-'} | {vec(loops)} |"
        )
    return "\n".join(lines) + "\n"


def html_list(items: list[str], limit: int = 10) -> str:
    if not items:
        return "-"
    clipped = items[:limit]
    suffix = f"<li>... +{len(items) - limit}</li>" if len(items) > limit else ""
    return "<ul>" + "".join(f"<li>{esc(item)}</li>" for item in clipped) + suffix + "</ul>"


def html_events(events: list[dict[str, Any]], limit: int = 24) -> str:
    values = []
    for event in events:
        tick = event.get("absoluteTick", event.get("tick"))
        label = f"@{tick} {event.get('kind')}"
        if event.get("frame") is not None:
            label += f" frame {event.get('frame')} gate {event.get('gate')}"
        if event.get("maskHex"):
            label += f" mask {event.get('maskHex')}"
        if event.get("targetVaHex"):
            label += f" target {event.get('targetVaHex')}"
        if event.get("vaHex"):
            label += f" ({event.get('vaHex')})"
        values.append(label)
    return html_list(values, limit=limit)


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"])
    table_rows = []
    for row in report["rows"]:
        starts = [f"{item['targetVaHex']} @ child gate {item['tick']}" for item in row["childTimeline"]["frameScriptStarts"]]
        direct_values = [
            (
                f"{item.get('asset') or item.get('spriteHex')} #{item.get('frame')} "
                f"@ {item.get('absoluteTick', item.get('tick'))} "
                f"spawn {item.get('spawnVaHex')} target {item.get('targetVaHex')} "
                f"{item.get('source') or item.get('reason') or ''}"
            )
            for item in row.get("directSpawnFrameEvents") or []
        ]
        transform_values = []
        for item in row.get("directSpawnFrameEvents") or []:
            scope = item.get("transformScope") or {}
            if not scope:
                continue
            transform_values.append(
                f"{item.get('targetVaHex')} {scope.get('scope') or '-'}: {scope.get('coordinateSummary') or '-'}; "
                f"patterns {scope.get('patternSummary') or '-'}"
            )
        flag_values = [
            f"{flag.get('targetVaHex', 'child')} @ {flag.get('absoluteTick', flag.get('tick'))} mask {flag.get('maskHex')}"
            for flag in row["combinedActorFlagEvents"]
        ]
        target_details = []
        for target in row["frameScriptTimelines"]:
            target_details.append(
                f"<details><summary>{esc(target['targetVaHex'])} frames/flags</summary>"
                f"<h4>relative events</h4>{html_events(target['events'])}"
                f"<h4>absolute frames</h4>{html_events(target['absoluteFrameEvents'])}"
                f"<h4>absolute flags</h4>{html_events(target['absoluteFlagEvents'])}"
                f"<h4>loops</h4>{html_events(target['loopEvents'])}</details>"
            )
        table_rows.append(
            "<tr>"
            f"<td><code>{esc(row['helperId'])}</code><br><code>{esc(row.get('childScriptVaHex'))}</code></td>"
            f"<td>{esc(vec(row['syncSignals']))}</td>"
            f"<td>{esc(compact_skills(row['skillRows']))}</td>"
            f"<td>{html_list(starts)}</td>"
            f"<td>{html_list(direct_values, limit=20)}</td>"
            f"<td>{html_list(transform_values, limit=20)}</td>"
            f"<td>{html_list(flag_values)}</td>"
            f"<td>{''.join(target_details) or '-'}</td>"
            f"<td><details><summary>child timeline</summary>{html_events(row['childTimeline']['events'])}</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 Helper Sync Timing 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; }}
    ul {{ margin: 0; padding-left: 18px; }}
    h4 {{ margin: 8px 0 4px; color: #bac2cf; }}
    .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 Helper Sync Timing Review</h1>
  <p><a href="../web/index.html">홈</a> · <a href="../web/battle_simulator.html">전투 기술 실행</a> · <a href="battle_helper_effect_runtime_review.html">helper runtime facts</a> · <a href="battle_helper_frame_script_review.html">helper frameScript</a> · <a href="battle_helper_sync_timing_review.json">JSON</a> · <a href="battle_helper_sync_timing_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>Gate-Order Sync</h2>
  <div class="wide"><table><thead><tr><th>helper</th><th>signals</th><th>skills</th><th>0x20 starts</th><th>direct spawn frames</th><th>transform scopes</th><th>actor flags</th><th>frameScript details</th><th>child timeline</th></tr></thead><tbody>{''.join(table_rows)}</tbody></table></div>
</body>
</html>
"""


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


if __name__ == "__main__":
    main()
