#!/usr/bin/env python3
"""Review spawn-tree helpers behind battle 0xbd visual helper calls.

The object/effect review already proves that many 0xbd helpers spawn child
display objects.  This pass focuses only on the spawn graph:

* which helper owns which spawned child VM target,
* whether the child target is a renderer, controller, loop, or nested spawner,
* which spawn-local 0x20 frameScript streams can be decoded,
* which targets are reused by multiple skills.

The important distinction is that some 0x20 frameScript pointers only appear
inside spawned child scripts.  The older frameScript report indexes root helper
targets only, so this report decodes spawn-local frameScript pointers directly
from the EXE.
"""
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"
EFFECT_JSON = OUT / "battle_effect_object_review.json"
FRAME_JSON = OUT / "battle_helper_frame_script_review.json"
OUT_JSON = OUT / "battle_helper_spawn_tree_review.json"
OUT_MD = OUT / "battle_helper_spawn_tree_review.md"
OUT_HTML = OUT / "battle_helper_spawn_tree_review.html"

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

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


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 int_hex(value: str | None) -> int:
    if not value:
        return -1
    try:
        return int(value, 16)
    except ValueError:
        return -1


def compact(values: list[Any] | tuple[Any, ...] | set[Any], limit: int = 10) -> str:
    items = [str(value) for value in values if value not in (None, "")]
    if not items:
        return "-"
    if len(items) > limit:
        items = items[:limit] + [f"... +{len(items) - limit}"]
    return ", ".join(items)


def skill_label(skill: dict[str, Any]) -> str:
    owner = skill.get("ownerName")
    name = skill.get("skillName")
    skill_id = skill.get("skillIdHex")
    level = skill.get("levelOrFixed")
    parts = [owner, name, skill_id]
    if level is not None:
        parts.append(f"L{level}")
    return " ".join(str(part) for part in parts if part)


def helper_skill_labels(helper: dict[str, Any]) -> list[str]:
    labels = helper.get("skillLabels") or []
    if labels:
        return labels
    return [skill_label(skill) for skill in helper.get("skills") or helper.get("skillRows") or []]


def frame_gate_sequence(decoded: dict[str, Any]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for frame in decoded.get("frames") or []:
        rows.append(
            {
                "vaHex": frame.get("vaHex"),
                "spriteHex": frame.get("spriteHex"),
                "frame": frame.get("frame"),
                "gate": frame.get("gate"),
                "label": f"{frame.get('frame')}@{frame.get('gate')}",
            }
        )
    return rows


def summarize_frame_script(decoded: dict[str, Any], target_hex: str, source: str) -> dict[str, Any]:
    sequence = frame_gate_sequence(decoded)
    return {
        "targetVaHex": target_hex,
        "source": source,
        "missing": False,
        "stopReason": decoded.get("stopReason"),
        "instructionCount": decoded.get("instructionCount"),
        "opcodeCounts": decoded.get("opcodeCounts") or {},
        "frameGateSequence": sequence,
        "frameLabels": [item.get("label") for item in sequence],
        "frameSequence": [item.get("frame") for item in sequence],
        "gateSequence": [item.get("gate") for item in sequence],
        "spriteHexSequence": [item.get("spriteHex") for item in sequence],
        "durationGate": sum(int(item.get("gate") or 0) for item in sequence),
        "initFrameSequence": decoded.get("initFrameSequence") or [],
        "randomRanges": decoded.get("randomRanges") or [],
        "nestedFrameScriptTargets": decoded.get("frameScriptTargets") or [],
        "waits": decoded.get("waits") or [],
        "unknowns": decoded.get("unknowns") or [],
    }


def summarize_frame_row(row: dict[str, Any], target_hex: str) -> dict[str, Any]:
    sequence = row.get("frameGateSequence") or []
    return {
        "targetVaHex": target_hex,
        "source": "root-frame-script-review",
        "missing": False,
        "stopReason": row.get("stopReason"),
        "instructionCount": row.get("instructionCount"),
        "opcodeCounts": row.get("opcodeCounts") or {},
        "frameGateSequence": sequence,
        "frameLabels": [item.get("label") for item in sequence],
        "frameSequence": [item.get("frame") for item in sequence],
        "gateSequence": [item.get("gate") for item in sequence],
        "spriteHexSequence": [item.get("spriteHex") for item in sequence],
        "durationGate": sum(int(item.get("gate") or 0) for item in sequence),
        "initFrameSequence": row.get("initFrameSequence") or [],
        "randomRanges": row.get("randomRanges") or [],
        "nestedFrameScriptTargets": row.get("nestedFrameScriptTargets") or [],
        "waits": row.get("waits") or [],
        "unknowns": row.get("unknowns") or [],
    }


def resolve_frame_script(
    data: bytes,
    sections: list[dict[str, Any]],
    frame_by_target: dict[str, dict[str, Any]],
    target_hex: str | None,
) -> dict[str, Any] | None:
    if not target_hex:
        return None
    if target_hex in frame_by_target:
        return summarize_frame_row(frame_by_target[target_hex], target_hex)
    target_va = int_hex(target_hex)
    if target_va < 0:
        return {"targetVaHex": target_hex, "source": "invalid-target", "missing": True}
    decoded = walk_child_script(data, sections, target_va, max_steps=220)
    return summarize_frame_script(decoded, target_hex, "spawn-local-decode")


def node_tags(decoded: dict[str, Any], node: dict[str, Any], resolved_frame_scripts: list[dict[str, Any]]) -> list[str]:
    tags: list[str] = []
    if node.get("children"):
        tags.append("spawns-children")
    if resolved_frame_scripts:
        tags.append("frameScript-emitter")
    if decoded.get("frameSequence") or decoded.get("initFrameSequence"):
        tags.append("direct-frame-emitter")
    if decoded.get("randomRanges"):
        tags.append("random-placement")
    if decoded.get("positionWrites") or decoded.get("motionWrites") or decoded.get("motionSteps"):
        tags.append("motion-position")
    if decoded.get("anchorWrites") or decoded.get("parentActorLinks"):
        tags.append("anchor-link")
    if decoded.get("clearDisplayLists"):
        tags.append("cleanup")
    if decoded.get("paletteEvents"):
        tags.append("palette-effect")
    stop = str(decoded.get("stopReason") or "")
    if "loop" in stop:
        tags.append("loop-controller")
    if "unknown opcode" in stop:
        tags.append("unknown-stop")
    return tags or ["plain-control"]


def primary_class(tags: list[str]) -> str:
    order = [
        "spawns-children",
        "frameScript-emitter",
        "direct-frame-emitter",
        "loop-controller",
        "motion-position",
        "random-placement",
        "anchor-link",
        "cleanup",
        "unknown-stop",
        "plain-control",
        "palette-effect",
    ]
    for tag in order:
        if tag in tags:
            return tag
    return tags[0] if tags else "unknown"


def row_brief(row: dict[str, Any]) -> dict[str, Any]:
    keys = (
        "vaHex",
        "opcode",
        "category",
        "summary",
        "targetVaHex",
        "destHex",
        "sourceHex",
        "immHex",
        "immFixed",
        "modeHex",
        "randomRange",
        "randomRangeHex",
        "axes",
        "childObjectType",
        "sourceBranchVaHex",
        "sourceBranchTargetVaHex",
        "paletteStartHex",
        "paletteCountHex",
        "argHex",
    )
    return {key: row.get(key) for key in keys if row.get(key) not in (None, "", [])}


def frame_script_refs(decoded: dict[str, Any]) -> list[str]:
    refs: list[str] = []
    for item in decoded.get("frameScripts") or []:
        target = item.get("targetVaHex")
        if target:
            refs.append(target)
    return refs


def analyze_node(
    node: dict[str, Any],
    path: str,
    helper_id: int,
    skill_labels: list[str],
    data: bytes,
    sections: list[dict[str, Any]],
    frame_by_target: dict[str, dict[str, Any]],
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
    decoded = node.get("decoded") or {}
    resolved = [
        item
        for item in (
            resolve_frame_script(data, sections, frame_by_target, target_hex)
            for target_hex in frame_script_refs(decoded)
        )
        if item
    ]
    tags = node_tags(decoded, node, resolved)
    analyzed = {
        "path": path,
        "depth": node.get("depth"),
        "helperId": helper_id,
        "skillLabels": skill_labels,
        "sourceVaHex": node.get("sourceVaHex"),
        "sourceBranchVaHex": node.get("sourceBranchVaHex"),
        "sourceBranchTargetVaHex": node.get("sourceBranchTargetVaHex"),
        "targetVaHex": node.get("targetVaHex"),
        "childObjectType": node.get("childObjectType"),
        "primaryClass": primary_class(tags),
        "tags": tags,
        "stopReason": decoded.get("stopReason"),
        "instructionCount": decoded.get("instructionCount"),
        "opcodeCounts": decoded.get("opcodeCounts") or {},
        "directFrames": decoded.get("frameSequence") or [],
        "initFrames": decoded.get("initFrameSequence") or [],
        "resolvedFrameScripts": resolved,
        "randomRanges": [row_brief(row) for row in decoded.get("randomRanges") or []],
        "motionSteps": [row_brief(row) for row in decoded.get("motionSteps") or []],
        "positionWrites": [row_brief(row) for row in decoded.get("positionWrites") or []],
        "motionWrites": [row_brief(row) for row in decoded.get("motionWrites") or []],
        "anchorWrites": [row_brief(row) for row in decoded.get("anchorWrites") or []],
        "parentActorLinks": [row_brief(row) for row in decoded.get("parentActorLinks") or []],
        "spawnRows": [row_brief(row) for row in decoded.get("spawnRows") or []],
        "branchTargets": [row_brief(row) for row in decoded.get("branchTargets") or []],
        "paletteEvents": [row_brief(row) for row in decoded.get("paletteEvents") or []],
        "childrenCount": len(node.get("children") or []),
    }
    flat = [analyzed]
    child_nodes: list[dict[str, Any]] = []
    for index, child in enumerate(node.get("children") or [], 1):
        child_path = f"{path}.{index}"
        child_analyzed, child_flat = analyze_node(
            child,
            child_path,
            helper_id,
            skill_labels,
            data,
            sections,
            frame_by_target,
        )
        child_nodes.append(child_analyzed)
        flat.extend(child_flat)
    analyzed["children"] = child_nodes
    return analyzed, flat


def analyze_helper(
    helper: dict[str, Any],
    data: bytes,
    sections: list[dict[str, Any]],
    frame_by_target: dict[str, dict[str, Any]],
) -> dict[str, Any]:
    helper_id = int(helper.get("helperId"))
    skill_labels = helper_skill_labels(helper)
    nodes: list[dict[str, Any]] = []
    flat: list[dict[str, Any]] = []
    for index, node in enumerate(helper.get("spawnTree") or [], 1):
        analyzed, analyzed_flat = analyze_node(
            node,
            f"root.{index}",
            helper_id,
            skill_labels,
            data,
            sections,
            frame_by_target,
        )
        nodes.append(analyzed)
        flat.extend(analyzed_flat)

    frame_script_targets = sorted(
        {
            script.get("targetVaHex")
            for node in flat
            for script in node.get("resolvedFrameScripts") or []
            if script.get("targetVaHex")
        },
        key=int_hex,
    )
    spawn_targets = sorted({node.get("targetVaHex") for node in flat if node.get("targetVaHex")}, key=int_hex)
    tags = sorted({tag for node in flat for tag in node.get("tags") or []})
    return {
        "helperId": helper_id,
        "functionVaHex": helper.get("functionVaHex"),
        "bodyClass": helper.get("bodyClass"),
        "helperClass": helper.get("helperClass"),
        "childScriptVaHex": helper.get("childScriptVaHex"),
        "skillLabels": skill_labels,
        "spawnNodeCount": len(flat),
        "maxDepth": max([int(node.get("depth") or 0) for node in flat] or [0]),
        "spawnTargets": spawn_targets,
        "frameScriptTargets": frame_script_targets,
        "tags": tags,
        "nodes": nodes,
        "flatNodes": flat,
    }


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

    helper_rows = [
        analyze_helper(helper, data, sections, frame_by_target)
        for helper in effect.get("helperRows") or []
        if helper.get("spawnTree")
    ]
    all_nodes = [node for helper in helper_rows for node in helper.get("flatNodes") or []]

    class_counts = Counter(node.get("primaryClass") for node in all_nodes)
    tag_counts = Counter(tag for node in all_nodes for tag in node.get("tags") or [])
    target_to_helpers: defaultdict[str, set[int]] = defaultdict(set)
    target_to_classes: defaultdict[str, Counter[str]] = defaultdict(Counter)
    target_to_skills: defaultdict[str, set[str]] = defaultdict(set)
    frame_target_to_helpers: defaultdict[str, set[int]] = defaultdict(set)
    frame_target_to_sources: defaultdict[str, Counter[str]] = defaultdict(Counter)
    frame_target_to_labels: dict[str, list[str]] = {}
    frame_target_to_duration: dict[str, int] = {}

    for node in all_nodes:
        target = node.get("targetVaHex")
        if target:
            target_to_helpers[target].add(int(node["helperId"]))
            target_to_classes[target][node.get("primaryClass") or "unknown"] += 1
            target_to_skills[target].update(node.get("skillLabels") or [])
        for script in node.get("resolvedFrameScripts") or []:
            target_hex = script.get("targetVaHex")
            if not target_hex:
                continue
            frame_target_to_helpers[target_hex].add(int(node["helperId"]))
            frame_target_to_sources[target_hex][script.get("source") or "unknown"] += 1
            frame_target_to_labels.setdefault(target_hex, script.get("frameLabels") or [])
            frame_target_to_duration[target_hex] = int(script.get("durationGate") or 0)

    spawn_target_rows = [
        {
            "targetVaHex": target,
            "helperIds": sorted(helpers),
            "classes": dict(sorted(target_to_classes[target].items())),
            "skillLabels": sorted(target_to_skills[target]),
        }
        for target, helpers in sorted(target_to_helpers.items(), key=lambda item: int_hex(item[0]))
    ]
    frame_script_rows = [
        {
            "targetVaHex": target,
            "helperIds": sorted(helpers),
            "sources": dict(sorted(frame_target_to_sources[target].items())),
            "frameLabels": frame_target_to_labels.get(target, []),
            "durationGate": frame_target_to_duration.get(target, 0),
        }
        for target, helpers in sorted(frame_target_to_helpers.items(), key=lambda item: int_hex(item[0]))
    ]

    return {
        "version": 1,
        "kind": "hwanse-battle-helper-spawn-tree-review",
        "source": [
            "Hwanse2.exe",
            "out/battle_effect_object_review.json",
            "out/battle_helper_frame_script_review.json",
        ],
        "status": "spawn-tree-helper-decode",
        "runtimeUsed": False,
        "summary": {
            "helpersWithSpawnTree": len(helper_rows),
            "spawnNodes": len(all_nodes),
            "uniqueSpawnTargets": len(spawn_target_rows),
            "uniqueSpawnLocalFrameScriptTargets": len(frame_script_rows),
            "maxDepth": max([int(row.get("maxDepth") or 0) for row in helper_rows] or [0]),
            "nodeClassCounts": dict(sorted(class_counts.items())),
            "nodeTagCounts": dict(sorted(tag_counts.items())),
            "spawnLocalFrameScriptsDecoded": sum(
                1
                for row in frame_script_rows
                if "spawn-local-decode" in (row.get("sources") or {})
            ),
        },
        "interpretationNotes": [
            "0xbd helper body에서 시작된 child display VM은 다시 0x07로 child object를 spawn할 수 있다. 이 보고서는 그 spawn graph를 helper별로 펼친다.",
            "spawnTree 내부에서만 발견되는 0x20 frameScript target 8개는 기존 root frameScript 리뷰에 빠졌지만, EXE bytes 기준으로 모두 0x21 frame/gate 스트림으로 디코드된다.",
            "frameScript-emitter/direct-frame-emitter 노드는 실제 화면에 그릴 가능성이 높은 노드다. loop-controller/plain-control 노드는 타이밍/반복/제어 성격으로 보아야 한다.",
            "동일 spawn target이 여러 helper에서 재사용된다. 이는 기술별로 완전히 다른 렌더러가 있는 것이 아니라, 공통 effect primitive를 helper가 조합한다는 근거다.",
            "이 보고서는 damage/miss/critical 계산이 아니라 visual helper spawn side만 다룬다.",
        ],
        "spawnTargetRows": spawn_target_rows,
        "frameScriptTargetRows": frame_script_rows,
        "helperRows": helper_rows,
    }


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Helper Spawn Tree Review",
        "",
        f"- status: `{report['status']}`",
        f"- helpers with spawn tree: `{report['summary']['helpersWithSpawnTree']}`",
        f"- spawn nodes: `{report['summary']['spawnNodes']}`",
        f"- unique spawn targets: `{report['summary']['uniqueSpawnTargets']}`",
        f"- spawn-local frameScripts decoded: `{report['summary']['spawnLocalFrameScriptsDecoded']}`",
        "",
        "## Interpretation",
        "",
    ]
    lines.extend(f"- {note}" for note in report["interpretationNotes"])
    lines.extend(
        [
            "",
            "## Spawn Target Reuse",
            "",
            "| target | helpers | classes | skills |",
            "| --- | --- | --- | --- |",
        ]
    )
    for row in report["spawnTargetRows"]:
        lines.append(
            f"| `{row['targetVaHex']}` | {compact(row['helperIds'], 20)} | "
            f"`{row['classes']}` | {compact(row['skillLabels'], 6)} |"
        )
    lines.extend(
        [
            "",
            "## Spawn-Local FrameScript Targets",
            "",
            "| target | helpers | source | frames@gate | duration gate |",
            "| --- | --- | --- | --- | ---: |",
        ]
    )
    for row in report["frameScriptTargetRows"]:
        lines.append(
            f"| `{row['targetVaHex']}` | {compact(row['helperIds'], 20)} | `{row['sources']}` | "
            f"{compact(row['frameLabels'], 20)} | {row['durationGate']} |"
        )
    lines.extend(
        [
            "",
            "## Helper Spawn Trees",
            "",
            "| helper | skills | tags | spawn nodes | targets | frameScripts |",
            "| ---: | --- | --- | ---: | --- | --- |",
        ]
    )
    for row in report["helperRows"]:
        lines.append(
            f"| {row['helperId']} | {compact(row['skillLabels'], 4)} | {compact(row['tags'], 8)} | "
            f"{row['spawnNodeCount']} | {compact(row['spawnTargets'], 8)} | {compact(row['frameScriptTargets'], 8)} |"
        )
    return "\n".join(lines) + "\n"


def summary_table(summary: dict[str, Any]) -> str:
    return "".join(f"<tr><td>{esc(key)}</td><td><code>{esc(value)}</code></td></tr>" for key, value in summary.items())


def frame_summary(script: dict[str, Any]) -> str:
    labels = compact(script.get("frameLabels") or [], 24)
    return (
        f"<code>{esc(script.get('targetVaHex'))}</code> "
        f"<span class=\"muted\">{esc(script.get('source'))}</span> "
        f"{esc(labels)} · gate {esc(script.get('durationGate'))}"
    )


def node_detail_html(node: dict[str, Any]) -> str:
    scripts = "<br>".join(frame_summary(script) for script in node.get("resolvedFrameScripts") or []) or "-"
    direct = compact(node.get("directFrames") or node.get("initFrames") or [])
    randoms = "<br>".join(esc(row.get("summary") or row.get("randomRange")) for row in node.get("randomRanges") or []) or "-"
    motions = "<br>".join(esc(row.get("summary") or row.get("modeHex")) for row in node.get("motionSteps") or []) or "-"
    spawns = "<br>".join(esc(row.get("summary")) for row in node.get("spawnRows") or []) or "-"
    return (
        "<details>"
        f"<summary><code>{esc(node.get('path'))}</code> <code>{esc(node.get('targetVaHex'))}</code> "
        f"{esc(node.get('primaryClass'))} · {esc(compact(node.get('tags') or [], 8))}</summary>"
        "<table class=\"nested\"><tbody>"
        f"<tr><th>source</th><td><code>{esc(node.get('sourceVaHex'))}</code></td></tr>"
        f"<tr><th>stop</th><td>{esc(node.get('stopReason'))}</td></tr>"
        f"<tr><th>opcodes</th><td><code>{esc(node.get('opcodeCounts'))}</code></td></tr>"
        f"<tr><th>direct/init frames</th><td>{esc(direct)}</td></tr>"
        f"<tr><th>frameScripts</th><td>{scripts}</td></tr>"
        f"<tr><th>random</th><td>{randoms}</td></tr>"
        f"<tr><th>motion</th><td>{motions}</td></tr>"
        f"<tr><th>spawn rows</th><td>{spawns}</td></tr>"
        "</tbody></table>"
        "</details>"
    )


def html_page(report: dict[str, Any]) -> str:
    notes = "".join(f"<li>{esc(note)}</li>" for note in report["interpretationNotes"])
    spawn_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['targetVaHex'])}</code></td>"
        f"<td>{esc(compact(row['helperIds'], 20))}</td>"
        f"<td><code>{esc(row['classes'])}</code></td>"
        f"<td>{esc(compact(row['skillLabels'], 8))}</td>"
        "</tr>"
        for row in report["spawnTargetRows"]
    )
    frame_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['targetVaHex'])}</code></td>"
        f"<td>{esc(compact(row['helperIds'], 20))}</td>"
        f"<td><code>{esc(row['sources'])}</code></td>"
        f"<td>{esc(compact(row['frameLabels'], 32))}</td>"
        f"<td>{esc(row['durationGate'])}</td>"
        "</tr>"
        for row in report["frameScriptTargetRows"]
    )
    helper_rows = []
    for row in report["helperRows"]:
        node_details = "".join(node_detail_html(node) for node in row.get("flatNodes") or [])
        helper_rows.append(
            "<tr>"
            f"<td><code>{esc(row['helperId'])}</code></td>"
            f"<td><code>{esc(row['helperClass'])}</code><br><small>{esc(row['bodyClass'])}</small></td>"
            f"<td>{esc(compact(row['skillLabels'], 8))}</td>"
            f"<td>{esc(row['spawnNodeCount'])}</td>"
            f"<td>{esc(compact(row['tags'], 8))}</td>"
            f"<td>{node_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 Spawn Tree Review</title>
  <style>
    body {{ margin: 0; padding: 24px; background: #f6f7f9; color: #151923; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
    h1, h2 {{ margin: 0 0 12px; }}
    h2 {{ margin-top: 24px; font-size: 18px; }}
    a {{ color: #1f6feb; text-decoration: none; }}
    a:hover {{ text-decoration: underline; }}
    p, li, .muted {{ color: #5c6678; }}
    table {{ width: 100%; border-collapse: collapse; margin: 12px 0 20px; background: #fff; border: 1px solid #d9dee7; }}
    th, td {{ border-bottom: 1px solid #e3e7ef; padding: 7px 8px; text-align: left; vertical-align: top; font-size: 13px; }}
    th {{ background: #eef1f6; color: #303846; position: sticky; top: 0; z-index: 2; }}
    code {{ color: #8a4b00; }}
    details {{ margin: 4px 0; }}
    summary {{ cursor: pointer; color: #102a43; }}
    .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }}
    .panel {{ background: #fff; border: 1px solid #d9dee7; border-radius: 8px; padding: 14px; }}
    .wide {{ overflow: auto; max-height: 76vh; border: 1px solid #d9dee7; background: #fff; }}
    .nested {{ margin: 6px 0; border-color: #edf0f5; }}
    .nested th {{ position: static; width: 150px; }}
  </style>
</head>
<body>
  <header>
    <h1>Battle Helper Spawn Tree Review</h1>
    <p>
      <a href="../web/index.html">홈</a> ·
      <a href="../web/battle_simulator.html">전투 기술 실행</a> ·
      <a href="../web/battle_effect_visual_review.html">이펙트 시각 검토</a> ·
      <a href="battle_effect_object_review.html">이펙트 객체 근거</a> ·
      <a href="battle_helper_child_script_review.html">child script</a> ·
      <a href="battle_helper_frame_script_review.html">frameScript</a> ·
      <a href="battle_helper_spawn_tree_review.json">JSON</a> ·
      <a href="battle_helper_spawn_tree_review.md">MD</a>
    </p>
  </header>
  <main>
    <div class="grid">
      <section class="panel"><h2>Summary</h2><table><tbody>{summary_table(report['summary'])}</tbody></table></section>
      <section class="panel"><h2>Interpretation</h2><ul>{notes}</ul></section>
    </div>
    <h2>Spawn Target Reuse</h2>
    <div class="wide"><table><thead><tr><th>target</th><th>helpers</th><th>classes</th><th>skills</th></tr></thead><tbody>{spawn_rows}</tbody></table></div>
    <h2>Spawn-Local FrameScripts</h2>
    <div class="wide"><table><thead><tr><th>target</th><th>helpers</th><th>sources</th><th>frames@gate</th><th>duration gate</th></tr></thead><tbody>{frame_rows}</tbody></table></div>
    <h2>Helper Spawn Trees</h2>
    <div class="wide"><table><thead><tr><th>helper</th><th>class/body</th><th>skills</th><th>nodes</th><th>tags</th><th>node details</th></tr></thead><tbody>{''.join(helper_rows)}</tbody></table></div>
  </main>
</body>
</html>
"""


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


if __name__ == "__main__":
    main()
