#!/usr/bin/env python3
"""Build a static review of monster action presentation/effect coverage.

This report is intentionally static-only.  It consumes the already decoded
monster action catalog and helper body catalog, then summarizes which monster
actions can be previewed from local CNS frames alone and which need helper
effect script support.
"""
from __future__ import annotations

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


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"

CATALOG = OUT / "battle_monster_action_catalog.json"
HELPER_BODY = OUT / "battle_helper_body_review.json"
EFFECT_OBJECT = OUT / "battle_effect_object_review.json"
SPAWN_TREE = OUT / "battle_helper_spawn_tree_review.json"
POSITION_MOTION = OUT / "battle_helper_position_motion_review.json"


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


def load_json(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}


def compact_counter(counter: Counter[Any]) -> list[dict[str, Any]]:
    return [
        {"value": key, "count": count}
        for key, count in counter.most_common()
    ]


def action_key(action: dict[str, Any]) -> str:
    return "|".join(
        str(action.get(key, ""))
        for key in ("cns", "targetVaHex", "sharedActionIdHex", "visibleSlotHex", "variantIndex", "choiceTableIndex")
    )


def classify_action(action: dict[str, Any], effect_object_by_id: dict[int, dict[str, Any]]) -> str:
    display = action.get("displayEntry") or {}
    summary = action.get("sharedActionSummary") or {}
    frames = display.get("frames") or []
    helpers = display.get("helperCalls") or []
    has_damage = bool(summary.get("hasDamageOrStatusFormula"))
    has_recovery = bool(summary.get("hasRecovery"))
    has_self_setup = bool(summary.get("hasSelfSetup"))
    helper_ids = []
    for call in helpers:
        try:
            helper_id = int(call.get("helperId", -1))
        except (TypeError, ValueError):
            continue
        if helper_id >= 0:
            helper_ids.append(helper_id)
    helpers_static_decoded = bool(helper_ids) and all(helper_id in effect_object_by_id for helper_id in helper_ids)
    if not frames and has_recovery:
        return "recovery-no-local-frame"
    if helpers and has_damage:
        if helpers_static_decoded:
            return "damage-helper-effect-static-decoded"
        return "damage-needs-helper-effect-runner"
    if helpers:
        if helpers_static_decoded:
            return "support-helper-effect-static-decoded"
        return "support-needs-helper-effect-runner"
    if display.get("repeatLoops"):
        return "local-frame-repeat-ready"
    if frames and display.get("movements"):
        return "local-frame-motion-ready"
    if frames and display.get("positionWrites"):
        return "local-frame-position-ready"
    if frames:
        return "local-frame-ready"
    if has_self_setup:
        return "self-setup-no-local-frame"
    return "unclassified-no-frame"


def preview_scripts_for_helper(
    helper_id: int,
    effect_object_by_id: dict[int, dict[str, Any]],
    spawn_tree_by_id: dict[int, dict[str, Any]],
) -> list[dict[str, Any]]:
    scripts: list[dict[str, Any]] = []
    effect = effect_object_by_id.get(helper_id) or {}
    root = effect.get("root") or {}
    for index, script in enumerate(root.get("frameScripts") or []):
        frames = script.get("frameGateSequence") or []
        if not frames:
            continue
        scripts.append(
            {
                "source": "root-frameScript",
                "sourceLabel": f"root {script.get('targetVaHex')}",
                "targetVaHex": script.get("targetVaHex"),
                "frameGateSequence": frames,
                "durationGate": script.get("durationGate"),
                "loop": "loop" in str(script.get("stopReason") or ""),
                "index": index,
            }
        )

    spawn = spawn_tree_by_id.get(helper_id) or {}
    for node in spawn.get("flatNodes") or []:
        for script in node.get("resolvedFrameScripts") or []:
            frames = script.get("frameGateSequence") or []
            if not frames:
                continue
            scripts.append(
                {
                    "source": script.get("source") or "spawn-tree",
                    "sourceLabel": f"{node.get('path')} {node.get('targetVaHex')}",
                    "targetVaHex": script.get("targetVaHex"),
                    "frameGateSequence": frames,
                    "durationGate": script.get("durationGate"),
                    "loop": "loop" in str(script.get("stopReason") or ""),
                    "nodeClass": node.get("primaryClass"),
                    "tags": node.get("tags") or [],
                    "index": len(scripts),
                }
            )
        if not (node.get("resolvedFrameScripts") or []):
            direct_frames = node.get("directFrames") or node.get("initFrames") or []
            if direct_frames and node.get("primaryClass") in {"direct-frame-emitter", "frameScript-emitter"}:
                scripts.append(
                    {
                        "source": "spawn-direct-frame",
                        "sourceLabel": f"{node.get('path')} {node.get('targetVaHex')}",
                        "targetVaHex": node.get("targetVaHex"),
                        "frameGateSequence": [
                            {"spriteHex": "0x1a", "frame": int(frame), "gate": 5, "label": f"{frame}@5"}
                            for frame in direct_frames
                        ],
                        "durationGate": len(direct_frames) * 5,
                        "loop": False,
                        "nodeClass": node.get("primaryClass"),
                        "tags": node.get("tags") or [],
                        "index": len(scripts),
                    }
                )
    return scripts


def compact_branch_row(row: dict[str, Any]) -> dict[str, Any]:
    target = row.get("targetVaHex") or row.get("branchTargetVaHex")
    branch_va = row.get("vaHex")
    is_backward = False
    try:
        if target and branch_va:
            is_backward = int(str(target), 16) < int(str(branch_va), 16)
    except ValueError:
        is_backward = False
    return {
        "vaHex": branch_va,
        "opcode": row.get("opcode"),
        "comparison": row.get("comparison"),
        "leftSource": row.get("leftSource"),
        "rightSource": row.get("rightSource"),
        "targetVaHex": target,
        "layout": row.get("op14Layout") or row.get("layout"),
        "isBackwardLoop": is_backward,
        "summary": row.get("summary") or row.get("op14Condition"),
    }


def va_int(value: Any) -> int | None:
    if value is None:
        return None
    try:
        return int(str(value), 16)
    except ValueError:
        return None


def local_frame_label(frame: dict[str, Any]) -> str:
    selector = frame.get("selectorHighWord")
    gate = frame.get("gate")
    if selector is None:
        return ""
    return f"{selector}@{gate}" if gate is not None else str(selector)


def build_display_event_timeline(display: dict[str, Any]) -> dict[str, Any]:
    events: list[dict[str, Any]] = []
    frames = display.get("frames") or []

    for index, frame in enumerate(frames):
        events.append(
            {
                "type": "local-frame",
                "vaHex": frame.get("vaHex"),
                "sortVa": va_int(frame.get("vaHex")),
                "index": index,
                "selector": frame.get("selectorHighWord"),
                "selectorHex": frame.get("selectorHighWordHex"),
                "gate": frame.get("gate"),
                "label": local_frame_label(frame),
            }
        )
    for loop in display.get("repeatLoops") or []:
        events.append(
            {
                "type": "repeat-loop",
                "vaHex": loop.get("vaHex"),
                "sortVa": va_int(loop.get("vaHex")),
                "targetVaHex": loop.get("targetVaHex"),
                "targetSortVa": va_int(loop.get("targetVaHex")),
                "repeatCount": loop.get("repeatCount"),
                "summary": loop.get("summary"),
            }
        )
    for sound_kind, sounds in (("effect-sound", display.get("effectSounds") or []), ("result-sound", display.get("resultSounds") or [])):
        for sound in sounds:
            event = {
                "type": sound_kind,
                "vaHex": sound.get("vaHex"),
                "sortVa": va_int(sound.get("vaHex")),
                "wlkNo": sound.get("wlkNo"),
                "normalWlkNo": sound.get("normalWlkNo"),
                "altWlkNo": sound.get("altWlkNo"),
                "mode": sound.get("mode"),
                "summary": sound.get("summary"),
            }
            if sound_kind == "effect-sound" and sound.get("wlkFileIndex") is not None:
                event["wlkFileIndex"] = sound.get("wlkFileIndex")
            if sound_kind == "effect-sound" and sound.get("effectArgsHex"):
                event["effectArgsHex"] = sound.get("effectArgsHex")
            events.append(event)
    for helper in display.get("helperCalls") or []:
        events.append(
            {
                "type": "helper-call",
                "vaHex": helper.get("vaHex"),
                "sortVa": va_int(helper.get("vaHex")),
                "helperId": helper.get("helperId"),
                "helperIdHex": helper.get("helperIdHex"),
                "summary": helper.get("summary"),
            }
        )
    for wait in display.get("waitBarriers") or []:
        events.append(
            {
                "type": "wait-barrier",
                "vaHex": wait.get("vaHex"),
                "sortVa": va_int(wait.get("vaHex")),
                "opcode": wait.get("opcode"),
                "maskHex": wait.get("maskHex"),
                "summary": wait.get("summary"),
            }
        )
    for flag in display.get("actorFlags") or []:
        events.append(
            {
                "type": "actor-flag",
                "vaHex": flag.get("vaHex"),
                "sortVa": va_int(flag.get("vaHex")),
                "mode": flag.get("mode"),
                "maskHex": flag.get("maskHex"),
                "summary": flag.get("summary"),
            }
        )
    for movement in display.get("movements") or []:
        events.append(
            {
                "type": "movement",
                "vaHex": movement.get("vaHex"),
                "sortVa": va_int(movement.get("vaHex")),
                "movementMode": movement.get("movementMode"),
                "motionMode": movement.get("motionMode"),
                "motionKind": movement.get("motionKind"),
                "selector": movement.get("selector"),
                "selectorHex": movement.get("selectorHex"),
                "divisor": movement.get("divisor"),
                "stepDivisor": movement.get("stepDivisor"),
                "targetRangePolicy": movement.get("targetRangePolicy"),
                "selectorMeaning": movement.get("selectorMeaning"),
                "kind1Formula": movement.get("kind1Formula"),
                "kind2Formula": movement.get("kind2Formula"),
                "handlerEvidence": movement.get("handlerEvidence"),
                "summary": movement.get("summary"),
            }
        )
    for write in display.get("positionWrites") or []:
        events.append(
            {
                "type": "position-write",
                "vaHex": write.get("vaHex"),
                "sortVa": va_int(write.get("vaHex")),
                "summary": write.get("summary"),
            }
        )

    events.sort(key=lambda event: (event["sortVa"] is None, event["sortVa"] or 0, event["type"]))
    for event in events:
        event.pop("sortVa", None)
        event.pop("targetSortVa", None)

    frame_lookup = [(va_int(frame.get("vaHex")), frame) for frame in frames]
    frame_by_va = {frame_va: frame for frame_va, frame in frame_lookup if frame_va is not None}
    frame_index_by_va = {frame_va: index for index, (frame_va, _frame) in enumerate(frame_lookup) if frame_va is not None}
    loop_bodies: list[dict[str, Any]] = []
    loop_body_by_va: dict[str, dict[str, Any]] = {}
    for loop in display.get("repeatLoops") or []:
        loop_va = va_int(loop.get("vaHex"))
        target_va = va_int(loop.get("targetVaHex"))
        repeat_count = int(loop.get("repeatCount") or 0)
        body = [
            frame
            for frame_va, frame in frame_lookup
            if frame_va is not None
            and target_va is not None
            and loop_va is not None
            and target_va <= frame_va < loop_va
        ]
        body_summary = [local_frame_label(frame) for frame in body]
        loop_bodies.append(
            {
                "vaHex": loop.get("vaHex"),
                "targetVaHex": loop.get("targetVaHex"),
                "repeatCount": repeat_count,
                "bodyFrameCount": len(body),
                "bodyFrameLabels": body_summary,
                "expansionPolicy": "repeatCount is total body executions; append count-1 extra copies at the repeat opcode position",
            }
        )
        loop_body_by_va[loop.get("vaHex")] = {**loop_bodies[-1], "body": body}

    expanded_frames: list[dict[str, Any]] = []
    for event in events:
        if event.get("type") == "local-frame":
            event_va = va_int(event.get("vaHex"))
            frame = frame_by_va.get(event_va) or {
                "vaHex": event.get("vaHex"),
                "selectorHighWord": event.get("selector"),
                "gate": event.get("gate"),
            }
            expanded_frames.append(
                {
                    "source": "initial-pass",
                    "sourceVaHex": frame.get("vaHex"),
                    "frameIndex": frame_index_by_va.get(event_va, event.get("index")),
                    "selector": frame.get("selectorHighWord"),
                    "gate": frame.get("gate"),
                    "label": local_frame_label(frame),
                }
            )
        elif event.get("type") == "repeat-loop":
            loop_body = loop_body_by_va.get(event.get("vaHex")) or {}
            body = loop_body.get("body") or []
            repeat_total_count = int(loop_body.get("repeatCount") or event.get("repeatCount") or 0)
            extra_repeat_count = max(0, repeat_total_count - 1)
            for repeat_iteration in range(1, extra_repeat_count + 1):
                for frame_index, frame in enumerate(body):
                    expanded_frames.append(
                        {
                            "source": "repeat-loop",
                            "repeatSourceVaHex": event.get("vaHex"),
                            "repeatIteration": repeat_iteration,
                            "repeatTotalCount": repeat_total_count,
                            "frameIndex": frame_index,
                            "sourceVaHex": frame.get("vaHex"),
                            "selector": frame.get("selectorHighWord"),
                            "gate": frame.get("gate"),
                            "label": local_frame_label(frame),
                        }
                    )

    return {
        "events": events,
        "loopBodies": loop_bodies,
        "expandedLocalFrames": expanded_frames,
        "expandedLocalFrameLabels": [frame.get("label") for frame in expanded_frames if frame.get("label")],
        "expandedLocalDurationGate": sum(int(frame.get("gate") or 0) for frame in expanded_frames),
        "repeatExpansionPolicy": "candidate schedule; exact wall-clock remains frame-gate calibration, but repeat target/count are EXE-grounded",
    }


def compact_position_motion(row: dict[str, Any] | None) -> dict[str, Any]:
    if not row:
        return {}
    scopes = []
    anchors = []
    branch_rows: list[dict[str, Any]] = []
    for scope in row.get("scopes") or []:
        anchor = scope.get("anchor") or "-"
        anchors.append(anchor)
        scope_branch_rows = [compact_branch_row(branch) for branch in scope.get("branchRows") or []]
        branch_rows.extend(scope_branch_rows)
        scopes.append(
            {
                "scope": scope.get("scope"),
                "anchor": anchor,
                "patterns": scope.get("patterns") or [],
                "coordinateSummary": scope.get("coordinateSummary"),
                "branchRows": scope_branch_rows,
                "branchLoopCount": sum(1 for branch in scope_branch_rows if branch["isBackwardLoop"]),
            }
        )
    unique_anchors = sorted({anchor for anchor in anchors if anchor and anchor != "-"})
    unique_conditions = sorted(
        {
            f"{branch.get('leftSource')} {branch.get('comparison')} {branch.get('rightSource')}"
            for branch in branch_rows
            if branch.get("leftSource") or branch.get("rightSource")
        }
    )
    return {
        "patterns": row.get("patterns") or [],
        "anchors": unique_anchors,
        "scopeSummaries": scopes,
        "fieldCounts": row.get("fieldCounts") or {},
        "motionModeCounts": row.get("motionModeCounts") or {},
        "branchRows": branch_rows,
        "branchRowCount": len(branch_rows),
        "branchLoopCount": sum(1 for branch in branch_rows if branch["isBackwardLoop"]),
        "branchConditions": unique_conditions,
    }


def build_timeline_summary(
    display: dict[str, Any],
    helpers: list[dict[str, Any]],
) -> dict[str, Any]:
    display_timeline = build_display_event_timeline(display)
    helper_preview_scripts = sum(len(helper.get("previewFrameScripts") or []) for helper in helpers)
    helper_preview_frames = sum(
        len(script.get("frameGateSequence") or [])
        for helper in helpers
        for script in helper.get("previewFrameScripts") or []
    )
    helper_branch_rows = sum(
        (helper.get("positionMotion") or {}).get("branchRowCount") or 0
        for helper in helpers
    )
    helper_branch_loops = sum(
        (helper.get("positionMotion") or {}).get("branchLoopCount") or 0
        for helper in helpers
    )
    helper_motion_patterns = sorted(
        {
            pattern
            for helper in helpers
            for pattern in ((helper.get("positionMotion") or {}).get("patterns") or [])
        }
    )
    local_frames = display.get("frameSelectorSequence") or []
    result_wlk = [sound.get("wlkNo") for sound in display.get("resultSounds") or [] if sound.get("wlkNo") is not None]
    effect_wlk = [sound.get("wlkNo") for sound in display.get("effectSounds") or [] if sound.get("wlkNo") is not None]
    return {
        "displayEventCount": len(display_timeline["events"]),
        "localFrameSequence": local_frames,
        "localFrameCount": len(local_frames),
        "expandedLocalFrameCount": len(display_timeline["expandedLocalFrames"]),
        "expandedLocalDurationGate": display_timeline["expandedLocalDurationGate"],
        "expandedLocalFrameLabels": display_timeline["expandedLocalFrameLabels"],
        "repeatLoopCount": len(display.get("repeatLoops") or []),
        "movementCount": len(display.get("movements") or []),
        "positionWriteCount": len(display.get("positionWrites") or []),
        "waitBarrierCount": len(display.get("waitBarriers") or []),
        "helperCount": len(helpers),
        "helperPreviewScriptCount": helper_preview_scripts,
        "helperPreviewFrameCount": helper_preview_frames,
        "helperBranchRowCount": helper_branch_rows,
        "helperBranchLoopCount": helper_branch_loops,
        "helperMotionPatterns": helper_motion_patterns,
        "effectWlkNos": effect_wlk,
        "resultWlkNos": result_wlk,
        "displayTimeline": display_timeline,
    }


def build() -> dict[str, Any]:
    catalog = load_json(CATALOG)
    helper_body = load_json(HELPER_BODY)
    effect_object = load_json(EFFECT_OBJECT)
    spawn_tree = load_json(SPAWN_TREE)
    position_motion = load_json(POSITION_MOTION)
    helper_by_id = {
        int(row.get("helperId")): row
        for row in helper_body.get("helperRows") or []
        if row.get("helperId") is not None
    }
    effect_object_by_id = {
        int(row.get("helperId")): row
        for row in effect_object.get("helperRows") or []
        if row.get("helperId") is not None
    }
    spawn_tree_by_id = {
        int(row.get("helperId")): row
        for row in spawn_tree.get("helperRows") or []
        if row.get("helperId") is not None
    }
    position_motion_by_id = {
        int(row.get("helperId")): row
        for row in position_motion.get("helperRows") or []
        if row.get("helperId") is not None
    }

    class_counter: Counter[str] = Counter()
    visual_counter: Counter[str] = Counter()
    helper_counter: Counter[int] = Counter()
    helper_body_counter: Counter[str] = Counter()
    helper_effect_counter: Counter[str] = Counter()
    status_counter: Counter[str] = Counter()
    family_counter: Counter[str] = Counter()
    scope_counter: Counter[str] = Counter()
    result_sound_counter: Counter[int] = Counter()
    effect_sound_counter: Counter[int] = Counter()
    helper_branch_condition_counter: Counter[str] = Counter()
    helper_motion_pattern_counter: Counter[str] = Counter()
    rows: list[dict[str, Any]] = []
    helper_usage: dict[int, dict[str, Any]] = {}

    for action in catalog.get("actions") or []:
        display = action.get("displayEntry") or {}
        summary = action.get("sharedActionSummary") or {}
        cls = classify_action(action, effect_object_by_id)
        class_counter[cls] += 1
        visual_counter[str(action.get("visualClass") or "")] += 1
        for label in summary.get("statusLabels") or []:
            status_counter[label] += 1
        for label in summary.get("resultFamilyLabels") or []:
            family_counter[label] += 1
        for label in summary.get("targetScopeLabels") or []:
            scope_counter[label] += 1
        for sound in display.get("resultSounds") or []:
            if sound.get("wlkNo") is not None:
                result_sound_counter[int(sound["wlkNo"])] += 1
        for sound in display.get("effectSounds") or []:
            if sound.get("wlkNo") is not None:
                effect_sound_counter[int(sound["wlkNo"])] += 1

        helpers = []
        for call in display.get("helperCalls") or []:
            helper_id = int(call.get("helperId", -1))
            if helper_id < 0:
                continue
            helper_counter[helper_id] += 1
            body = helper_by_id.get(helper_id) or {}
            effect = effect_object_by_id.get(helper_id) or {}
            body_class = str(body.get("bodyClass") or "unknown")
            effect_class = str(effect.get("helperClass") or "unknown")
            preview_scripts = preview_scripts_for_helper(helper_id, effect_object_by_id, spawn_tree_by_id)
            position_motion_summary = compact_position_motion(position_motion_by_id.get(helper_id))
            for condition in position_motion_summary.get("branchConditions") or []:
                helper_branch_condition_counter[condition] += 1
            for pattern in position_motion_summary.get("patterns") or []:
                helper_motion_pattern_counter[pattern] += 1
            helper_body_counter[body_class] += 1
            helper_effect_counter[effect_class] += 1
            helpers.append(
                {
                    "helperId": helper_id,
                    "helperIdHex": call.get("helperIdHex"),
                    "callVaHex": call.get("vaHex"),
                    "bodyClass": body_class,
                    "helperClass": effect_class,
                    "functionVaHex": body.get("functionVaHex"),
                    "childScriptVaHex": body.get("childScriptVaHex"),
                    "spawnNodeCount": effect.get("flatSpawnTargets") and len(effect.get("flatSpawnTargets") or []),
                    "rootFrameScripts": [
                        script.get("targetVaHex")
                        for script in (effect.get("root") or {}).get("frameScripts") or []
                    ],
                    "previewFrameScripts": preview_scripts,
                    "positionMotion": position_motion_summary,
                }
            )
            usage = helper_usage.setdefault(
                helper_id,
                {
                    "helperId": helper_id,
                    "helperIdHex": call.get("helperIdHex"),
                    "bodyClass": body_class,
                    "helperClass": effect_class,
                    "functionVaHex": body.get("functionVaHex"),
                    "childScriptVaHex": body.get("childScriptVaHex"),
                    "count": 0,
                    "branchRowCount": 0,
                    "branchLoopCount": 0,
                    "previewFrameScriptCount": 0,
                    "sampleActions": [],
                },
            )
            usage["count"] += 1
            usage["branchRowCount"] += position_motion_summary.get("branchRowCount") or 0
            usage["branchLoopCount"] += position_motion_summary.get("branchLoopCount") or 0
            usage["previewFrameScriptCount"] += len(preview_scripts)
            if len(usage["sampleActions"]) < 8:
                usage["sampleActions"].append(
                    {
                        "enemyName": action.get("enemyName"),
                        "cns": action.get("cns"),
                        "actionName": action.get("sharedActionName"),
                        "sharedActionIdHex": action.get("sharedActionIdHex"),
                        "visibleSlotHex": action.get("visibleSlotHex"),
                    }
                )

        timeline_summary = build_timeline_summary(display, helpers)
        rows.append(
            {
                "key": action_key(action),
                "enemyName": action.get("enemyName"),
                "cns": action.get("cns"),
                "actorTableIdHex": action.get("actorTableIdHex"),
                "enemyStatIndex": action.get("enemyStatIndex"),
                "sharedActionIdHex": action.get("sharedActionIdHex"),
                "sharedActionName": action.get("sharedActionName"),
                "visibleSlotHex": action.get("visibleSlotHex"),
                "weightPercent": action.get("weightPercent"),
                "targetScopeLabels": summary.get("targetScopeLabels") or [],
                "resultFamilyLabels": summary.get("resultFamilyLabels") or [],
                "statusLabels": summary.get("statusLabels") or [],
                "coefficientTriples": summary.get("coefficientTriples") or [],
                "visualClass": action.get("visualClass"),
                "presentationClass": cls,
                "frameSelectorSequence": display.get("frameSelectorSequence") or [],
                "frameCount": len(display.get("frames") or []),
                "repeatLoopCount": len(display.get("repeatLoops") or []),
                "movementCount": len(display.get("movements") or []),
                "positionWriteCount": len(display.get("positionWrites") or []),
                "resultSounds": display.get("resultSounds") or [],
                "effectSounds": display.get("effectSounds") or [],
                "waitBarriers": display.get("waitBarriers") or [],
                "helpers": helpers,
                "timelineSummary": timeline_summary,
                "displayPointerVaHex": display.get("pointerVaHex"),
                "displayMatchStatus": action.get("displayMatchStatus"),
                "choiceWeightKind": action.get("choiceWeightKind"),
            }
        )

    helper_usage_rows = sorted(
        helper_usage.values(),
        key=lambda row: (-int(row["count"]), int(row["helperId"])),
    )

    static_conclusions = [
        "몬스터 행동은 descriptor VM의 +0x59 shared action id와 +0x5a visible slot으로 분리되어 있다.",
        "visible slot은 display phase 0x0a+slot으로 CNS local display script에 연결된다.",
        "display script에는 frame selector뿐 아니라 0xbd helper call, 0x24 effect sound, 0xc2 result sound, repeat loop, movement/position write가 함께 들어 있다.",
        "따라서 프레임만 재생 가능한 행동과 정적 helper/effect graph가 필요한 행동을 분리해야 한다.",
        "helper가 붙은 몬스터 행동은 helper body/function, child script, spawn-tree/frameScript까지 정적으로 연결된다. 남은 작업은 브라우저 실행 모델이다.",
        "helper 내부의 0x13/0x14/0x15는 이제 child motion write가 아니라 조건 분기/반복 제어로 분리된다. 몬스터 행동별 반복 조건과 backward branch 수를 보존한다.",
        "이 리뷰는 기존 EXE 정적 디코드 산출물만 소비하며 새 Wine/runtime 관찰을 사용하지 않는다.",
    ]

    helper_runner_rows = (
        class_counter["damage-helper-effect-static-decoded"]
        + class_counter["support-helper-effect-static-decoded"]
        + class_counter["damage-needs-helper-effect-runner"]
        + class_counter["support-needs-helper-effect-runner"]
    )
    known_gaps = [
        {
            "id": "helper-script-runtime-model",
            "label": "정적 helper graph를 브라우저에서 실행하는 모델",
            "count": helper_runner_rows,
            "note": "helper id, body/function, child script, spawn-tree/frameScript는 정적으로 연결됐지만 렌더 좌표/수명/부모-자식 transform을 브라우저 runner에 반영해야 한다.",
        },
        {
            "id": "recovery-frameless-display",
            "label": "회복 계열 local frame 없는 행동",
            "count": class_counter["recovery-no-local-frame"],
            "note": "공격 프레임이 없는 것이 오류가 아니라 지원/회복 계열 표시 정책으로 처리해야 한다.",
        },
        {
            "id": "gate-to-wall-clock",
            "label": "gate/wait를 브라우저 ms로 환산하는 미세 타이밍",
            "count": sum(1 for row in rows if row["waitBarriers"]),
            "note": "정적 gate와 wait barrier는 보존되어 있으나 wall-clock 속도는 프리뷰 정책값이다.",
        },
    ]

    return {
        "version": 1,
        "kind": "hwanse-battle-monster-action-effect-review",
        "title": "몬스터 행동 표시/이펙트 정적 리뷰",
        "status": "static-monster-action-presentation-effect-coverage",
        "runtimeUsed": False,
        "source": str(Path(__file__).relative_to(ROOT)),
        "updatedAt": datetime.now(timezone.utc).isoformat(),
        "inputs": [
            str(CATALOG.relative_to(ROOT)),
            str(HELPER_BODY.relative_to(ROOT)),
            str(EFFECT_OBJECT.relative_to(ROOT)),
            str(SPAWN_TREE.relative_to(ROOT)),
            str(POSITION_MOTION.relative_to(ROOT)),
        ],
        "summary": {
            "actionRows": len(rows),
            "presentationClassCounts": compact_counter(class_counter),
            "visualClassCounts": compact_counter(visual_counter),
            "helperActionRows": sum(1 for row in rows if row["helpers"]),
            "helperCallCount": sum(helper_counter.values()),
            "uniqueHelperIds": len(helper_counter),
            "helperBodyClassCounts": compact_counter(helper_body_counter),
            "helperEffectClassCounts": compact_counter(helper_effect_counter),
            "resultSoundActionRows": sum(1 for row in rows if row["resultSounds"]),
            "effectSoundActionRows": sum(1 for row in rows if row["effectSounds"]),
            "repeatLoopActionRows": sum(1 for row in rows if row["repeatLoopCount"]),
            "repeatExpandedActionRows": sum(
                1
                for row in rows
                if row["timelineSummary"]["expandedLocalFrameCount"] > row["timelineSummary"]["localFrameCount"]
            ),
            "movementActionRows": sum(1 for row in rows if row["movementCount"] or row["positionWriteCount"]),
            "helperBranchActionRows": sum(1 for row in rows if row["timelineSummary"]["helperBranchRowCount"]),
            "helperBranchLoopActionRows": sum(1 for row in rows if row["timelineSummary"]["helperBranchLoopCount"]),
            "helperPreviewFrameScriptRows": sum(1 for row in rows if row["timelineSummary"]["helperPreviewScriptCount"]),
            "helperPreviewFrameRows": sum(1 for row in rows if row["timelineSummary"]["helperPreviewFrameCount"]),
            "statusLabelCounts": compact_counter(status_counter),
            "resultFamilyLabelCounts": compact_counter(family_counter),
            "targetScopeLabelCounts": compact_counter(scope_counter),
            "topResultWlkNos": compact_counter(result_sound_counter)[:20],
            "topEffectWlkNos": compact_counter(effect_sound_counter)[:20],
            "topHelperBranchConditions": compact_counter(helper_branch_condition_counter)[:20],
            "topHelperMotionPatterns": compact_counter(helper_motion_pattern_counter)[:20],
        },
        "staticConclusions": static_conclusions,
        "knownGaps": known_gaps,
        "helperUsageRows": helper_usage_rows,
        "rows": rows,
    }


def write_html(data: dict[str, Any]) -> str:
    css = """
    :root{color-scheme:light;--bg:#f5f6f8;--fg:#17202a;--muted:#667482;--line:#d9e0e8;--panel:#fff;--head:#eef3f7;--link:#185abc;--good:#0f766e;--warn:#9a5b00}
    *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.48 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
    main{max-width:1480px;margin:0 auto;padding:18px}header{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;margin-bottom:14px}
    h1{margin:0 0 6px;font-size:24px;letter-spacing:0}h2{margin:0;font-size:17px;letter-spacing:0}.muted{color:var(--muted)}
    nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}
    nav a,.tag{display:inline-flex;align-items:center;min-height:28px;padding:3px 8px;border:1px solid var(--line);border-radius:5px;background:var(--panel);font-size:13px}
    section{margin:14px 0;background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}.section-head{padding:12px 14px;border-bottom:1px solid var(--line);background:var(--head);display:flex;justify-content:space-between;gap:12px}
    .body{padding:14px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}.metric{border:1px solid var(--line);border-radius:6px;padding:10px;background:#fafcff}.metric strong{display:block;font-size:22px}.metric span{color:var(--muted)}
    table{width:100%;border-collapse:collapse}th,td{border:1px solid var(--line);padding:6px 7px;vertical-align:top}th{position:sticky;top:0;background:var(--head);z-index:1;text-align:left}
    code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.scroll{max-height:680px;overflow:auto}.ok{color:var(--good);font-weight:700}.warn{color:var(--warn);font-weight:700}
    .chips{display:flex;gap:4px;flex-wrap:wrap}.chip{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:1px 7px;background:#f8fafc;font-size:12px}
    """
    s = data["summary"]

    def chips(values: list[Any]) -> str:
        return "<span class='chips'>" + "".join(f"<span class='chip'>{esc(v)}</span>" for v in values) + "</span>"

    def counter_table(rows: list[dict[str, Any]]) -> str:
        return "".join(f"<tr><td>{esc(row['value'])}</td><td>{esc(row['count'])}</td></tr>" for row in rows)

    class_rows = counter_table(s["presentationClassCounts"])
    body_rows = counter_table(s["helperBodyClassCounts"])
    branch_condition_rows = counter_table(s["topHelperBranchConditions"])
    motion_pattern_rows = counter_table(s["topHelperMotionPatterns"])
    gaps = "".join(
        f"<tr><td><code>{esc(row['id'])}</code></td><td>{esc(row['label'])}</td><td>{esc(row['count'])}</td><td>{esc(row['note'])}</td></tr>"
        for row in data["knownGaps"]
    )
    conclusions = "".join(f"<li>{esc(item)}</li>" for item in data["staticConclusions"])
    helper_rows_parts = []
    for row in data["helperUsageRows"]:
        samples = "<br>".join(
            esc(f"{sample['enemyName']} {sample['actionName']} {sample['visibleSlotHex']}")
            for sample in row["sampleActions"]
        )
        helper_rows_parts.append(
            "<tr>"
            f"<td><code>{esc(row['helperIdHex'])}</code></td>"
            f"<td>{esc(row['count'])}</td>"
            f"<td>{esc(row['bodyClass'])}</td>"
            f"<td>branch {esc(row.get('branchRowCount', 0))} / loop {esc(row.get('branchLoopCount', 0))}<br>preview scripts {esc(row.get('previewFrameScriptCount', 0))}</td>"
            f"<td><code>{esc(row['functionVaHex'])}</code><br><code>{esc(row['childScriptVaHex'])}</code></td>"
            f"<td>{samples}</td>"
            "</tr>"
        )
    helper_rows = "".join(helper_rows_parts)
    action_rows = "".join(
        (lambda timeline:
        "<tr>"
        f"<td>{esc(row['enemyName'])}<br><code>{esc(row['cns'])}</code></td>"
        f"<td>{esc(row['sharedActionName'])}<br><code>{esc(row['sharedActionIdHex'])}</code> slot <code>{esc(row['visibleSlotHex'])}</code></td>"
        f"<td>{esc(row['presentationClass'])}<br><span class='muted'>{esc(row['visualClass'])}</span></td>"
        f"<td>{chips(row['targetScopeLabels'])}<br>{chips(row['resultFamilyLabels'])}<br>{chips(row['statusLabels'])}</td>"
        f"<td>{esc(row['frameSelectorSequence'])}<br>frames {esc(row['frameCount'])} -> {esc(timeline['expandedLocalFrameCount'])}, gate {esc(timeline['expandedLocalDurationGate'])}, repeat {esc(row['repeatLoopCount'])}"
        f"<br><span class='muted'>{esc(' '.join((timeline.get('expandedLocalFrameLabels') or [])[:18]))}</span></td>"
        f"<td>{'<br>'.join(esc(h['helperIdHex'] + ' ' + h['bodyClass']) for h in row['helpers']) or '-'}"
        f"<br><span class='muted'>preview {esc(timeline['helperPreviewScriptCount'])}/{esc(timeline['helperPreviewFrameCount'])}, branch {esc(timeline['helperBranchRowCount'])}, loop {esc(timeline['helperBranchLoopCount'])}</span>"
        f"<br>{chips(timeline['helperMotionPatterns'][:5]) if timeline['helperMotionPatterns'] else ''}</td>"
        f"<td>{esc([x.get('wlkNo') for x in row['effectSounds']])}<br>{esc([x.get('wlkNo') for x in row['resultSounds']])}</td>"
        "</tr>"
        )(row.get("timelineSummary") or {})
        for row in data["rows"]
    )
    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>몬스터 행동 표시/이펙트 정적 리뷰</title>
  <style>{css}</style>
</head>
<body>
<main>
  <header>
    <div>
      <h1>몬스터 행동 표시/이펙트 정적 리뷰</h1>
      <div class="muted">EXE 정적 분석 산출물만 사용. 런타임/Wine 관찰 없음.</div>
    </div>
    <nav>
      <a href="../web/index.html">홈</a>
      <a href="../web/battle_analysis.html">전투 분석</a>
      <a href="../web/battle_simulator.html">전투 샌드박스</a>
      <a href="battle_monster_action_effect_review.json">JSON</a>
    </nav>
  </header>
  <section>
    <div class="section-head"><h2>요약</h2><span class="tag">{esc(data['status'])}</span></div>
    <div class="body grid">
      <div class="metric"><strong>{esc(s['actionRows'])}</strong><span>monster action rows</span></div>
      <div class="metric"><strong>{esc(s['helperActionRows'])}</strong><span>actions with helper calls</span></div>
      <div class="metric"><strong>{esc(s['uniqueHelperIds'])}</strong><span>unique helper ids</span></div>
      <div class="metric"><strong>{esc(s['repeatLoopActionRows'])}</strong><span>actions with repeat loop</span></div>
      <div class="metric"><strong>{esc(s['repeatExpandedActionRows'])}</strong><span>actions with expanded local frames</span></div>
      <div class="metric"><strong>{esc(s['movementActionRows'])}</strong><span>actions with movement/position writes</span></div>
      <div class="metric"><strong>{esc(s['helperBranchActionRows'])}</strong><span>actions with helper branch control</span></div>
      <div class="metric"><strong>{esc(s['helperPreviewFrameScriptRows'])}</strong><span>actions with helper frameScript preview</span></div>
      <div class="metric"><strong>{esc(s['effectSoundActionRows'])}/{esc(s['resultSoundActionRows'])}</strong><span>effect/result sound action rows</span></div>
    </div>
  </section>
  <section><div class="section-head"><h2>정적 결론</h2></div><div class="body"><ul>{conclusions}</ul></div></section>
  <section>
    <div class="section-head"><h2>분류/남은 구현 갭</h2></div>
    <div class="body grid">
      <table><thead><tr><th>presentation class</th><th>count</th></tr></thead><tbody>{class_rows}</tbody></table>
      <table><thead><tr><th>helper body class</th><th>count</th></tr></thead><tbody>{body_rows}</tbody></table>
      <table><thead><tr><th>helper branch condition</th><th>count</th></tr></thead><tbody>{branch_condition_rows}</tbody></table>
      <table><thead><tr><th>helper motion pattern</th><th>count</th></tr></thead><tbody>{motion_pattern_rows}</tbody></table>
    </div>
    <div class="body"><table><thead><tr><th>id</th><th>항목</th><th>count</th><th>note</th></tr></thead><tbody>{gaps}</tbody></table></div>
  </section>
  <section><div class="section-head"><h2>Helper 사용</h2></div><div class="body scroll"><table><thead><tr><th>helper</th><th>count</th><th>body class</th><th>branch/preview</th><th>function/script</th><th>sample actions</th></tr></thead><tbody>{helper_rows}</tbody></table></div></section>
  <section><div class="section-head"><h2>몬스터 행동 상세</h2></div><div class="body scroll"><table><thead><tr><th>monster</th><th>action</th><th>presentation</th><th>payload</th><th>local frames</th><th>helper timeline</th><th>effect/result WLK</th></tr></thead><tbody>{action_rows}</tbody></table></div></section>
</main>
</body>
</html>
"""


def main() -> None:
    data = build()
    OUT.mkdir(exist_ok=True)
    (OUT / "battle_monster_action_effect_review.json").write_text(
        json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    (OUT / "battle_monster_action_effect_review.html").write_text(write_html(data), encoding="utf-8")


if __name__ == "__main__":
    main()
