#!/usr/bin/env python3
"""Review battle helper child display objects and frameScript anchors.

This report is intentionally about object flow, not damage math.  The main
actor VM calls 0xbd helper bodies; those helpers spawn child display objects,
attach frameScript streams, set parent/target anchors, and sometimes add RNG or
motion fields.  Earlier reviews tried to find parent child refs in the actor VM
itself, but the stable evidence is now in:

* helper child scripts decoded from the 0xbd helper table,
* opcode 0x07 child-display-VM spawn targets,
* opcode 0x20 frameScript targets,
* opcode 0x42 / +0xa8 parent actor anchor writes,
* opcode 0x2b RNG ranges and 0x2d motion steps.
"""
from __future__ import annotations

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


ROOT = Path(__file__).resolve().parents[1]
TOOLS = ROOT / "tools"
OUT = ROOT / "out"
HELPER_CHILD_JSON = OUT / "battle_helper_child_script_review.json"
HELPER_FRAME_JSON = OUT / "battle_helper_frame_script_review.json"

if str(TOOLS) not in sys.path:
    sys.path.insert(0, str(TOOLS))

from build_battle_display_vm_static_decode import EXE, hex32, read_sections  # noqa: E402
from build_battle_helper_child_script_review import walk_child_script  # noqa: E402


ANCHOR_DESTS = {"0xa8"}
POSITION_DESTS = {"0x1c", "0x20", "0x68", "0x6c", "0x74", "0x80", "0x84", "0x88"}
MOTION_DESTS = {"0x8c", "0x8e", "0x90", "0x92", "0x94", "0x96"}
KEY_CATEGORIES = {
    "frame",
    "init-block",
    "frame-script-pointer",
    "spawn-child-vm",
    "link-child-parent",
    "parent-actor",
    "parent-actor-bind",
    "global-parent-anchor",
    "random-range",
    "motion-step",
    "clear-display-list",
    "actor-flags",
    "countdown-wait",
    "yield",
    "palette-backup",
    "palette-transform",
    "palette-restore",
}


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


def read_json(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8"))


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


def player_skill_label(skill: dict[str, Any]) -> str:
    return f"{skill.get('ownerName')} {skill.get('skillName')} {skill.get('skillIdHex')}"


def monster_action_label(row: dict[str, Any]) -> str:
    return f"{row.get('enemyName')} {row.get('sharedActionName')} {row.get('sharedActionIdHex')}"


def usage_labels(row: dict[str, Any]) -> list[str]:
    labels = [player_skill_label(skill) for skill in row.get("skillRows") or []]
    labels.extend(monster_action_label(monster) for monster in row.get("monsterRows") or [])
    return labels


def compact_labels(labels: list[str], limit: int = 8) -> str:
    values = [label for label in labels if label and "None" not in label]
    if len(values) > limit:
        values = values[:limit] + [f"... +{len(values) - limit}"]
    return "; ".join(values) or "-"


def row_brief(row: dict[str, Any]) -> dict[str, Any]:
    keys = (
        "vaHex",
        "opcode",
        "category",
        "summary",
        "targetVaHex",
        "destHex",
        "sourceHex",
        "immHex",
        "immFixed",
        "modeHex",
        "spriteHex",
        "frame",
        "gate",
        "randomRange",
        "randomRangeHex",
        "axes",
        "modeGroupHex",
        "comparison",
        "conditionOpHex",
        "operandWidth",
        "leftSource",
        "rightSource",
        "leftSelectorHex",
        "rightSelectorHex",
        "rightImmediateHex",
        "branchTargetOffset",
        "branchTargetVaHex",
        "listIndex",
        "maskHex",
        "childObjectType",
        "paletteStartHex",
        "paletteCountHex",
        "argHex",
    )
    return {key: row.get(key) for key in keys if row.get(key) not in (None, "", [])}


def key_events(decoded: dict[str, Any]) -> list[dict[str, Any]]:
    events: list[dict[str, Any]] = []
    for row in decoded.get("rows") or []:
        category = row.get("category")
        dest = row.get("destHex")
        if category in KEY_CATEGORIES:
            events.append(row_brief(row))
        elif category == "branch":
            events.append(row_brief(row))
        elif category in {"write", "child-write"} and dest in ANCHOR_DESTS | POSITION_DESTS | MOTION_DESTS:
            events.append(row_brief(row))
        elif row.get("directFrameSelector"):
            events.append(row_brief(row))
    return events


def frame_target_summary(frame_by_target: dict[str, dict[str, Any]], target_hex: str | None) -> dict[str, Any] | None:
    if not target_hex:
        return None
    row = frame_by_target.get(target_hex)
    if not row:
        return {"targetVaHex": target_hex, "missing": True}
    frame_gate = row.get("frameGateSequence") or []
    decoded_rows = (row.get("decoded") or {}).get("rows") or []
    writes = [
        row_brief(instr)
        for instr in decoded_rows
        if instr.get("category") == "write" and instr.get("destHex") in POSITION_DESTS | ANCHOR_DESTS | MOTION_DESTS
    ]
    parent_anchors = [row_brief(instr) for instr in decoded_rows if instr.get("category") == "parent-actor"]
    actor_flags = [row_brief(instr) for instr in decoded_rows if instr.get("category") == "actor-flags"]
    return {
        "targetVaHex": target_hex,
        "helperIds": row.get("helperIds") or [],
        "skillLabels": [
            f"{skill.get('ownerName')} {skill.get('skillName')} {skill.get('skillIdHex')}"
            for skill in row.get("skillRows") or []
        ],
        "frameGateSequence": frame_gate,
        "frameLabels": [item.get("label") for item in frame_gate],
        "spriteHexSequence": row.get("spriteHexSequence") or [],
        "durationGate": sum(int(item.get("gate") or 0) for item in frame_gate),
        "positionWrites": writes,
        "parentAnchors": parent_anchors,
        "actorFlags": actor_flags,
        "stopReason": row.get("stopReason"),
    }


def summarize_decoded(decoded: dict[str, Any], frame_by_target: dict[str, dict[str, Any]]) -> dict[str, Any]:
    rows = decoded.get("rows") or []
    frame_scripts = [
        frame_target_summary(frame_by_target, item.get("targetVaHex"))
        for item in decoded.get("frameScriptTargets") or []
    ]
    frame_scripts = [item for item in frame_scripts if item]
    anchor_writes = [
        row_brief(row)
        for row in rows
        if row.get("category") in {"write", "child-write"} and row.get("destHex") in ANCHOR_DESTS
    ]
    position_writes = [
        row_brief(row)
        for row in rows
        if row.get("category") in {"write", "child-write"} and row.get("destHex") in POSITION_DESTS
    ]
    motion_writes = [
        row_brief(row)
        for row in rows
        if row.get("category") in {"write", "child-write"} and row.get("destHex") in MOTION_DESTS
    ]
    branch_rows = [row_brief(row) for row in rows if row.get("category") in {"branch", "conditional-branch"}]
    return {
        "startVaHex": decoded.get("startVaHex"),
        "stopReason": decoded.get("stopReason"),
        "instructionCount": decoded.get("instructionCount"),
        "opcodeCounts": decoded.get("opcodeCounts") or {},
        "frameSequence": decoded.get("frameSequence") or [],
        "initFrameSequence": decoded.get("initFrameSequence") or [],
        "frameScripts": frame_scripts,
        "randomRanges": decoded.get("randomRanges") or [],
        "parentActorLinks": decoded.get("parentActorLinks") or [],
        "anchorWrites": anchor_writes,
        "positionWrites": position_writes,
        "motionWrites": motion_writes,
        "branchRows": branch_rows,
        "branchTargets": decoded.get("branchTargets") or [],
        "paletteEvents": decoded.get("paletteEvents") or [],
        "motionSteps": [row_brief(row) for row in rows if row.get("category") == "motion-step"],
        "clearDisplayLists": [row_brief(row) for row in rows if row.get("category") == "clear-display-list"],
        "spawnRows": decoded.get("spawnRows") or [row_brief(row) for row in rows if row.get("category") == "spawn-child-vm"],
        "keyEvents": key_events(decoded),
    }


def decoded_spawn_paths(
    data: bytes,
    sections: list[dict[str, Any]],
    decoded: dict[str, Any],
    seen: set[int],
    branch_depth: int = 0,
    max_branch_depth: int = 3,
) -> list[dict[str, Any]]:
    paths = [decoded]
    if branch_depth >= max_branch_depth:
        return paths
    if decoded.get("spawnRows") or any(row.get("category") == "spawn-child-vm" for row in decoded.get("rows") or []):
        return paths
    for branch in decoded.get("branchTargets") or []:
        target_hex = branch.get("targetVaHex")
        target = int(target_hex, 16) if target_hex else None
        if not isinstance(target, int) or target in seen:
            continue
        next_seen = set(seen)
        next_seen.add(target)
        branch_decoded = walk_child_script(data, sections, target, max_steps=180)
        branch_decoded["branchSource"] = branch
        paths.extend(
            decoded_spawn_paths(
                data,
                sections,
                branch_decoded,
                next_seen,
                branch_depth=branch_depth + 1,
                max_branch_depth=max_branch_depth,
            )
        )
    return paths


def decode_spawn_tree(
    data: bytes,
    sections: list[dict[str, Any]],
    frame_by_target: dict[str, dict[str, Any]],
    decoded: dict[str, Any],
    depth: int = 1,
    seen: set[int] | None = None,
    max_depth: int = 4,
) -> list[dict[str, Any]]:
    seen = seen or set()
    nodes: list[dict[str, Any]] = []
    spawn_sources = decoded_spawn_paths(data, sections, decoded, seen=set(seen))
    for source_decoded in spawn_sources:
        source_branch = source_decoded.get("branchSource") or {}
        source_rows = source_decoded.get("rows") or []
        for row in source_rows:
            if row.get("category") != "spawn-child-vm":
                continue
            target = row.get("targetVa")
            if not isinstance(target, int):
                continue
            target_hex = row.get("targetVaHex") or hex32(target)
            node: dict[str, Any] = {
                "depth": depth,
                "sourceVaHex": row.get("vaHex"),
                "sourceBranchVaHex": source_branch.get("vaHex"),
                "sourceBranchTargetVaHex": source_branch.get("targetVaHex"),
                "targetVa": target,
                "targetVaHex": target_hex,
                "childObjectType": row.get("childObjectType"),
                "summary": row.get("summary"),
            }
            if target in seen:
                node["cycle"] = True
                nodes.append(node)
                continue
            if depth > max_depth:
                node["maxDepthReached"] = True
                nodes.append(node)
                continue
            next_seen = set(seen)
            next_seen.add(target)
            child_decoded = walk_child_script(data, sections, target, max_steps=180)
            node["decoded"] = summarize_decoded(child_decoded, frame_by_target)
            node["children"] = decode_spawn_tree(
                data,
                sections,
                frame_by_target,
                child_decoded,
                depth=depth + 1,
                seen=next_seen,
                max_depth=max_depth,
            )
            nodes.append(node)
    return nodes


def flatten_spawn_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
    flat: list[dict[str, Any]] = []
    for node in nodes:
        flat.append(node)
        flat.extend(flatten_spawn_nodes(node.get("children") or []))
    return flat


def classify_helper(summary: dict[str, Any], flat_nodes: list[dict[str, Any]]) -> str:
    if flat_nodes and any((node.get("decoded") or {}).get("frameScripts") for node in flat_nodes):
        return "spawn-tree-with-frameScript"
    if flat_nodes and any((node.get("decoded") or {}).get("frameSequence") or (node.get("decoded") or {}).get("initFrameSequence") for node in flat_nodes):
        return "spawn-tree-with-direct-frames"
    if summary.get("frameScripts"):
        return "direct-frameScript-effect"
    if flat_nodes:
        return "spawn-tree-position-motion"
    if summary.get("motionSteps"):
        return "motion-only-effect"
    if summary.get("randomRanges"):
        return "randomized-offset-effect"
    return "helper-control-or-cleanup"


def build() -> dict[str, Any]:
    data = EXE.read_bytes()
    sections = read_sections(data)
    child_report = read_json(HELPER_CHILD_JSON)
    frame_report = read_json(HELPER_FRAME_JSON)
    frame_by_target = {
        row.get("targetVaHex"): row
        for row in frame_report.get("frameScriptRows") or []
        if row.get("targetVaHex")
    }

    helper_rows: list[dict[str, Any]] = []
    unique_spawn_targets: defaultdict[str, list[int]] = defaultdict(list)
    frame_script_usage: defaultdict[str, list[int]] = defaultdict(list)
    class_counts: Counter[str] = Counter()
    sprite_counts: Counter[str] = Counter()
    anchor_counts: Counter[str] = Counter()

    for helper in child_report.get("helperRows") or []:
        helper_id = int(helper["helperId"])
        decoded = helper.get("decoded") or {}
        root_summary = summarize_decoded(decoded, frame_by_target)
        spawn_tree = decode_spawn_tree(data, sections, frame_by_target, decoded, seen=set())
        flat_nodes = flatten_spawn_nodes(spawn_tree)
        helper_class = classify_helper(root_summary, flat_nodes)
        class_counts[helper_class] += 1

        for node in flat_nodes:
            unique_spawn_targets[node.get("targetVaHex")].append(helper_id)
            node_decoded = node.get("decoded") or {}
            for fs in node_decoded.get("frameScripts") or []:
                frame_script_usage[fs.get("targetVaHex")].append(helper_id)
                for sprite_hex in fs.get("spriteHexSequence") or []:
                    sprite_counts[str(sprite_hex)] += 1
            if node_decoded.get("anchorWrites"):
                anchor_counts["spawn +0xa8"] += 1
            if node_decoded.get("parentActorLinks"):
                anchor_counts["spawn 0x42"] += 1
        for fs in root_summary.get("frameScripts") or []:
            frame_script_usage[fs.get("targetVaHex")].append(helper_id)
            for sprite_hex in fs.get("spriteHexSequence") or []:
                sprite_counts[str(sprite_hex)] += 1
        if root_summary.get("anchorWrites"):
            anchor_counts["root +0xa8"] += 1
        if root_summary.get("parentActorLinks"):
            anchor_counts["root 0x42"] += 1

        helper_rows.append(
            {
                "helperId": helper_id,
                "functionVaHex": helper.get("functionVaHex"),
                "bodyClass": helper.get("bodyClass"),
                "childScriptVaHex": helper.get("childScriptVaHex"),
                "skills": helper.get("skillRows") or [],
                "monsterActions": helper.get("monsterRows") or [],
                "skillLabels": usage_labels(helper),
                "helperClass": helper_class,
                "root": root_summary,
                "spawnTree": spawn_tree,
                "flatSpawnTargets": [
                    {
                        "depth": node.get("depth"),
                        "sourceVaHex": node.get("sourceVaHex"),
                        "targetVaHex": node.get("targetVaHex"),
                        "childObjectType": node.get("childObjectType"),
                        "frameScripts": [
                            fs.get("targetVaHex")
                            for fs in (node.get("decoded") or {}).get("frameScripts") or []
                        ],
                        "frames": (node.get("decoded") or {}).get("frameSequence") or (node.get("decoded") or {}).get("initFrameSequence") or [],
                        "randomRanges": [
                            item.get("randomRange")
                            for item in (node.get("decoded") or {}).get("randomRanges") or []
                        ],
                        "motionSteps": [
                            item.get("modeHex")
                            for item in (node.get("decoded") or {}).get("motionSteps") or []
                        ],
                        "anchors": len((node.get("decoded") or {}).get("anchorWrites") or [])
                        + len((node.get("decoded") or {}).get("parentActorLinks") or []),
                    }
                    for node in flat_nodes
                ],
            }
        )

    return {
        "version": 2,
        "kind": "hwanse-battle-effect-object-review",
        "source": [
            "Hwanse2.exe",
            "out/battle_helper_child_script_review.json",
            "out/battle_helper_frame_script_review.json",
        ],
        "status": "helper-child-spawn-frameScript-anchor-review",
        "runtimeUsed": False,
        "summary": {
            "helpers": len(helper_rows),
            "helpersWithDirectFrameScript": sum(1 for row in helper_rows if row["root"]["frameScripts"]),
            "helpersWithSpawnTree": sum(1 for row in helper_rows if row["flatSpawnTargets"]),
            "helpersWithRandomRange": sum(1 for row in helper_rows if row["root"]["randomRanges"] or any(node["randomRanges"] for node in row["flatSpawnTargets"])),
            "helpersWithMotionStep": sum(1 for row in helper_rows if row["root"]["motionSteps"] or any(node["motionSteps"] for node in row["flatSpawnTargets"])),
            "uniqueSpawnTargets": len(unique_spawn_targets),
            "uniqueFrameScriptTargets": len(frame_script_usage),
            "helperClassCounts": dict(sorted(class_counts.items())),
            "frameScriptSpriteCounts": dict(sorted(sprite_counts.items())),
            "anchorCounts": dict(sorted(anchor_counts.items())),
        },
        "interpretationNotes": [
            "0xbd helper bodies are not opaque anymore: many call a child display VM script that either attaches 0x20 frameScript streams or spawns deeper 0x07 child objects.",
            "0x20 frameScript streams mostly use sprite 0x1a and carry EXE gate values, so these are effect/object frames rather than the main actor CNS frames.",
            "0x42 and writes to +0xa8 are the strongest current target/parent anchor evidence. 0x1f links a freshly spawned child object before +0xa8 is copied from +0xa4.",
            "0x2b random ranges feed offsets/angles. They are not resource ids.",
            "0x2d motion-step rows define relative or absolute trig motion over x/y/z axes.",
            "0x4b is display-list cleanup/free. It is listed as lifecycle evidence, not as a visual resource.",
            "This still does not decode damage, miss, critical, or status formulas. It isolates the visual object/control side needed by the browser skill runner.",
        ],
        "uniqueSpawnTargets": {target: sorted(set(ids)) for target, ids in sorted(unique_spawn_targets.items())},
        "frameScriptUsage": {target: sorted(set(ids)) for target, ids in sorted(frame_script_usage.items())},
        "helperRows": helper_rows,
    }


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Effect Object Review",
        "",
        f"- status: `{report['status']}`",
        f"- helpers: `{report['summary']['helpers']}`",
        f"- helpers with direct frameScript: `{report['summary']['helpersWithDirectFrameScript']}`",
        f"- helpers with spawn tree: `{report['summary']['helpersWithSpawnTree']}`",
        f"- unique spawn targets: `{report['summary']['uniqueSpawnTargets']}`",
        f"- unique frameScript targets: `{report['summary']['uniqueFrameScriptTargets']}`",
        "",
        "## Interpretation",
        "",
    ]
    lines.extend(f"- {note}" for note in report["interpretationNotes"])
    lines.extend(
        [
            "",
            "## Helper Object Flow",
            "",
            "| helper | class | body | skills | root 0x20 | spawn targets | RNG | motion | anchors |",
            "| ---: | --- | --- | --- | --- | --- | --- | --- | --- |",
        ]
    )
    for row in report["helperRows"]:
        root = row["root"]
        root_fs = vec([fs.get("targetVaHex") for fs in root.get("frameScripts") or []])
        spawns = vec([f"{node['targetVaHex']}@d{node['depth']}" for node in row.get("flatSpawnTargets") or []])
        rngs = vec([item.get("randomRange") for item in root.get("randomRanges") or []] + [rng for node in row.get("flatSpawnTargets") or [] for rng in node.get("randomRanges") or []])
        motions = vec([item.get("modeHex") for item in root.get("motionSteps") or []] + [mode for node in row.get("flatSpawnTargets") or [] for mode in node.get("motionSteps") or []])
        anchors = len(root.get("anchorWrites") or []) + len(root.get("parentActorLinks") or []) + sum(int(node.get("anchors") or 0) for node in row.get("flatSpawnTargets") or [])
        lines.append(
            f"| {row['helperId']} | `{row['helperClass']}` | `{row['bodyClass']}` | "
            f"{compact_labels(row.get('skillLabels') or [])} | {root_fs} | {spawns} | {rngs} | {motions} | {anchors} |"
        )
    return "\n".join(lines) + "\n"


def code_list(values: list[Any] | None) -> str:
    if not values:
        return "-"
    return "<br>".join(f"<code>{esc(value)}</code>" for value in values)


def frame_script_html(frame_scripts: list[dict[str, Any]]) -> str:
    if not frame_scripts:
        return "-"
    blocks = []
    for fs in frame_scripts:
        frames = ", ".join(str(label) for label in fs.get("frameLabels") or []) or "-"
        writes = "<br>".join(f"<code>{esc(w.get('vaHex'))}</code> {esc(w.get('summary'))}" for w in fs.get("positionWrites") or []) or "-"
        anchors = "<br>".join(f"<code>{esc(a.get('vaHex'))}</code> {esc(a.get('summary'))}" for a in fs.get("parentAnchors") or []) or "-"
        flags = ", ".join(f"{flag.get('mode')}:{flag.get('maskHex')}" for flag in fs.get("actorFlags") or []) or "-"
        blocks.append(
            f"<details open><summary><code>{esc(fs.get('targetVaHex'))}</code> duration {esc(fs.get('durationGate'))}</summary>"
            f"<div>frames: {esc(frames)}</div><div>sprites: {esc(vec(fs.get('spriteHexSequence') or []))}</div>"
            f"<div>flags: {esc(flags)}</div><div>anchors: {anchors}</div><div>writes: {writes}</div></details>"
        )
    return "".join(blocks)


def key_events_html(events: list[dict[str, Any]], limit: int = 32) -> str:
    if not events:
        return "-"
    rows = []
    for event in events[:limit]:
        rows.append(
            "<tr>"
            f"<td><code>{esc(event.get('vaHex'))}</code></td>"
            f"<td><code>{esc(event.get('opcode'))}</code></td>"
            f"<td>{esc(event.get('category'))}</td>"
            f"<td>{esc(event.get('summary'))}</td>"
            "</tr>"
        )
    if len(events) > limit:
        rows.append(f"<tr><td colspan='4'>... +{len(events) - limit}</td></tr>")
    return f"<table><thead><tr><th>VA</th><th>op</th><th>kind</th><th>summary</th></tr></thead><tbody>{''.join(rows)}</tbody></table>"


def html_page(report: dict[str, Any]) -> str:
    summary_rows = "".join(f"<tr><td>{esc(k)}</td><td><code>{esc(v)}</code></td></tr>" for k, v in report["summary"].items())
    notes = "".join(f"<li>{esc(note)}</li>" for note in report["interpretationNotes"])
    helper_rows = []
    for row in report["helperRows"]:
        root = row["root"]
        skills = "<br>".join(esc(label) for label in row.get("skillLabels") or []) or "-"
        spawn_rows = []
        for node in row.get("flatSpawnTargets") or []:
            spawn_rows.append(
                f"d{esc(node.get('depth'))} <code>{esc(node.get('targetVaHex'))}</code> "
                f"frames {esc(vec(node.get('frames') or []))} "
                f"0x20 {esc(vec(node.get('frameScripts') or []))} "
                f"rng {esc(vec(node.get('randomRanges') or []))} "
                f"motion {esc(vec(node.get('motionSteps') or []))} "
                f"anchors {esc(node.get('anchors'))}"
            )
        rngs = [item.get("randomRange") for item in root.get("randomRanges") or []]
        motions = [item.get("modeHex") for item in root.get("motionSteps") or []]
        anchors = len(root.get("anchorWrites") or []) + len(root.get("parentActorLinks") or [])
        helper_rows.append(
            "<tr>"
            f"<td>{esc(row['helperId'])}</td>"
            f"<td><code>{esc(row['helperClass'])}</code><br>{esc(row['bodyClass'])}<br><code>{esc(row['childScriptVaHex'])}</code></td>"
            f"<td>{skills}</td>"
            f"<td>{frame_script_html(root.get('frameScripts') or [])}</td>"
            f"<td>{'<br>'.join(spawn_rows) or '-'}</td>"
            f"<td>{esc(vec(rngs))}</td>"
            f"<td>{esc(vec(motions))}</td>"
            f"<td>{esc(anchors)}</td>"
            f"<td><details><summary>root events</summary>{key_events_html(root.get('keyEvents') or [])}</details></td>"
            "</tr>"
        )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" href="../favicon.ico">
  <title>Battle Effect Object 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 Effect Object Review</h1>
  <p><a href="../web/index.html">홈</a> · <a href="../web/battle_simulator.html">전투 기술 실행</a> · <a href="battle_action_event_timeline_review.html">actor hit timeline</a> · <a href="battle_helper_frame_script_review.html">helper frameScript</a> · <a href="battle_effect_object_review.json">JSON</a> · <a href="battle_effect_object_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>Helper Child / Spawn / FrameScript Flow</h2>
  <div class="wide">
    <table>
      <thead><tr><th>helper</th><th>class/body</th><th>skills</th><th>root frameScript</th><th>spawn tree</th><th>root RNG</th><th>root motion</th><th>root anchors</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_effect_object_review.json").write_text(json.dumps(report, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
    (OUT / "battle_effect_object_review.md").write_text(markdown(report), encoding="utf-8")
    (OUT / "battle_effect_object_review.html").write_text(html_page(report), encoding="utf-8")
    print(f"wrote {OUT / 'battle_effect_object_review.json'}")
    print(f"wrote {OUT / 'battle_effect_object_review.md'}")
    print(f"wrote {OUT / 'battle_effect_object_review.html'}")


if __name__ == "__main__":
    main()
