#!/usr/bin/env python3
"""Decode child display scripts spawned by 0xbd battle helpers.

The first battle helper promotion identified the dispatch table and function
bodies behind opcode 0xbd.  Those helper bodies usually spawn a new display
object and attach a child script VA from the table at 0x442da1.  This report
walks those child scripts with the original display-VM opcode lengths,
including the variable-length 0x08 init block that was not covered by the
actor-only skill decoder.
"""
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,
    OUT,
    decode_instruction,
    hex32,
    hex8,
    pointer_to_static,
    read_bytes,
    read_sections,
    s32,
    u16,
    u32,
)


HELPER_BODY_JSON = OUT / "battle_helper_body_review.json"
DISPLAY_HANDLER_TABLE_VA = 0x00440538


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


def field_name(offset: int | None) -> str:
    names = {
        0x00: "display.flags",
        0x14: "display.kind/priority",
        0x1C: "display.x",
        0x20: "display.y",
        0x28: "display.spriteFrame",
        0x2C: "display.timerOrCounter",
        0x40: "display.scriptCursor",
        0x58: "display.child/randomTemp",
        0x5E: "display.waitCounter",
        0x62: "display.frameGate",
        0x64: "display.frameScript",
        0x74: "motion.dx",
        0x78: "motion.dy",
        0x80: "motion.targetX",
        0x84: "motion.targetY",
        0x8C: "motion.paramX",
        0x90: "motion.paramY",
        0x92: "display.sizeOrEffectW",
        0x94: "display.sizeOrEffectH",
        0x96: "motion.stepCount",
        0xA0: "display.parentObject",
        0xA4: "display.childObject",
        0xA8: "display.parentActor",
    }
    if offset is None:
        return "-"
    return f"{names.get(offset, 'field')} +0x{offset:02x}"


def fixed16(value: int | None) -> float | None:
    signed = s32(value)
    if signed is None:
        return None
    return signed / 65536


def frame_from_selector(selector: int | None) -> dict[str, Any] | None:
    if selector is None:
        return None
    return {
        "sprite": (selector >> 16) & 0xFFFF,
        "spriteHex": hex8((selector >> 16) & 0xFFFF),
        "frame": selector & 0xFFFF,
        "selectorHex": hex32(selector),
    }


def decode_init_block(raw: bytes) -> tuple[int, list[dict[str, Any]], str]:
    """Decode opcode 0x08's internal write-list until the 0xff terminator."""
    pos = 4
    writes: list[dict[str, Any]] = []
    stop = "unterminated"
    while pos < min(len(raw), 512):
        subop = raw[pos]
        if subop == 0xFF:
            writes.append(
                {
                    "subop": "0xff",
                    "offset": pos,
                    "length": 4,
                    "category": "init-end",
                    "summary": "0x08 init terminator",
                }
            )
            pos += 4
            stop = "terminator"
            break
        if subop == 0x01 and pos + 4 <= len(raw):
            dest = raw[pos + 1]
            value = raw[pos + 2]
            writes.append(
                {
                    "subop": "0x01",
                    "offset": pos,
                    "length": 4,
                    "category": "byte-write",
                    "dest": dest,
                    "destHex": hex8(dest),
                    "destLabel": field_name(dest),
                    "value": value,
                    "valueHex": hex8(value),
                    "summary": f"byte {field_name(dest)} = {hex8(value)}",
                }
            )
            pos += 4
            continue
        if subop == 0x02 and pos + 4 <= len(raw):
            dest = raw[pos + 1]
            value = u16(raw, pos + 2)
            writes.append(
                {
                    "subop": "0x02",
                    "offset": pos,
                    "length": 4,
                    "category": "word-write",
                    "dest": dest,
                    "destHex": hex8(dest),
                    "destLabel": field_name(dest),
                    "value": value,
                    "valueHex": f"0x{value:04x}" if value is not None else "",
                    "summary": f"word {field_name(dest)} = {f'0x{value:04x}' if value is not None else '-'}",
                }
            )
            pos += 4
            continue
        if subop == 0x03 and pos + 8 <= len(raw):
            dest = raw[pos + 1]
            value = u32(raw, pos + 4)
            row: dict[str, Any] = {
                "subop": "0x03",
                "offset": pos,
                "length": 8,
                "category": "dword-write",
                "dest": dest,
                "destHex": hex8(dest),
                "destLabel": field_name(dest),
                "value": value,
                "valueHex": hex32(value),
                "valueFixed": fixed16(value),
                "summary": f"dword {field_name(dest)} = {hex32(value)}",
            }
            if dest == 0x28:
                frame = frame_from_selector(value)
                row["frame"] = frame
                if frame:
                    row["summary"] += f" / sprite {frame['spriteHex']} frame {frame['frame']}"
            writes.append(row)
            pos += 8
            continue
        writes.append(
            {
                "subop": hex8(subop),
                "offset": pos,
                "length": 4,
                "category": "unknown-init-subop",
                "summary": f"unknown init subop {hex8(subop)}",
            }
        )
        pos += 4
    return pos, writes, stop


def child_instruction_length(raw: bytes) -> int:
    if not raw:
        return 0
    op = raw[0]
    if op == 0x08:
        length, _writes, _stop = decode_init_block(raw)
        return length
    if op == 0x04:
        return 8
    if op == 0x05:
        return 4
    if op == 0x0A:
        count = raw[2] if len(raw) > 2 else 0
        return 4 + count * 4
    if op == 0x15:
        mode = raw[1] if len(raw) > 1 else 0
        return 12 if (mode & 0x30) == 0 else 8
    if op in {0x2D, 0x40}:
        return 4
    if op in {0x00, 0x01, 0x02, 0x1E, 0x1F, 0x2B, 0x42, 0xFF}:
        return 4
    if op == 0x20:
        return 8
    if op in {0x38, 0x39, 0x3F}:
        return 8
    if op in {0x3A, 0x3B, 0x3C, 0x3D, 0x3E}:
        return 4
    base = decode_instruction(raw, 0).get("length") or 0
    return int(base)


def decode_child_instruction(raw: bytes, va: int) -> dict[str, Any]:
    op = raw[0] if raw else None
    length = child_instruction_length(raw)
    row: dict[str, Any] = {
        "va": va,
        "vaHex": hex32(va),
        "opcode": hex8(op),
        "length": length,
        "bytes": raw[: max(length, 1)].hex(" "),
        "category": "unknown",
        "summary": raw[: max(length, 1)].hex(" "),
        "nextMode": "fallthrough",
    }
    if op is None:
        return row
    if op == 0x00:
        row.update(category="destroy/end", summary="destroy/free display object candidate", nextMode="stop")
    elif op == 0x01:
        row.update(category="yield", summary="yield one VM tick and advance")
    elif op == 0x02:
        wait = u16(raw, 2)
        row.update(category="countdown-wait", waitFrames=wait, summary=f"countdown wait frames={wait}")
    elif op == 0x04:
        target = pointer_to_static(u32(raw, 4))
        row.update(
            category="call-subscript",
            targetVa=target,
            targetVaHex=hex32(target),
            summary=f"call child subscript {hex32(target)} and keep return cursor",
            nextMode="call-subscript-fallthrough-preview",
        )
    elif op == 0x05:
        row.update(category="return-subscript", summary="return from child subscript stack", nextMode="stop")
    elif op == 0x08:
        length, writes, stop = decode_init_block(raw)
        init_frames = [write["frame"] for write in writes if write.get("frame")]
        row.update(
            category="init-block",
            length=length,
            bytes=raw[:length].hex(" "),
            initWrites=writes,
            initStop=stop,
            initFrames=init_frames,
            summary=f"variable init block writes={len(writes)} stop={stop}",
        )
    elif op == 0x0A:
        index_field = raw[1] if len(raw) > 1 else None
        count = raw[2] if len(raw) > 2 else 0
        targets = []
        for idx in range(count):
            target = pointer_to_static(u32(raw, 4 + idx * 4))
            targets.append({"index": idx, "targetVa": target, "targetVaHex": hex32(target)})
        row.update(
            category="indexed-jump-table",
            indexField=index_field,
            indexFieldHex=hex8(index_field),
            indexFieldLabel=field_name(index_field),
            tableCount=count,
            targets=targets,
            summary=f"jump table by {field_name(index_field)} count={count}; first target {targets[0]['targetVaHex'] if targets else '-'}",
            nextMode="indexed-jump-dynamic",
            staticPathChoice="first-target",
        )
    elif op == 0x1F:
        row.update(category="link-child-parent", summary="link current display object with object in +0x58 (+0xa4/+0xa0)")
    elif op == 0x1E:
        row.update(
            category="child-control-noop",
            summary="4-byte child control/no-op barrier; observed as 1e 00 00 00 before normal continuation",
        )
    elif op == 0x20:
        target = pointer_to_static(u32(raw, 4))
        row.update(
            category="frame-script-pointer",
            targetVa=target,
            targetVaHex=hex32(target),
            summary=f"set display.frameScript(+0x64) = {hex32(target)}",
        )
    elif op == 0x2B:
        random_range = u16(raw, 2)
        row.update(
            category="random-range",
            randomRange=random_range,
            randomRangeHex=f"0x{random_range:04x}" if random_range is not None else "",
            rngHandlerVaHex="0x00427730",
            summary=f"rng(range={random_range}) -> +0x58",
        )
    elif op == 0x42:
        mode = raw[1] if len(raw) > 1 else None
        target_slot = raw[2] if len(raw) > 2 else None
        if mode == 0:
            summary = "set parent actor pointer from this display object's actor slot"
        elif mode == 1:
            summary = f"set parent actor pointer from explicit battle actor slot {target_slot}"
        else:
            summary = f"set parent actor pointer mode={hex8(mode)} target={hex8(target_slot)}"
        row.update(category="parent-actor", mode=mode, targetSlot=target_slot, summary=summary)
    elif op == 0x15:
        mode = raw[1] if len(raw) > 1 else None
        left = raw[2] if len(raw) > 2 else None
        right = raw[3] if len(raw) > 3 else None
        immediate = (mode or 0) & 0x30 == 0
        imm = u32(raw, 4) if immediate else None
        target_offset = 8 if immediate else 4
        raw_target = u32(raw, target_offset)
        target = pointer_to_static(raw_target)
        cmp_op = (mode or 0) & 0x0F
        cmp_name = {
            0x00: "!=",
            0x01: "==",
            0x02: ">",
            0x03: "<",
            0x04: ">=",
            0x05: "<=",
            0x06: "bit-test",
        }.get(cmp_op, f"cmp{cmp_op:x}")
        row.update(
            category="conditional-branch",
            mode=mode,
            modeHex=hex8(mode),
            left=left,
            leftHex=hex8(left),
            leftLabel=field_name(left),
            right=right,
            rightHex=hex8(right),
            immediate=immediate,
            imm=imm,
            immHex=hex32(imm),
            branchTargetOffset=target_offset,
            rawTargetDwordHex=hex32(raw_target),
            targetVa=target,
            targetVaHex=hex32(target),
            comparison=cmp_name,
            summary=(
                f"conditional branch {field_name(left)} {cmp_name} "
                f"{hex32(imm) if immediate else field_name(right)}; target {hex32(target)}"
            ),
            nextMode="conditional-fallthrough-preview",
        )
    elif op == 0x2D:
        mode = raw[1] if len(raw) > 1 else None
        group = (mode or 0) & 0xF8
        axes = []
        if (mode or 0) & 0x01:
            axes.append("x")
        if (mode or 0) & 0x02:
            axes.append("y")
        if (mode or 0) & 0x04:
            axes.append("z")
        basis = "absolute base + trig" if group == 0x08 else "relative trig step" if group == 0x00 else f"group {hex8(group)}"
        row.update(
            category="motion-step",
            mode=mode,
            modeHex=hex8(mode),
            modeGroup=group,
            modeGroupHex=hex8(group),
            axes=axes,
            summary=f"{basis} motion step mode={hex8(mode)} axes={'+'.join(axes) or '-'}",
        )
    elif op == 0x40:
        row.update(
            category="global-parent-anchor",
            summary="set display.parentActor(+0xa8) to global anchor 0x0059e310",
        )
    elif op == 0xFF:
        row.update(category="noop/terminator", summary="default no-op/terminator byte outside 0x08 init")
    else:
        decoded = decode_instruction(raw, 0)
        row.update(decoded)
        row["va"] = va
        row["vaHex"] = hex32(va)
        row["length"] = length
        row["bytes"] = raw[: max(length, 1)].hex(" ")
    return row


def annotate_control_targets(rows: list[dict[str, Any]], data: bytes, sections: list[dict[str, Any]]) -> None:
    """Classify branch/call targets without changing the linear preview walk."""
    index_by_va = {int(row["va"]): index for index, row in enumerate(rows) if isinstance(row.get("va"), int)}

    def target_summary(row: dict[str, Any], relation: str) -> str:
        return str(row.get("summary") or "").replace("unresolved", relation)

    def classify(current: dict[str, Any], target: int | None) -> dict[str, Any]:
        if not isinstance(target, int):
            return {
                "branchTargetRelation": "no-static-target",
                "branchTargetDistance": None,
                "branchTargetCategory": "",
                "branchTargetOpcode": "",
                "branchTargetSummary": "",
            }
        current_index = index_by_va.get(int(current.get("va", -1)))
        target_index = index_by_va.get(target)
        if target_index is not None and current_index is not None:
            delta = target_index - current_index
            relation = "local-self" if delta == 0 else "local-backward-loop" if delta < 0 else "local-forward-skip"
            target_row = rows[target_index]
            return {
                "branchTargetRelation": relation,
                "branchTargetDistance": delta,
                "branchTargetCategory": target_row.get("category") or "",
                "branchTargetOpcode": target_row.get("opcode") or "",
                "branchTargetSummary": target_summary(target_row, relation),
            }
        raw = read_bytes(data, sections, target, 512)
        if not raw:
            return {
                "branchTargetRelation": "external-unreadable",
                "branchTargetDistance": None,
                "branchTargetCategory": "",
                "branchTargetOpcode": "",
                "branchTargetSummary": "",
            }
        target_row = decode_child_instruction(raw, target)
        return {
            "branchTargetRelation": "external-static-target",
            "branchTargetDistance": None,
            "branchTargetCategory": target_row.get("category") or "",
            "branchTargetOpcode": target_row.get("opcode") or "",
            "branchTargetSummary": target_summary(target_row, "external-static-target"),
        }

    for row in rows:
        if row.get("category") in {"branch", "conditional-branch", "call-subscript"}:
            row.update(classify(row, row.get("targetVa")))
            if row.get("category") == "branch" and row.get("nextMode") == "branch-unresolved":
                row["nextMode"] = "generic-branch-fallthrough-preview"
                row["summary"] = str(row.get("summary") or "").replace("unresolved", row.get("branchTargetRelation") or "classified")
        elif row.get("category") == "indexed-jump-table":
            annotated = []
            for item in row.get("targets") or []:
                annotated.append({**item, **classify(row, item.get("targetVa"))})
            row["targets"] = annotated
            row["branchTargetRelation"] = ",".join(
                sorted(set(item.get("branchTargetRelation") for item in annotated if item.get("branchTargetRelation")))
            )
            row["branchTargetSummary"] = "; ".join(
                f"{item.get('targetVaHex')}:{item.get('branchTargetCategory')}"
                for item in annotated[:6]
            )


def walk_child_script(data: bytes, sections: list[dict[str, Any]], start_va: int, max_steps: int = 220) -> dict[str, Any]:
    va = start_va
    rows: list[dict[str, Any]] = []
    visited: set[int] = set()
    stop_reason = "max-steps"
    for _ in range(max_steps):
        if va in visited:
            stop_reason = f"loop at {hex32(va)}"
            break
        visited.add(va)
        raw = read_bytes(data, sections, va, 512)
        if not raw:
            stop_reason = f"unreadable {hex32(va)}"
            break
        row = decode_child_instruction(raw, va)
        rows.append(row)
        length = row.get("length") or 0
        if length <= 0:
            stop_reason = f"unknown opcode {row.get('opcode')} at {hex32(va)}"
            break
        if row.get("nextMode") == "stop":
            stop_reason = f"{row.get('category')} at {hex32(va)}"
            break
        if row.get("nextMode") == "jump":
            target = row.get("targetVa")
            if not isinstance(target, int):
                stop_reason = f"bad jump at {hex32(va)}"
                break
            va = target
            continue
        if row.get("nextMode") == "indexed-jump-dynamic":
            targets = row.get("targets") or []
            target = targets[0].get("targetVa") if targets else None
            if not isinstance(target, int):
                stop_reason = f"bad indexed jump at {hex32(va)}"
                break
            va = target
            continue
        va += int(length)
    annotate_control_targets(rows, data, sections)
    opcode_counts = Counter(row.get("opcode") for row in rows)
    frames = []
    init_frames = []
    init_writes = []
    random_ranges = []
    frame_scripts = []
    spawn_rows = []
    branch_targets = []
    palette_events = []
    waits = []
    parents = []
    sounds = []
    unknowns = []
    for row in rows:
        if row.get("category") == "frame":
            frames.append({"vaHex": row["vaHex"], "spriteHex": row.get("spriteHex"), "frame": row.get("frame"), "gate": row.get("gate")})
        if row.get("category") == "init-block":
            init_frames.extend({"vaHex": row["vaHex"], **frame} for frame in row.get("initFrames") or [])
            init_writes.extend({"vaHex": row["vaHex"], **write} for write in row.get("initWrites") or [])
        if row.get("category") == "random-range":
            random_ranges.append(
                {
                    "vaHex": row["vaHex"],
                    "randomRange": row.get("randomRange"),
                    "randomRangeHex": row.get("randomRangeHex"),
                }
            )
        if row.get("category") == "frame-script-pointer":
            frame_scripts.append({"vaHex": row["vaHex"], "targetVaHex": row.get("targetVaHex")})
        if row.get("category") == "spawn-child-vm":
            spawn_rows.append(
                {
                    "vaHex": row["vaHex"],
                    "targetVaHex": row.get("targetVaHex"),
                    "childObjectType": row.get("childObjectType"),
                    "summary": row.get("summary"),
                }
            )
        if row.get("category") in {"branch", "conditional-branch", "call-subscript"} and isinstance(row.get("targetVa"), int):
            branch_targets.append(
                {
                    "vaHex": row["vaHex"],
                    "category": row.get("category"),
                    "targetVaHex": row.get("targetVaHex"),
                    "comparison": row.get("comparison"),
                    "relation": row.get("branchTargetRelation"),
                    "distance": row.get("branchTargetDistance"),
                    "targetCategory": row.get("branchTargetCategory"),
                    "targetOpcode": row.get("branchTargetOpcode"),
                    "targetSummary": row.get("branchTargetSummary"),
                    "summary": row.get("summary"),
                }
            )
        if row.get("category") == "indexed-jump-table":
            for item in row.get("targets") or []:
                if isinstance(item.get("targetVa"), int):
                    branch_targets.append(
                        {
                            "vaHex": row["vaHex"],
                            "category": row.get("category"),
                            "targetIndex": item.get("index"),
                            "targetVaHex": item.get("targetVaHex"),
                            "relation": item.get("branchTargetRelation"),
                            "distance": item.get("branchTargetDistance"),
                            "targetCategory": item.get("branchTargetCategory"),
                            "targetOpcode": item.get("branchTargetOpcode"),
                            "targetSummary": item.get("branchTargetSummary"),
                            "summary": row.get("summary"),
                        }
                    )
        if row.get("category") in {"palette-backup", "palette-transform", "palette-restore"}:
            palette_events.append(
                {
                    "vaHex": row["vaHex"],
                    "category": row.get("category"),
                    "paletteStartHex": row.get("paletteStartHex"),
                    "paletteCountHex": row.get("paletteCountHex"),
                    "modeHex": row.get("modeHex"),
                    "argHex": row.get("argHex"),
                    "summary": row.get("summary"),
                }
            )
        if row.get("category") in {"countdown-wait", "yield", "wait"}:
            waits.append({"vaHex": row["vaHex"], "category": row.get("category"), "waitFrames": row.get("waitFrames"), "summary": row.get("summary")})
        if row.get("category") == "parent-actor":
            parents.append({"vaHex": row["vaHex"], "mode": row.get("mode"), "targetSlot": row.get("targetSlot"), "summary": row.get("summary")})
        if row.get("category") in {"sound", "effect-sound"}:
            sounds.append({"vaHex": row["vaHex"], "category": row.get("category"), "wlkNo": row.get("wlkNo"), "summary": row.get("summary")})
        if row.get("category") == "unknown" or (row.get("opcode") and (row.get("length") or 0) <= 0):
            unknowns.append({"vaHex": row["vaHex"], "opcode": row.get("opcode"), "bytes": row.get("bytes")})
    return {
        "startVa": start_va,
        "startVaHex": hex32(start_va),
        "stopReason": stop_reason,
        "instructionCount": len(rows),
        "opcodeCounts": dict(sorted(opcode_counts.items())),
        "frames": frames,
        "frameSequence": [frame.get("frame") for frame in frames],
        "initFrames": init_frames,
        "initFrameSequence": [frame.get("frame") for frame in init_frames],
        "initWrites": init_writes,
        "randomRanges": random_ranges,
        "frameScriptTargets": frame_scripts,
        "spawnRows": spawn_rows,
        "branchTargets": branch_targets,
        "branchTargetRelations": dict(sorted(Counter(item.get("relation") or "unknown" for item in branch_targets).items())),
        "paletteEvents": palette_events,
        "waits": waits,
        "parentActorLinks": parents,
        "sounds": sounds,
        "unknowns": unknowns,
        "rows": rows,
    }


def build() -> dict[str, Any]:
    data = EXE.read_bytes()
    sections = read_sections(data)
    helper_body = json.loads(HELPER_BODY_JSON.read_text(encoding="utf-8"))
    helper_rows = []
    opcode_counter: Counter[str] = Counter()
    class_counter: Counter[str] = Counter()
    unknown_helpers = []
    helpers_with_frames = []
    helpers_with_random_ranges = []
    helpers_with_frame_scripts = []
    branch_relation_counter: Counter[str] = Counter()
    by_skill: defaultdict[str, list[int]] = defaultdict(list)
    for source in helper_body.get("helperRows") or []:
        child_hex = source.get("childScriptVaHex") or ""
        child_va = int(child_hex, 16) if child_hex else None
        decoded = walk_child_script(data, sections, child_va) if child_va else {}
        opcode_counter.update(decoded.get("opcodeCounts") or {})
        class_counter[source.get("bodyClass") or "unknown"] += 1
        if decoded.get("unknowns"):
            unknown_helpers.append(source.get("helperId"))
        if decoded.get("frames") or decoded.get("initFrames"):
            helpers_with_frames.append(source.get("helperId"))
        if decoded.get("randomRanges"):
            helpers_with_random_ranges.append(source.get("helperId"))
        if decoded.get("frameScriptTargets"):
            helpers_with_frame_scripts.append(source.get("helperId"))
        branch_relation_counter.update(decoded.get("branchTargetRelations") or {})
        for skill in source.get("skillRows") or []:
            by_skill[f"{skill.get('ownerName')}::{skill.get('skillName')}"].append(source.get("helperId"))
        helper_rows.append(
            {
                "helperId": source.get("helperId"),
                "functionVaHex": source.get("functionVaHex"),
                "bodyClass": source.get("bodyClass"),
                "childScriptVaHex": child_hex,
                "skillRows": source.get("skillRows") or [],
                "monsterRows": source.get("monsterRows") or [],
                "featureSummary": source.get("featureSummary") or {},
                "decoded": decoded,
            }
        )
    skill_groups = [
        {"skill": key, "helperIds": sorted(set(value))}
        for key, value in sorted(by_skill.items())
    ]
    return {
        "version": 1,
        "kind": "hwanse-battle-helper-child-script-review",
        "source": [
            "Hwanse2.exe",
            "out/battle_helper_body_review.json",
        ],
        "status": "child-display-script-static-decode",
        "runtimeUsed": False,
        "displayHandlerTableVaHex": hex32(DISPLAY_HANDLER_TABLE_VA),
        "summary": {
            "helperRows": len(helper_rows),
            "helpersWithDecodedChildScript": sum(1 for row in helper_rows if row["decoded"]),
            "helpersWithAnyFrameWrite": len(helpers_with_frames),
            "helpersWithRandomRange": len(helpers_with_random_ranges),
            "helpersWithFrameScriptTargets": len(helpers_with_frame_scripts),
            "helpersWithUnknownOpcode": len(unknown_helpers),
            "opcodeCounts": dict(sorted(opcode_counter.items())),
            "bodyClassCounts": dict(sorted(class_counter.items())),
            "branchTargetRelations": dict(sorted(branch_relation_counter.items())),
        },
        "interpretationNotes": [
            "Opcode 0x08 is a variable-length init block. Its handler advances past internal byte/word/dword writes until an 0xff terminator.",
            "Opcode 0x02 is a countdown wait gate using the word at script+2, not an unknown child opcode.",
            "Opcode 0x2b calls RNG helper 0x427730 with the script word as modulo/range and stores the result into display +0x58.",
            "Opcode 0x1f links the current display object and the object stored in +0x58 through +0xa4/+0xa0.",
            "Opcode 0x42 sets display +0xa8 to a parent battle actor pointer. This is important for projectile/effect targeting.",
            "Opcode 0x15 is a conditional branch. Immediate-right forms are 12 bytes because the branch target dword sits after the immediate value.",
            "Opcode 0x0a is an indexed jump table by a display-object byte field. Static review lists all table targets and follows the first target as the linear preview path.",
            "Branch/call targets are now annotated as local-forward-skip, local-backward-loop, local-self, or external-static-target. The annotation does not emulate the condition; it records where control can go.",
            "The report does not yet emulate every side effect, but it separates actor frames from helper child object frames, RNG ranges, frame scripts, and motion/control primitives.",
        ],
        "helperRows": helper_rows,
        "skillGroups": skill_groups,
    }


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


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Helper Child Script Review",
        "",
        f"- status: `{report['status']}`",
        f"- display handler table: `{report['displayHandlerTableVaHex']}`",
        f"- helpers decoded: `{report['summary']['helpersWithDecodedChildScript']}`",
        f"- helpers with frame writes: `{report['summary']['helpersWithAnyFrameWrite']}`",
        f"- helpers with RNG ranges: `{report['summary']['helpersWithRandomRange']}`",
        f"- helpers with unknown opcode: `{report['summary']['helpersWithUnknownOpcode']}`",
        "",
        "## Interpretation",
        "",
    ]
    lines.extend(f"- {note}" for note in report["interpretationNotes"])
    lines.extend(
        [
            "",
            "## Helper Rows",
            "",
            "| id | body | child script | stop | opcodes | init frames | frames | RNG ranges | frame scripts | skills |",
            "| ---: | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
        ]
    )
    for row in report["helperRows"]:
        decoded = row.get("decoded") or {}
        skills = "; ".join(f"{skill.get('ownerName')} {skill.get('skillName')} {skill.get('skillIdHex')}" for skill in row.get("skillRows") or [])
        random_ranges = vec([item.get("randomRange") for item in decoded.get("randomRanges") or []])
        frame_scripts = vec([item.get("targetVaHex") for item in decoded.get("frameScriptTargets") or []])
        lines.append(
            f"| {row['helperId']} | `{row['bodyClass']}` | `{row['childScriptVaHex'] or '-'}` | "
            f"{decoded.get('stopReason', '-')} | {decoded.get('opcodeCounts', {})} | "
            f"{vec(decoded.get('initFrameSequence') or [])} | {vec(decoded.get('frameSequence') or [])} | "
            f"{random_ranges} | {frame_scripts} | {skills} |"
        )
    return "\n".join(lines) + "\n"


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"])
    helper_rows = []
    for row in report["helperRows"]:
        decoded = row.get("decoded") or {}
        skills = "<br>".join(f"{esc(skill.get('ownerName'))} {esc(skill.get('skillName'))} <code>{esc(skill.get('skillIdHex'))}</code>" for skill in row.get("skillRows") or [])
        opcodes = ", ".join(f"{key}:{value}" for key, value in (decoded.get("opcodeCounts") or {}).items()) or "-"
        init_writes = "<br>".join(esc(item.get("summary")) for item in (decoded.get("initWrites") or [])[:18]) or "-"
        instr = "".join(
            "<tr>"
            f"<td><code>{esc(item.get('vaHex'))}</code></td>"
            f"<td><code>{esc(item.get('opcode'))}</code></td>"
            f"<td>{esc(item.get('category'))}</td>"
            f"<td>{esc(item.get('length'))}</td>"
            f"<td>{esc(item.get('summary'))}</td>"
            "</tr>"
            for item in decoded.get("rows") or []
        )
        helper_rows.append(
            "<tr>"
            f"<td><code>{esc(row.get('helperId'))}</code></td>"
            f"<td><code>{esc(row.get('functionVaHex'))}</code><br>{esc(row.get('bodyClass'))}</td>"
            f"<td><code>{esc(row.get('childScriptVaHex') or '-')}</code></td>"
            f"<td>{skills}</td>"
            f"<td>{esc(decoded.get('stopReason') or '-')}</td>"
            f"<td>{esc(opcodes)}</td>"
            f"<td>{esc(vec(decoded.get('initFrameSequence') or []))}</td>"
            f"<td>{esc(vec(decoded.get('frameSequence') or []))}</td>"
            f"<td>{esc(vec([item.get('randomRange') for item in decoded.get('randomRanges') or []]))}</td>"
            f"<td>{esc(vec([item.get('targetVaHex') for item in decoded.get('frameScriptTargets') or []]))}</td>"
            f"<td><details><summary>init writes</summary>{init_writes}</details><details><summary>instructions</summary><table><thead><tr><th>VA</th><th>op</th><th>kind</th><th>len</th><th>summary</th></tr></thead><tbody>{instr}</tbody></table></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 Child Script 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 Helper Child Script Review</h1>
  <p><a href="../web/index.html">홈</a> · <a href="../web/battle_simulator.html">전투 기술 실행</a> · <a href="battle_helper_child_script_review.json">JSON</a> · <a href="battle_helper_child_script_review.md">MD</a> · <a href="battle_helper_body_review.html">helper body</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>Helper Child Scripts</h2>
  <div class="wide"><table><thead><tr><th>id</th><th>body</th><th>child script</th><th>skills</th><th>stop</th><th>opcodes</th><th>init frames</th><th>frames</th><th>RNG range</th><th>frame scripts</th><th>detail</th></tr></thead><tbody>{''.join(helper_rows)}</tbody></table></div>
</body>
</html>
"""


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


if __name__ == "__main__":
    main()
