#!/usr/bin/env python3
"""Summarize random/motion/control flow inside battle helper child scripts.

The existing helper reports decode frame gates and actor sync points.  This
report looks at the other still-actionable part of the child VM: RNG ranges,
display-list cleanup, motion-step opcodes, and control/jump-table flow.
Opcode 0x2b was previously treated as a resource-load candidate, but handler
disassembly proves it calls RNG helper 0x427730 and stores the random result in
display +0x58.  The goal is to expose stable patterns that can be promoted into
the browser skill runner without guessing damage, hit, miss, or critical
formulas.
"""
from __future__ import annotations

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


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
HELPER_CHILD_JSON = OUT / "battle_helper_child_script_review.json"
HELPER_OPCODE_JSON = OUT / "battle_helper_opcode_review.json"
HELPER_RUNTIME_JSON = OUT / "battle_helper_effect_runtime_review.json"


INTERESTING_CATEGORIES = {
    "random-range",
    "motion-step",
    "clear-display-list",
    "screen-helper",
    "switch/control",
    "spawn-child-vm",
    "indexed-jump-table",
    "call-subscript",
    "placement-expr",
    "frame-script-pointer",
    "child-write",
    "write",
    "actor-flags",
    "repeat-loop",
}

FLOW_AFTER_RANDOM_CATEGORIES = {
    "child-write",
    "write",
    "motion-step",
    "placement-expr",
    "clear-display-list",
    "screen-helper",
    "switch/control",
    "spawn-child-vm",
    "frame-script-pointer",
    "actor-flags",
}


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 as_hex(value: int | None, width: int = 2) -> str:
    if value is None:
        return ""
    return f"0x{value:0{width}x}"


def unique(values: list[Any]) -> list[Any]:
    result: list[Any] = []
    seen: set[str] = set()
    for value in values:
        key = json.dumps(value, ensure_ascii=False, sort_keys=True)
        if key in seen:
            continue
        seen.add(key)
        result.append(value)
    return result


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


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


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


def helper_skills(helper: dict[str, Any]) -> list[dict[str, Any]]:
    return helper.get("skillRows") or []


def row_brief(row: dict[str, Any]) -> dict[str, Any]:
    category = row.get("category")
    brief: dict[str, Any] = {
        "vaHex": row.get("vaHex"),
        "opcode": row.get("opcode"),
        "category": category,
        "summary": row.get("summary"),
    }
    if category == "random-range":
        brief.update({"randomRange": row.get("randomRange"), "randomRangeHex": row.get("randomRangeHex")})
    if category == "motion-step":
        brief.update({"mode": row.get("mode"), "modeHex": row.get("modeHex")})
    if category in {"clear-display-list", "screen-helper"}:
        brief.update({"effectArgsHex": row.get("effectArgsHex")})
    if category == "clear-display-list":
        brief.update({"listIndex": row.get("listIndex"), "maskHex": row.get("maskHex")})
    if category in {"switch/control", "spawn-child-vm", "call-subscript", "frame-script-pointer"}:
        brief.update({"targetVaHex": row.get("targetVaHex"), "arg": row.get("arg")})
    if category == "indexed-jump-table":
        brief.update(
            {
                "indexFieldHex": row.get("indexFieldHex"),
                "tableCount": row.get("tableCount"),
                "targets": row.get("targets") or [],
            }
        )
    if category in {"child-write", "write"}:
        brief.update(
            {
                "destHex": row.get("destHex"),
                "sourceHex": row.get("sourceHex"),
                "modeHex": row.get("modeHex"),
                "opName": row.get("opName"),
                "immHex": row.get("immHex"),
                "immFixed": row.get("immFixed"),
            }
        )
    if category == "placement-expr":
        brief.update({"bytes": row.get("bytes")})
    if category == "actor-flags":
        brief.update({"maskHex": row.get("maskHex"), "mode": row.get("mode")})
    return {key: value for key, value in brief.items() if value not in (None, "", [])}


def collect_random_flows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    flows: list[dict[str, Any]] = []
    for index, row in enumerate(rows):
        if row.get("category") != "random-range":
            continue
        following: list[dict[str, Any]] = []
        for next_row in rows[index + 1 : index + 10]:
            category = next_row.get("category")
            if category == "random-range":
                break
            if category in FLOW_AFTER_RANDOM_CATEGORIES:
                following.append(row_brief(next_row))
            if len(following) >= 8:
                break
        flows.append({**row_brief(row), "following": following})
    return flows


def build_signature(helper_row: dict[str, Any]) -> str:
    randoms = [row.get("randomRangeHex") for row in helper_row["randomRanges"]]
    motions = [row.get("modeHex") for row in helper_row["motionSteps"]]
    clears = [row.get("maskHex") for row in helper_row["clearDisplayLists"]]
    switches = [row.get("targetVaHex") for row in helper_row["switchControls"]]
    jumps = [f"{row.get('indexFieldHex')}:{row.get('tableCount')}" for row in helper_row["jumpTables"]]
    placements = [row.get("bytes") or row.get("summary") for row in helper_row["placementExprs"]]
    return "|".join(
        [
            "RNG=" + ",".join(str(item) for item in randoms),
            "M=" + ",".join(str(item) for item in motions),
            "CLR=" + ",".join(str(item) for item in clears),
            "S=" + ",".join(str(item) for item in switches),
            "J=" + ",".join(str(item) for item in jumps),
            "P=" + ",".join(str(item) for item in placements),
        ]
    )


def build() -> dict[str, Any]:
    child = read_json(HELPER_CHILD_JSON)
    opcode = read_json(HELPER_OPCODE_JSON)
    runtime = read_json(HELPER_RUNTIME_JSON)

    opcode_by_helper: defaultdict[int, list[dict[str, Any]]] = defaultdict(list)
    for row in opcode.get("rows") or []:
        for helper_id in row.get("helperIds") or []:
            opcode_by_helper[int(helper_id)].append(row)

    runtime_by_helper = {
        int(row.get("helperId")): row
        for row in runtime.get("runtimeRows") or []
        if row.get("helperId") is not None
    }

    helper_rows: list[dict[str, Any]] = []
    random_group_rows_map: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
    motion_groups: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
    clear_list_groups: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
    switch_groups: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
    jump_table_groups: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
    pattern_groups: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
    random_write_dest_counts: Counter[str] = Counter()
    category_counts: Counter[str] = Counter()

    for helper in child.get("helperRows") or []:
        helper_id = int(helper["helperId"])
        decoded_rows = (helper.get("decoded") or {}).get("rows") or []
        skills = helper_skills(helper)
        interesting = [row_brief(row) for row in decoded_rows if row.get("category") in INTERESTING_CATEGORIES]
        random_ranges = [row_brief(row) for row in decoded_rows if row.get("category") == "random-range"]
        motions = [row_brief(row) for row in decoded_rows if row.get("category") == "motion-step"]
        clears = [row_brief(row) for row in decoded_rows if row.get("category") == "clear-display-list"]
        screen_helpers = [row_brief(row) for row in decoded_rows if row.get("category") == "screen-helper"]
        switches = [row_brief(row) for row in decoded_rows if row.get("category") == "switch/control"]
        spawns = [row_brief(row) for row in decoded_rows if row.get("category") == "spawn-child-vm"]
        jump_tables = [row_brief(row) for row in decoded_rows if row.get("category") == "indexed-jump-table"]
        calls = [row_brief(row) for row in decoded_rows if row.get("category") == "call-subscript"]
        placements = [row_brief(row) for row in decoded_rows if row.get("category") == "placement-expr"]
        frame_scripts = [row_brief(row) for row in decoded_rows if row.get("category") == "frame-script-pointer"]
        random_flows = collect_random_flows(decoded_rows)

        for row in decoded_rows:
            category_counts[row.get("category") or "unknown"] += 1

        helper_row = {
            "helperId": helper_id,
            "functionVaHex": helper.get("functionVaHex"),
            "bodyClass": helper.get("bodyClass"),
            "childScriptVaHex": helper.get("childScriptVaHex"),
            "skills": skills,
            "skillLabels": [skill_label(skill) for skill in skills],
            "randomRangeCount": len(random_ranges),
            "motionStepsCount": len(motions),
            "clearDisplayListCount": len(clears),
            "screenLikeHelperCount": len(screen_helpers),
            "switchControlCount": len(switches),
            "spawnChildVmCount": len(spawns),
            "jumpTableCount": len(jump_tables),
            "randomRanges": random_ranges,
            "motionSteps": motions,
            "clearDisplayLists": clears,
            "screenLikeHelpers": screen_helpers,
            "switchControls": switches,
            "spawnChildVms": spawns,
            "jumpTables": jump_tables,
            "callSubscripts": calls,
            "placementExprs": placements,
            "frameScriptTargets": frame_scripts,
            "randomFlows": random_flows,
            "interestingRows": interesting,
            "runtimeSignals": (runtime_by_helper.get(helper_id) or {}).get("signals") or [],
            "opcodeOwners": [
                {
                    "ownerName": row.get("ownerName"),
                    "skillName": row.get("skillName"),
                    "skillIdHex": row.get("skillIdHex"),
                    "positionClass": row.get("positionClass"),
                    "renderTrack": row.get("renderTrack"),
                }
                for row in opcode_by_helper.get(helper_id, [])
            ],
        }
        helper_row["signature"] = build_signature(helper_row)
        helper_rows.append(helper_row)

        for random_range in random_ranges:
            key = random_range.get("randomRangeHex") or as_hex(random_range.get("randomRange"), 4)
            random_group_rows_map[key].append(helper_row)
        for flow in random_flows:
            for follow in flow.get("following") or []:
                if follow.get("category") in {"child-write", "write"} and follow.get("destHex"):
                    random_write_dest_counts[f"{flow.get('randomRangeHex')} -> {follow.get('destHex')}"] += 1
        for motion in motions:
            motion_groups[motion.get("modeHex") or as_hex(motion.get("mode"))].append(helper_row)
        for clear in clears:
            clear_list_groups[f"slot={clear.get('listIndex')} mask={clear.get('maskHex')}"].append(helper_row)
        for switch in switches:
            switch_groups[switch.get("targetVaHex") or "-"].append(helper_row)
        for jump in jump_tables:
            jump_table_groups[f"{jump.get('indexFieldHex')} count={jump.get('tableCount')}"].append(helper_row)
        pattern_groups[helper_row["signature"]].append(helper_row)

    def summarize_group(items: list[dict[str, Any]]) -> dict[str, Any]:
        skills = []
        helper_ids = []
        body_classes = []
        randoms = []
        motions = []
        clears = []
        for item in items:
            helper_ids.append(item["helperId"])
            body_classes.append(item.get("bodyClass"))
            skills.extend(item.get("skillLabels") or [])
            randoms.extend(random_range.get("randomRangeHex") for random_range in item.get("randomRanges") or [])
            motions.extend(motion.get("modeHex") for motion in item.get("motionSteps") or [])
            clears.extend(clear.get("maskHex") for clear in item.get("clearDisplayLists") or [])
        return {
            "count": len(items),
            "helperIds": sorted(set(helper_ids)),
            "bodyClasses": sorted(set(filter(None, body_classes))),
            "skills": sorted(set(filter(None, skills))),
            "randomRanges": sorted(set(filter(None, randoms))),
            "motionModes": sorted(set(filter(None, motions))),
            "clearMasks": sorted(set(filter(None, clears))),
        }

    random_group_rows = []
    for key, items in sorted(random_group_rows_map.items(), key=lambda kv: (int(kv[0], 16) if kv[0].startswith("0x") else 999999, kv[0])):
        row = {"randomRangeHex": key, **summarize_group(items)}
        random_group_rows.append(row)

    motion_group_rows = []
    for key, items in sorted(motion_groups.items()):
        motion_group_rows.append({"modeHex": key, **summarize_group(items)})

    clear_group_rows = []
    for key, items in sorted(clear_list_groups.items()):
        clear_group_rows.append({"clearList": key, **summarize_group(items)})

    switch_group_rows = []
    for key, items in sorted(switch_groups.items()):
        switch_group_rows.append({"targetVaHex": key, **summarize_group(items)})

    jump_group_rows = []
    for key, items in sorted(jump_table_groups.items()):
        jump_group_rows.append({"jumpTable": key, **summarize_group(items)})

    pattern_group_rows = []
    for signature, items in sorted(pattern_groups.items(), key=lambda kv: (-len(kv[1]), kv[0])):
        if len(items) < 2 and not any(item.get("randomRanges") or item.get("motionSteps") or item.get("clearDisplayLists") for item in items):
            continue
        pattern_group_rows.append({"signature": signature, **summarize_group(items)})

    report = {
        "version": 1,
        "kind": "hwanse-battle-helper-random-motion-control-review",
        "source": [
            "out/battle_helper_child_script_review.json",
            "out/battle_helper_opcode_review.json",
            "out/battle_helper_effect_runtime_review.json",
        ],
        "status": "random-motion-control-flow-static-review",
        "runtimeUsed": False,
        "summary": {
            "helperRows": len(helper_rows),
            "helpersWithRandomRange": sum(1 for row in helper_rows if row["randomRanges"]),
            "helpersWithMotionStep": sum(1 for row in helper_rows if row["motionSteps"]),
            "helpersWithClearDisplayList": sum(1 for row in helper_rows if row["clearDisplayLists"]),
            "helpersWithSwitchControl": sum(1 for row in helper_rows if row["switchControls"]),
            "helpersWithSpawnChildVm": sum(1 for row in helper_rows if row["spawnChildVms"]),
            "helpersWithJumpTable": sum(1 for row in helper_rows if row["jumpTables"]),
            "randomRangeCount": len(random_group_rows),
            "motionModeCount": len(motion_group_rows),
            "clearListPatternCount": len(clear_group_rows),
            "signatureGroupCount": len(pattern_group_rows),
            "categoryCounts": dict(sorted(category_counts.items())),
            "randomWriteDestCounts": dict(random_write_dest_counts.most_common()),
        },
        "interpretationNotes": [
            "Correction: opcode 0x2b is not a visual resource loader. Handler disassembly shows it calls RNG helper 0x427730 with the script word as range/modulo and stores the result into display field +0x58.",
            "Random ranges are often followed by child/write opcodes that copy +0x58 into size, motion, or effect parameter fields. The following rows are retained so the browser runner can promote only local, evidenced flows.",
            "0x2d is a confirmed child-display motion step. Mode group 0x00 is relative trig movement, while group 0x08 writes absolute/base-relative trig positions.",
            "0x4b is a display-list cleanup/free instruction. It walks a display object list slot and frees objects whose kind/priority mask matches the operand.",
            "0x07 spawns a child display VM object and stores it in +0x58; 0x0a is an indexed jump table by a display-object byte field.",
            "This report deliberately excludes damage, hit/miss, critical, and final result sound formulas. Those remain separate battle-core logic work.",
        ],
        "randomRangeGroups": random_group_rows,
        "motionGroups": motion_group_rows,
        "clearDisplayListGroups": clear_group_rows,
        "switchControlGroups": switch_group_rows,
        "jumpTableGroups": jump_group_rows,
        "patternGroups": pattern_group_rows,
        "helperRows": helper_rows,
    }
    return report


def write_json(report: dict[str, Any]) -> None:
    (OUT / "battle_helper_resource_flow_review.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )


def write_md(report: dict[str, Any]) -> None:
    summary = report["summary"]
    lines = [
        "# Battle Helper Random/Motion/Control Review",
        "",
        "## Summary",
        "",
        f"- helper rows: {summary['helperRows']}",
        f"- helpers with RNG range: {summary['helpersWithRandomRange']}",
        f"- helpers with motion-step: {summary['helpersWithMotionStep']}",
        f"- helpers with display-list cleanup: {summary['helpersWithClearDisplayList']}",
        f"- helpers with switch/control: {summary['helpersWithSwitchControl']}",
        f"- helpers with jump-table: {summary['helpersWithJumpTable']}",
        "",
        "## Notes",
        "",
    ]
    lines.extend(f"- {note}" for note in report["interpretationNotes"])
    lines.extend(["", "## RNG Range Groups", "", "| Range | Helpers | Skills | Motion | Cleanup |", "|---|---:|---|---|---|"])
    for row in report["randomRangeGroups"]:
        lines.append(
            f"| {row['randomRangeHex']} | {row['count']} | {compact(row['skills'], 8)} | {compact(row['motionModes'])} | {compact(row['clearMasks'])} |"
        )
    lines.extend(["", "## Motion Groups", "", "| Mode | Helpers | Skills | RNG ranges |", "|---|---:|---|---|"])
    for row in report["motionGroups"]:
        lines.append(f"| {row['modeHex']} | {row['count']} | {compact(row['skills'], 8)} | {compact(row['randomRanges'])} |")
    lines.extend(["", "## Pattern Groups", "", "| Count | Signature | Helpers | Skills |", "|---:|---|---|---|"])
    for row in report["patternGroups"][:80]:
        lines.append(
            f"| {row['count']} | `{row['signature']}` | {compact(row['helperIds'])} | {compact(row['skills'], 8)} |"
        )
    (OUT / "battle_helper_resource_flow_review.md").write_text("\n".join(lines) + "\n", encoding="utf-8")


def td_list(values: list[Any], limit: int = 10) -> str:
    if not values:
        return "<span class=\"muted\">-</span>"
    shown = values[:limit]
    extra = len(values) - len(shown)
    text = "<br>".join(esc(value) for value in shown)
    if extra > 0:
        text += f"<br><span class=\"muted\">... +{extra}</span>"
    return text


def write_html(report: dict[str, Any]) -> None:
    summary = report["summary"]

    def metric(label: str, value: Any) -> str:
        return f"<div class=\"metric\"><strong>{esc(value)}</strong><span>{esc(label)}</span></div>"

    def group_table(title: str, rows: list[dict[str, Any]], key_field: str) -> str:
        body = []
        for row in rows:
            body.append(
                "<tr>"
                f"<td><code>{esc(row.get(key_field))}</code></td>"
                f"<td>{esc(row.get('count'))}</td>"
                f"<td>{td_list(row.get('helperIds') or [], 16)}</td>"
                f"<td>{td_list(row.get('skills') or [], 12)}</td>"
                f"<td>{td_list(row.get('randomRanges') or [], 12)}</td>"
                f"<td>{td_list(row.get('motionModes') or [], 12)}</td>"
                f"<td>{td_list(row.get('clearMasks') or [], 12)}</td>"
                "</tr>"
            )
        return (
            f"<section><h2>{esc(title)}</h2>"
            "<div class=\"table-wrap\"><table><thead><tr>"
            f"<th>{esc(key_field)}</th><th>count</th><th>helpers</th><th>skills</th><th>RNG ranges</th><th>motion</th><th>cleanup masks</th>"
            "</tr></thead><tbody>"
            + "\n".join(body)
            + "</tbody></table></div></section>"
        )

    helper_body = []
    for row in report["helperRows"]:
        if not (row["randomRanges"] or row["motionSteps"] or row["clearDisplayLists"] or row["switchControls"] or row["jumpTables"]):
            continue
        jump_labels = [
            f"{item.get('indexFieldHex')} count={item.get('tableCount')}"
            for item in row.get("jumpTables") or []
        ]
        helper_body.append(
            "<tr>"
            f"<td><code>{esc(row['helperId'])}</code><br><span class=\"muted\">{esc(row.get('bodyClass'))}</span></td>"
            f"<td>{td_list(row.get('skillLabels') or [], 10)}</td>"
            f"<td>{td_list([item.get('randomRangeHex') for item in row.get('randomRanges') or []], 12)}</td>"
            f"<td>{td_list([item.get('modeHex') for item in row.get('motionSteps') or []], 12)}</td>"
            f"<td>{td_list([item.get('maskHex') for item in row.get('clearDisplayLists') or []], 12)}</td>"
            f"<td>{td_list([item.get('targetVaHex') for item in row.get('switchControls') or []], 12)}</td>"
            f"<td>{td_list(jump_labels, 8)}</td>"
            f"<td><details><summary>flow</summary><pre>{esc(json.dumps(row.get('randomFlows') or row.get('interestingRows') or [], ensure_ascii=False, indent=2))}</pre></details></td>"
            "</tr>"
        )

    pattern_body = []
    for row in report["patternGroups"]:
        pattern_body.append(
            "<tr>"
            f"<td>{esc(row['count'])}</td>"
            f"<td><code>{esc(row['signature'])}</code></td>"
            f"<td>{td_list(row.get('helperIds') or [], 16)}</td>"
            f"<td>{td_list(row.get('skills') or [], 12)}</td>"
            "</tr>"
        )

    html_text = 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>전투 helper random/motion/control</title>
    <style>
      :root {{ color-scheme: light; --bg:#f6f7f9; --fg:#17202a; --muted:#657282; --line:#d7dde6; --head:#eef2f6; --link:#185abc; }}
      * {{ box-sizing: border-box; }}
      body {{ margin:0; background:var(--bg); color:var(--fg); font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; line-height:1.45; }}
      main {{ max-width:1480px; margin:0 auto; padding:18px; }}
      header {{ display:flex; justify-content:space-between; align-items:flex-start; gap:14px; margin-bottom:14px; }}
      h1 {{ margin:0 0 6px; font-size:24px; }}
      h2 {{ margin:0; font-size:18px; }}
      section {{ margin:14px 0; border:1px solid var(--line); border-radius:8px; background:white; overflow:hidden; }}
      section h2 {{ padding:12px 14px; background:var(--head); border-bottom:1px solid var(--line); }}
      nav {{ display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; }}
      a {{ color:var(--link); text-decoration:none; font-weight:600; }}
      a:hover {{ text-decoration:underline; }}
      .sub, .muted {{ color:var(--muted); }}
      .metrics {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:10px; margin:12px 0; }}
      .metric {{ border:1px solid var(--line); border-radius:7px; background:white; padding:10px; }}
      .metric strong {{ display:block; font-size:22px; line-height:1.1; }}
      .metric span {{ display:block; color:var(--muted); font-size:12px; margin-top:4px; }}
      .notes {{ background:white; border:1px solid var(--line); border-radius:8px; padding:12px 16px; }}
      .notes li {{ margin:5px 0; }}
      .table-wrap {{ overflow:auto; }}
      table {{ width:100%; border-collapse:collapse; min-width:980px; }}
      th,td {{ padding:8px 10px; border-bottom:1px solid var(--line); text-align:left; vertical-align:top; font-size:13px; }}
      th {{ background:#f8fafc; color:#344050; position:sticky; top:0; z-index:1; }}
      code {{ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:12px; }}
      pre {{ margin:8px 0 0; max-height:360px; overflow:auto; white-space:pre-wrap; font-size:12px; }}
      summary {{ cursor:pointer; color:var(--link); font-weight:700; }}
    </style>
  </head>
  <body>
    <main>
      <header>
        <div>
          <h1>전투 helper random/motion/control</h1>
          <p class="sub">0xbd helper child VM의 RNG range, 이동 스텝, display-list cleanup, 분기/점프 테이블을 기술별로 묶은 정적 근거. 파일명은 기존 링크 호환을 위해 유지한다.</p>
        </div>
        <nav>
          <a href="../web/index.html">홈</a>
          <a href="../web/battle_simulator.html">전투 기술 실행</a>
          <a href="battle_helper_child_script_review.html">child script</a>
          <a href="battle_helper_sync_timing_review.html">sync timing</a>
          <a href="battle_effect_semantics_review.html">이펙트 의미</a>
          <a href="battle_helper_resource_flow_review.json">JSON</a>
          <a href="battle_helper_resource_flow_review.md">MD</a>
        </nav>
      </header>
      <div class="metrics">
        {metric('helper rows', summary['helperRows'])}
        {metric('RNG-range helpers', summary['helpersWithRandomRange'])}
        {metric('motion-step helpers', summary['helpersWithMotionStep'])}
        {metric('cleanup helpers', summary['helpersWithClearDisplayList'])}
        {metric('switch/control helpers', summary['helpersWithSwitchControl'])}
        {metric('jump-table helpers', summary['helpersWithJumpTable'])}
        {metric('RNG ranges', summary['randomRangeCount'])}
        {metric('motion modes', summary['motionModeCount'])}
      </div>
      <ul class="notes">
        {''.join(f'<li>{esc(note)}</li>' for note in report['interpretationNotes'])}
      </ul>
      {group_table('RNG range groups', report['randomRangeGroups'], 'randomRangeHex')}
      {group_table('Motion mode groups', report['motionGroups'], 'modeHex')}
      {group_table('Display-list cleanup groups', report['clearDisplayListGroups'], 'clearList')}
      {group_table('Switch/control target groups', report['switchControlGroups'], 'targetVaHex')}
      {group_table('Jump table groups', report['jumpTableGroups'], 'jumpTable')}
      <section>
        <h2>Pattern groups</h2>
        <div class="table-wrap"><table><thead><tr><th>count</th><th>signature</th><th>helpers</th><th>skills</th></tr></thead><tbody>
          {''.join(pattern_body)}
        </tbody></table></div>
      </section>
      <section>
        <h2>Helper rows</h2>
        <div class="table-wrap"><table><thead><tr><th>helper</th><th>skills</th><th>RNG ranges</th><th>motion</th><th>cleanup</th><th>switch</th><th>jump table</th><th>random flow</th></tr></thead><tbody>
          {''.join(helper_body)}
        </tbody></table></div>
      </section>
    </main>
  </body>
</html>
"""
    (OUT / "battle_helper_resource_flow_review.html").write_text(html_text, encoding="utf-8")


def main() -> None:
    report = build()
    write_json(report)
    write_md(report)
    write_html(report)
    print(json.dumps(report["summary"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
