#!/usr/bin/env python3
"""Review static helper parent/child transform coverage for battle previews.

This report does not execute the game.  It combines the monster action helper
preview scripts with the decoded helper position/motion VM writes, then checks
which preview scripts have enough static coordinate writes to be placed by a
browser runner instead of using a generic fallback anchor.
"""
from __future__ import annotations

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

from build_battle_helper_child_script_review import (
    EXE,
    decode_child_instruction,
    hex32,
    pointer_to_static,
    read_bytes,
    read_sections,
    u32,
)


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

ACTION_EFFECT = OUT / "battle_monster_action_effect_review.json"
POSITION_MOTION = OUT / "battle_helper_position_motion_review.json"
DISPLAY_HANDLER_TABLE_VA = 0x00440538

COORD_FIELDS = {"0x1c", "0x20", "0x58", "0x68", "0x6c", "0x74", "0x78", "0x80", "0x84", "0x88"}
OPERATION_NAMES = {
    0x0: "set",
    0x1: "add",
    0x2: "sub",
    0x3: "mul",
    0x4: "div",
    0x5: "mod",
    0x6: "and",
    0x7: "or",
    0x8: "xor",
    0x9: "not",
    0xA: "neg",
    0xB: "shl",
    0xC: "shr",
}


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


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


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


def display_handler_va(exe_data: bytes, sections: list[dict[str, Any]], opcode: int) -> str:
    raw = read_bytes(exe_data, sections, DISPLAY_HANDLER_TABLE_VA + opcode * 4, 4)
    return hex32(pointer_to_static(u32(raw, 0)))


def handler_semantics_review(exe_data: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    return {
        "handlerTableVaHex": hex32(DISPLAY_HANDLER_TABLE_VA),
        "handlerRows": [
            {
                "opcode": "0x11",
                "handlerVaHex": display_handler_va(exe_data, sections, 0x11),
                "meaning": "child field arithmetic write; low nibble dispatches through arithmetic helper 0x00402f6f",
            },
            {
                "opcode": "0x12",
                "handlerVaHex": display_handler_va(exe_data, sections, 0x12),
                "meaning": "display/actor field arithmetic write; same arithmetic helper, dword-capable field writes",
            },
            {
                "opcode": "0x13",
                "handlerVaHex": display_handler_va(exe_data, sections, 0x13),
                "meaning": "byte-width conditional branch; source groups 0x40/0x80/0xc0 read global temp/current display/parent actor",
            },
            {
                "opcode": "0x14",
                "handlerVaHex": display_handler_va(exe_data, sections, 0x14),
                "meaning": "word-width conditional branch; if condition helper 0x0040370a passes, script cursor jumps to the embedded target VA, otherwise advances",
            },
            {
                "opcode": "0x15",
                "handlerVaHex": display_handler_va(exe_data, sections, 0x15),
                "meaning": "dword-width conditional branch; same branch helper and source groups as 0x13/0x14",
            },
            {
                "opcode": "0x2d",
                "handlerVaHex": display_handler_va(exe_data, sections, 0x2D),
                "meaning": "motion step; low bits select x/y/z axes and high group 0x00 vs 0x08 selects relative vs target-base motion",
            },
        ],
        "arithmeticHelper": {
            "handlerVaHex": "0x00402f6f",
            "operations": [
                {"lowNibble": "0x0", "operation": "set", "formula": "left = right"},
                {"lowNibble": "0x1", "operation": "add", "formula": "left += right"},
                {"lowNibble": "0x2", "operation": "sub", "formula": "left -= right"},
                {"lowNibble": "0x3", "operation": "mul", "formula": "left *= right"},
                {"lowNibble": "0x4", "operation": "div", "formula": "left = floor(left / right)"},
                {"lowNibble": "0x5", "operation": "mod", "formula": "left = left % right"},
                {"lowNibble": "0x6", "operation": "and", "formula": "left &= right"},
                {"lowNibble": "0x7", "operation": "or", "formula": "left |= right"},
                {"lowNibble": "0x8", "operation": "xor", "formula": "left ^= right"},
                {"lowNibble": "0x9", "operation": "not", "formula": "left = ~right"},
                {"lowNibble": "0xa", "operation": "neg", "formula": "left = -right"},
                {"lowNibble": "0xb", "operation": "shl", "formula": "left <<= right"},
                {"lowNibble": "0xc", "operation": "shr", "formula": "left >>= right"},
            ],
        },
        "conditionHelper": {
            "handlerVaHex": "0x0040370a",
            "operations": [
                {"lowNibble": "0x0", "operation": "!=", "formula": "left != right"},
                {"lowNibble": "0x1", "operation": "==", "formula": "left == right"},
                {"lowNibble": "0x2", "operation": ">", "formula": "left > right"},
                {"lowNibble": "0x3", "operation": "<", "formula": "left < right"},
                {"lowNibble": "0x4", "operation": ">=", "formula": "left >= right"},
                {"lowNibble": "0x5", "operation": "<=", "formula": "left <= right"},
                {"lowNibble": "0x6", "operation": "bit-test", "formula": "(left & right) != 0"},
                {"lowNibble": "0x7", "operation": "or-nonzero", "formula": "(left | right) != 0"},
                {"lowNibble": "0x8", "operation": "xor-nonzero", "formula": "(left ^ right) != 0"},
                {"lowNibble": "0x9", "operation": "nonzero", "formula": "left != 0"},
            ],
            "branchHandlers": [
                {"opcode": "0x13", "width": "byte", "immediateLayout": "target at +4"},
                {"opcode": "0x14", "width": "word", "immediateLayout": "right word at +4, target at +8; source-field target at +4"},
                {"opcode": "0x15", "width": "dword", "immediateLayout": "right dword at +4, target at +8; source-field target at +4"},
            ],
        },
        "motionStepFormula": {
            "handlerVaHex": display_handler_va(exe_data, sections, 0x2D),
            "trigXHelperVaHex": "0x00428070",
            "trigYHelperVaHex": "0x0042819b",
            "relativeGroup0x00": [
                "if mode&0x01: display.x += trigX(+0x8c) * +0x92",
                "if mode&0x02: display.y -= trigY(+0x8e) * +0x94",
                "if mode&0x04: display.z(+0x24) += trigY(+0x90) * +0x96",
            ],
            "absoluteGroup0x08": [
                "if mode&0x01: display.x = +0x80 + trigX(+0x8c) * +0x92",
                "if mode&0x02: display.y = +0x84 - trigY(+0x8e) * +0x94",
                "if mode&0x04: display.z(+0x24) = +0x88 + trigY(+0x90) * +0x96",
            ],
            "notes": [
                "trig helpers read 16-bit phase fields and table data near 0x004cb300/0x004cc300.",
                "0x2d always advances the child script cursor by 4 bytes after applying selected axes.",
            ],
        },
    }


def vm_number(value: Any) -> float:
    try:
        number = float(value)
    except (TypeError, ValueError):
        return 0.0
    return 0.0 if abs(number) < 0.01 else number


def vm_operation(write: dict[str, Any]) -> str:
    explicit = str(write.get("operation") or "").lower()
    if explicit in {"set", "add", "sub", "mul", "div"}:
        return explicit
    try:
        low = int(str(write.get("modeHex") or "0"), 16) & 0x0F
    except ValueError:
        return "unknown"
    return OPERATION_NAMES.get(low, "unknown")


def vm_operand(write: dict[str, Any], fields: dict[str, float], raw_integer: bool = False) -> float:
    source = str(write.get("sourceHex") or "").lower()
    if source and source != "0x00" and source in fields:
        return vm_number(fields[source])
    if raw_integer and write.get("immHex"):
        try:
            return float(int(str(write.get("immHex")), 16))
        except ValueError:
            pass
    if write.get("immFixed") is not None:
        return vm_number(write.get("immFixed"))
    return 0.0


def apply_write(write: dict[str, Any], fields: dict[str, float], changed: set[str]) -> bool:
    dest = str(write.get("destHex") or "").lower()
    if dest not in fields:
        return False
    op = vm_operation(write)
    if op == "unknown":
        return False
    raw_integer = op in {"mod", "and", "or", "xor", "not", "neg", "shl", "shr"}
    operand = vm_operand(write, fields, raw_integer=raw_integer)
    before = vm_number(fields[dest])
    after = before
    if op == "set":
        after = operand
    elif op == "add":
        after = before + operand
    elif op == "sub":
        after = before - operand
    elif op == "mul":
        after = before * operand
    elif op == "div":
        after = before / operand if operand else before
    elif op == "mod":
        after = float(int(before) % int(operand)) if operand else before
    elif op == "and":
        after = float(int(before) & int(operand))
    elif op == "or":
        after = float(int(before) | int(operand))
    elif op == "xor":
        after = float(int(before) ^ int(operand))
    elif op == "not":
        after = float(~int(operand))
    elif op == "neg":
        after = -operand
    elif op == "shl":
        after = float(int(before) << max(0, int(operand)))
    elif op == "shr":
        after = float(int(before) >> max(0, int(operand)))
    fields[dest] = after
    changed.add(dest)
    return abs(after - before) >= 0.01 or abs(operand) >= 0.01


def visual_path(script: dict[str, Any]) -> str:
    return str(script.get("sourceLabel") or "").split()[0] if script.get("sourceLabel") else ""


def normalized_scope(scope_name: str) -> str:
    import re

    return re.sub(r"\s+0x[0-9a-f]+", "", str(scope_name or ""), flags=re.I)


def visual_scope_key(path: str) -> str:
    return str(path or "").replace("root", "spawn", 1)


def scope_matches(scope: dict[str, Any], path_key: str) -> bool:
    normalized = normalized_scope(str(scope.get("scope") or ""))
    return normalized == path_key or normalized.startswith(f"{path_key}.")


def scope_chain(position_row: dict[str, Any], script: dict[str, Any]) -> list[dict[str, Any]]:
    scopes = position_row.get("scopes") or []
    chain: list[dict[str, Any]] = []

    def add(scope: dict[str, Any] | None) -> None:
        if scope and scope not in chain:
            chain.append(scope)

    add(next((scope for scope in scopes if scope.get("scope") == "root"), None))
    path = visual_path(script)
    if path.startswith("root."):
        parts = path.split(".")[1:]
        for index in range(len(parts)):
            key = f"spawn.{'.'.join(parts[: index + 1])}"
            add(
                next((scope for scope in scopes if normalized_scope(str(scope.get("scope") or "")) == key), None)
                or next((scope for scope in scopes if scope_matches(scope, key)), None)
            )
    if not chain:
        key = visual_scope_key(path)
        add(next((scope for scope in scopes if scope_matches(scope, key)), None))
    return chain


def unique_writes(scopes: list[dict[str, Any]]) -> list[dict[str, Any]]:
    writes: list[dict[str, Any]] = []
    seen: set[str] = set()
    for scope in scopes:
        for write in scope.get("positionWrites") or []:
            key = "|".join(
                str(write.get(part) or "")
                for part in ("vaHex", "opcode", "destHex", "modeHex", "sourceHex", "immHex")
            )
            if key in seen:
                continue
            seen.add(key)
            writes.append(write)
    return writes


def solve_transform(position_row: dict[str, Any] | None, script: dict[str, Any]) -> dict[str, Any]:
    if not position_row:
        return {"status": "missing-position-row", "scopeChain": [], "changedFields": [], "fields": {}}
    chain = scope_chain(position_row, script)
    writes = unique_writes(chain)
    if not writes:
        return {"status": "parent-anchor-frameScript", "scopeChain": [scope.get("scope") for scope in chain], "changedFields": [], "fields": {}}
    fields = {field: 0.0 for field in COORD_FIELDS}
    changed: set[str] = set()
    meaningful = False
    for write in writes:
        meaningful = apply_write(write, fields, changed) or meaningful
    coord_changed = bool(changed & {"0x1c", "0x20", "0x80", "0x84", "0x74", "0x78", "0x68", "0x6c"})
    if coord_changed:
        status = "vm-transform"
    elif meaningful:
        status = "non-coordinate-transform"
    else:
        status = "zero-or-control-write"
    parent_anchor = next((scope.get("anchor") for scope in reversed(chain) if scope.get("anchor")), "-")
    return {
        "status": status,
        "scopeChain": [scope.get("scope") for scope in chain],
        "parentAnchor": parent_anchor,
        "changedFields": sorted(changed),
        "fields": {key: round(value, 3) for key, value in sorted(fields.items()) if key in changed},
        "writeCount": len(writes),
    }


def mode_prefix(write: dict[str, Any]) -> str:
    try:
        return f"0x{(int(str(write.get('modeHex') or '0'), 16) & 0xF0) >> 4:x}"
    except ValueError:
        return "unknown"


def source_space_candidate(write: dict[str, Any], kind: str) -> str:
    operand = str(write.get("operandClass") or "")
    prefix = mode_prefix(write)
    if "immediate" in operand:
        return "immediate/control"
    if kind == "motionWrites":
        return "child-motion-local-field"
    if kind == "positionWrites":
        if prefix == "0xa":
            return "target/base actor source candidate"
        if prefix == "0xb":
            return "current/secondary actor source candidate"
        if prefix == "0xe":
            return "parent/current display source candidate"
        if prefix == "0xf":
            return "nested/rare display source candidate"
    return "local-field-or-unknown-source"


def source_space_summary(position_rows: list[dict[str, Any]]) -> dict[str, Any]:
    prefix_counter: Counter[str] = Counter()
    candidate_counter: Counter[str] = Counter()
    pair_counter: Counter[str] = Counter()
    examples: dict[str, list[dict[str, Any]]] = {}
    delta_pairs = 0
    for row in position_rows:
        for scope in row.get("scopes") or []:
            by_dest: dict[str, list[dict[str, Any]]] = {}
            for kind in ("positionWrites", "motionWrites"):
                for write in scope.get(kind) or []:
                    prefix = mode_prefix(write)
                    candidate = source_space_candidate(write, kind)
                    prefix_counter[f"{kind}:{prefix}"] += 1
                    candidate_counter[candidate] += 1
                    pair = f"{kind}:{prefix}:{write.get('operation')}:{write.get('destHex')}<-{write.get('sourceHex')}"
                    pair_counter[pair] += 1
                    examples.setdefault(candidate, [])
                    if len(examples[candidate]) < 5:
                        examples[candidate].append(
                            {
                                "helperId": row.get("helperId"),
                                "scope": scope.get("scope"),
                                "kind": kind,
                                "modeHex": write.get("modeHex"),
                                "destHex": write.get("destHex"),
                                "sourceHex": write.get("sourceHex"),
                                "operation": write.get("operation"),
                                "summary": write.get("summary"),
                            }
                        )
                    if kind == "positionWrites":
                        by_dest.setdefault(str(write.get("destHex") or "").lower(), []).append(write)
            for writes in by_dest.values():
                has_target_set = any(
                    mode_prefix(write) == "0xa" and write.get("operation") == "set"
                    for write in writes
                )
                has_actor_sub = any(
                    mode_prefix(write) == "0xb" and write.get("operation") == "sub"
                    for write in writes
                )
                if has_target_set and has_actor_sub:
                    delta_pairs += 1
    return {
        "modePrefixCounts": compact_counter(prefix_counter),
        "sourceSpaceCandidateCounts": compact_counter(candidate_counter),
        "topModeOperationPairs": compact_counter(pair_counter)[:40],
        "targetMinusActorDeltaPairs": delta_pairs,
        "examples": examples,
    }


def helper_action_index(action_effect: dict[str, Any]) -> dict[int, list[dict[str, Any]]]:
    """Map helper id to the monster actions that call it."""
    index: dict[int, list[dict[str, Any]]] = defaultdict(list)
    for action in action_effect.get("rows") or []:
        for helper in action.get("helpers") or []:
            try:
                helper_id = int(helper.get("helperId") or int(str(helper.get("helperIdHex") or "0"), 16))
            except (TypeError, ValueError):
                continue
            sample = {
                "enemyName": action.get("enemyName"),
                "cns": action.get("cns"),
                "actionName": action.get("sharedActionName"),
                "visibleSlotHex": action.get("visibleSlotHex"),
                "helperClass": helper.get("helperClass") or helper.get("bodyClass"),
            }
            if sample not in index[helper_id]:
                index[helper_id].append(sample)
    return index


def first_examples(values: list[Any], limit: int = 8) -> list[Any]:
    out: list[Any] = []
    for value in values:
        if value in out:
            continue
        out.append(value)
        if len(out) >= limit:
            break
    return out


def motion_schedule_review(
    action_effect: dict[str, Any],
    position_motion: dict[str, Any],
    exe_data: bytes,
    sections: list[dict[str, Any]],
) -> dict[str, Any]:
    """Aggregate opcode 0x14 schedules and 0x2d motion modes by helper/action context."""
    action_by_helper = helper_action_index(action_effect)
    helper_by_id = {
        int(row.get("helperId")): row
        for row in position_motion.get("helperRows") or []
        if row.get("helperId") is not None
    }

    target_groups: dict[str, dict[str, Any]] = {}
    mode_counter: Counter[str] = Counter()
    layout_counter: Counter[str] = Counter()
    condition_counter: Counter[str] = Counter()
    mode_target_counter: Counter[str] = Counter()
    helpers_with_op14: set[int] = set()
    helpers_with_motion: set[int] = set()

    for row in position_motion.get("op14ScheduleRows") or []:
        target = str(row.get("targetVaHex") or "-")
        mode = str(row.get("modeHex") or "-")
        layout = str(row.get("layout") or "-")
        condition = f"{row.get('leftSource') or '-'} {row.get('comparison') or '?'} {row.get('rightSource') or '-'}"
        count = int(row.get("count") or 0)
        mode_counter[mode] += count
        layout_counter[layout] += count
        condition_counter[condition] += count
        mode_target_counter[f"{mode}->{target}"] += count
        group = target_groups.setdefault(
            target,
            {
                "targetVaHex": target,
                "count": 0,
                "conditionCounts": Counter(),
                "modeCounts": Counter(),
                "layoutCounts": Counter(),
                "helperIds": set(),
                "helperClasses": Counter(),
                "skillLabels": [],
                "actionSamples": [],
                "examples": [],
            },
        )
        group["count"] += count
        group["conditionCounts"][condition] += count
        group["modeCounts"][mode] += count
        group["layoutCounts"][layout] += count
        for example in row.get("examples") or []:
            helper_id = example.get("helperId")
            if helper_id is None:
                continue
            helper_id = int(helper_id)
            helpers_with_op14.add(helper_id)
            helper = helper_by_id.get(helper_id) or {}
            group["helperIds"].add(helper_id)
            if helper.get("helperClass"):
                group["helperClasses"][helper.get("helperClass")] += 1
            for label in helper.get("skillLabels") or []:
                if label not in group["skillLabels"]:
                    group["skillLabels"].append(label)
            for sample in action_by_helper.get(helper_id, [])[:4]:
                if sample not in group["actionSamples"]:
                    group["actionSamples"].append(sample)
            if len(group["examples"]) < 8:
                group["examples"].append(example)

    target_rows: list[dict[str, Any]] = []
    for group in target_groups.values():
        target_rows.append(
            {
                "targetVaHex": group["targetVaHex"],
                "count": group["count"],
                "helperIds": sorted(group["helperIds"]),
                "helperClasses": compact_counter(group["helperClasses"]),
                "skillLabels": first_examples(group["skillLabels"], 10),
                "actionSamples": group["actionSamples"][:8],
                "conditionCounts": compact_counter(group["conditionCounts"]),
                "modeCounts": compact_counter(group["modeCounts"]),
                "layoutCounts": compact_counter(group["layoutCounts"]),
                "examples": group["examples"],
            }
        )
    target_rows.sort(key=lambda item: (-int(item["count"]), str(item["targetVaHex"])))

    helper_schedule_motion_rows: list[dict[str, Any]] = []
    for helper_id, helper in sorted(helper_by_id.items()):
        motion_modes = dict(helper.get("motionModeCounts") or {})
        if motion_modes:
            helpers_with_motion.add(helper_id)
        op14_targets: Counter[str] = Counter()
        op14_modes: Counter[str] = Counter()
        op14_conditions: Counter[str] = Counter()
        for scope in helper.get("scopes") or []:
            for branch in scope.get("branchRows") or []:
                if branch.get("opcode") != "0x14":
                    continue
                target = str(branch.get("op14TargetVaHex") or branch.get("childTargetVaHex") or branch.get("targetVaHex") or "-")
                op14_targets[target] += 1
                op14_modes[str(branch.get("modeHex") or "-")] += 1
                condition = f"{branch.get('leftSource') or '-'} {branch.get('comparison') or '?'} {branch.get('rightSource') or '-'}"
                op14_conditions[condition] += 1
        if not op14_targets and not motion_modes:
            continue
        helper_schedule_motion_rows.append(
            {
                "helperId": helper_id,
                "helperIdHex": f"0x{helper_id:08x}",
                "helperClass": helper.get("helperClass"),
                "skillLabels": helper.get("skillLabels") or [],
                "actionSamples": action_by_helper.get(helper_id, [])[:8],
                "patterns": helper.get("patterns") or [],
                "op14Targets": compact_counter(op14_targets),
                "op14ModeCounts": compact_counter(op14_modes),
                "op14ConditionCounts": compact_counter(op14_conditions),
                "motionModeCounts": compact_counter(Counter(motion_modes)),
            }
        )

    motion_mode_rows = []
    for row in position_motion.get("motionModeRows") or []:
        helper_ids = [int(value) for value in row.get("helperIds") or []]
        labels: list[str] = []
        action_samples: list[dict[str, Any]] = []
        for helper_id in helper_ids:
            labels.extend(helper_by_id.get(helper_id, {}).get("skillLabels") or [])
            action_samples.extend(action_by_helper.get(helper_id, [])[:2])
        motion_mode_rows.append(
            {
                **row,
                "skillLabels": first_examples(labels, 10),
                "actionSamples": first_examples(action_samples, 8),
            }
        )

    subscript_rows = op14_target_subscript_rows(position_motion, helper_by_id, action_by_helper, exe_data, sections)
    subscript_class_counts = Counter(row["subscriptClass"] for row in subscript_rows)

    return {
        "summary": {
            "op14ScheduleRows": len(position_motion.get("op14ScheduleRows") or []),
            "op14BranchInstances": sum(row.get("count") or 0 for row in position_motion.get("op14ScheduleRows") or []),
            "uniqueOp14Targets": len(target_rows),
            "helpersWithOp14": len(helpers_with_op14),
            "helpersWithMotionStep": len(helpers_with_motion),
            "helpersWithBothOp14AndMotionStep": len(helpers_with_op14 & helpers_with_motion),
            "decodedOp14TargetSubscripts": len(subscript_rows),
            "op14ModeCounts": compact_counter(mode_counter),
            "op14LayoutCounts": compact_counter(layout_counter),
            "op14ConditionCounts": compact_counter(condition_counter),
            "op14TargetSubscriptClassCounts": compact_counter(subscript_class_counts),
        },
        "staticConclusions": [
            "opcode 0x14는 helper child field write가 아니라 word-width conditional branch다. target child-script VA로 반복/스케줄을 넘기는 loop control로 집계된다.",
            "0x2d motion step은 0x03/0x09/0x0b 세 계열로 좁혀졌고, 상대/절대 기준 trig motion 후보로 분리된다.",
            "0x14 branch target VA 재사용 그룹은 같은 helper 계열의 반복 motion/update 서브루틴을 가리키는 것으로 보이며, 리소스 id가 아니다.",
            "정확한 삼각/보간 공식과 0x14 target subfunction 내부 의미는 아직 완전 에뮬레이션하지 않았으므로 runner에는 보수적으로 적용해야 한다.",
        ],
        "op14TargetRows": target_rows,
        "op14TopModeTargetPairs": compact_counter(mode_target_counter)[:40],
        "op14TargetSubscriptRows": subscript_rows,
        "motionModeRows": motion_mode_rows,
        "helperScheduleMotionRows": helper_schedule_motion_rows,
    }


def decode_target_snippet(exe_data: bytes, sections: list[dict[str, Any]], target_hex: str, max_rows: int = 12) -> dict[str, Any]:
    try:
        va = int(str(target_hex), 16)
    except (TypeError, ValueError):
        return {"targetVaHex": target_hex, "decodeStatus": "bad-target", "rows": []}
    rows: list[dict[str, Any]] = []
    seen: set[int] = set()
    status = "max-rows"
    current = va
    for _ in range(max_rows):
        if current in seen:
            status = f"loop at 0x{current:08x}"
            break
        seen.add(current)
        raw = read_bytes(exe_data, sections, current, 512)
        if not raw:
            status = f"unreadable 0x{current:08x}"
            break
        row = decode_child_instruction(raw, current)
        rows.append(
            {
                "vaHex": row.get("vaHex"),
                "opcode": row.get("opcode"),
                "category": row.get("category"),
                "summary": row.get("summary"),
                "destHex": row.get("destHex"),
                "sourceHex": row.get("sourceHex"),
                "modeHex": row.get("modeHex"),
                "targetVaHex": row.get("targetVaHex"),
                "waitFrames": row.get("waitFrames"),
                "randomRange": row.get("randomRange"),
                "axes": row.get("axes"),
            }
        )
        length = int(row.get("length") or 0)
        if length <= 0:
            status = f"unknown length at {row.get('vaHex')}"
            break
        if row.get("nextMode") == "jump":
            status = f"jump at {row.get('vaHex')}"
            break
        if row.get("nextMode") == "stop" or row.get("category") in {
            "yield",
            "destroy/end",
            "return-subscript",
            "countdown-wait",
        }:
            status = f"{row.get('category')} at {row.get('vaHex')}"
            break
        current += length
    return {"targetVaHex": target_hex, "decodeStatus": status, "rows": rows}


def classify_target_snippet(target_hex: str, rows: list[dict[str, Any]]) -> str:
    opcodes = [str(row.get("opcode") or "") for row in rows]
    categories = [str(row.get("category") or "") for row in rows]
    summaries = " | ".join(str(row.get("summary") or "") for row in rows)
    has_spawn = "0x07" in opcodes
    has_motion = "0x2d" in opcodes
    has_random = "0x2b" in opcodes
    has_branch = any(op in {"0x13", "0x14", "0x15"} for op in opcodes)
    has_self_schedule = any(row.get("opcode") == "0x14" and str(row.get("branchTargetVaHex") or row.get("targetVaHex") or "").lower() == target_hex.lower() for row in rows)
    if has_spawn and has_self_schedule:
        return "repeat-spawn-and-advance"
    if has_spawn and has_random:
        return "randomized-child-spawn"
    if has_spawn:
        return "linked-child-spawn"
    if has_motion and has_branch:
        return "trig-motion-boundary-step"
    if has_motion:
        return "trig-motion-step"
    if has_self_schedule:
        return "self-reschedule-counter"
    if categories and categories[0] in {"child-control-noop", "yield", "countdown-wait", "destroy/end"}:
        return "barrier-or-end-label"
    if "+0x74" in summaries or "+0x78" in summaries:
        return "motion-accumulator-update"
    if any(op in {"0x11", "0x12"} for op in opcodes):
        return "field-update-label"
    if has_branch:
        return "conditional-branch-label"
    return "unclassified-target-subscript"


def op14_target_subscript_rows(
    position_motion: dict[str, Any],
    helper_by_id: dict[int, dict[str, Any]],
    action_by_helper: dict[int, list[dict[str, Any]]],
    exe_data: bytes,
    sections: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    seen: set[tuple[str, int | None]] = set()
    for schedule in position_motion.get("op14ScheduleRows") or []:
        target_hex = str(schedule.get("targetVaHex") or "-")
        for example in schedule.get("examples") or []:
            helper_id = example.get("helperId")
            helper_id_int = int(helper_id) if helper_id is not None else None
            key = (target_hex, helper_id_int)
            if key in seen:
                continue
            seen.add(key)
            snippet = decode_target_snippet(exe_data, sections, target_hex)
            snippet_rows = snippet["rows"]
            helper = helper_by_id.get(helper_id_int or -1) or {}
            rows.append(
                {
                    "targetVaHex": target_hex,
                    "helperId": helper_id_int,
                    "helperIdHex": f"0x{helper_id_int:08x}" if helper_id_int is not None else "-",
                    "scope": example.get("scope"),
                    "scheduleDestHex": schedule.get("destHex"),
                    "scheduleModeHex": schedule.get("modeHex"),
                    "scheduleLayout": schedule.get("layout"),
                    "subscriptClass": classify_target_snippet(target_hex, snippet_rows),
                    "decodeStatus": snippet["decodeStatus"],
                    "opcodes": [row.get("opcode") for row in snippet_rows],
                    "categories": [row.get("category") for row in snippet_rows],
                    "skillLabels": helper.get("skillLabels") or [],
                    "actionSamples": action_by_helper.get(helper_id_int or -1, [])[:6],
                    "snippetRows": snippet_rows,
                }
            )
    rows.sort(key=lambda row: (str(row["subscriptClass"]), str(row["targetVaHex"]), int(row.get("helperId") or 0)))
    return rows


def build() -> dict[str, Any]:
    action_effect = load_json(ACTION_EFFECT)
    position_motion = load_json(POSITION_MOTION)
    position_by_id = {
        int(row.get("helperId")): row
        for row in position_motion.get("helperRows") or []
        if row.get("helperId") is not None
    }
    position_rows = list(position_by_id.values())
    exe_data = EXE.read_bytes()
    sections = read_sections(exe_data)

    status_counter: Counter[str] = Counter()
    helper_class_counter: Counter[str] = Counter()
    anchor_counter: Counter[str] = Counter()
    rows: list[dict[str, Any]] = []
    helper_calls = 0
    helper_calls_with_preview = 0
    source_review = source_space_summary(position_rows)
    schedule_review = motion_schedule_review(action_effect, position_motion, exe_data, sections)
    handler_review = handler_semantics_review(exe_data, sections)

    for action in action_effect.get("rows") or []:
        for helper in action.get("helpers") or []:
            helper_calls += 1
            helper_id = int(helper.get("helperId") or int(str(helper.get("helperIdHex") or "0"), 16))
            scripts = helper.get("previewFrameScripts") or []
            if scripts:
                helper_calls_with_preview += 1
            helper_class = helper.get("helperClass") or "-"
            for script in scripts:
                solved = solve_transform(position_by_id.get(helper_id), script)
                status_counter[solved["status"]] += 1
                helper_class_counter[helper_class] += 1
                anchor_counter[solved.get("parentAnchor") or "-"] += 1
                rows.append(
                    {
                        "enemyName": action.get("enemyName"),
                        "cns": action.get("cns"),
                        "actionName": action.get("sharedActionName"),
                        "visibleSlotHex": action.get("visibleSlotHex"),
                        "helperId": helper_id,
                        "helperIdHex": helper.get("helperIdHex"),
                        "helperClass": helper_class,
                        "scriptSource": script.get("source"),
                        "scriptSourceLabel": script.get("sourceLabel"),
                        "targetVaHex": script.get("targetVaHex"),
                        "frameCount": len(script.get("frameGateSequence") or []),
                        "frames": [
                            f"{frame.get('frame')}@{frame.get('gate')}"
                            for frame in (script.get("frameGateSequence") or [])[:12]
                        ],
                        **solved,
                    }
                )

    known_gaps = [
        {
            "id": "dynamic-anchor-values",
            "label": "동적 actor/target 기준 좌표",
            "count": status_counter["vm-transform"] + status_counter["parent-anchor-frameScript"],
            "note": "EXE VM/부모 anchor 구조는 정적으로 해석됐지만 실제 화면 좌표는 브라우저 runner가 시전자/대상 anchor를 주입해야 한다.",
        },
        {
            "id": "zero-or-control-write",
            "label": "좌표 아닌 제어/0 쓰기",
            "count": status_counter["zero-or-control-write"] + status_counter["non-coordinate-transform"],
            "note": "렌더 위치 대신 loop/control/cleanup 보조 쓰기일 가능성이 높다.",
        },
        {
            "id": "parent-anchor-frameScript",
            "label": "부모 anchor 직접 사용 frameScript",
            "count": status_counter["parent-anchor-frameScript"],
            "note": "root direct-frameScript가 별도 position write 없이 호출부/부모 display object 위치를 그대로 쓴다.",
        },
    ]

    return {
        "version": 1,
        "kind": "hwanse-battle-helper-transform-solver-review",
        "title": "전투 helper parent-child transform 정적 해석",
        "status": "static-helper-transform-coverage",
        "runtimeUsed": False,
        "source": str(Path(__file__).relative_to(ROOT)),
        "updatedAt": datetime.now(timezone.utc).isoformat(),
        "inputs": ["Hwanse2.exe", str(ACTION_EFFECT.relative_to(ROOT)), str(POSITION_MOTION.relative_to(ROOT))],
        "summary": {
            "monsterActionRows": len(action_effect.get("rows") or []),
            "helperCalls": helper_calls,
            "helperCallsWithPreviewScript": helper_calls_with_preview,
            "previewScripts": len(rows),
            "statusCounts": compact_counter(status_counter),
            "helperClassCounts": compact_counter(helper_class_counter),
            "parentAnchorCounts": compact_counter(anchor_counter),
            "sourceSpaceCandidateCounts": source_review["sourceSpaceCandidateCounts"],
            "targetMinusActorDeltaPairs": source_review["targetMinusActorDeltaPairs"],
            "op14ScheduleRows": schedule_review["summary"]["op14ScheduleRows"],
            "uniqueOp14Targets": schedule_review["summary"]["uniqueOp14Targets"],
            "helpersWithBothOp14AndMotionStep": schedule_review["summary"]["helpersWithBothOp14AndMotionStep"],
            "decodedArithmeticOps": len(handler_review["arithmeticHelper"]["operations"]),
            "decodedConditionOps": len(handler_review["conditionHelper"]["operations"]),
        },
        "sourceSpaceReview": source_review,
        "handlerSemanticsReview": handler_review,
        "motionScheduleReview": schedule_review,
        "staticConclusions": [
            "몬스터 행동 helper preview script는 219개이며, 이 중 좌표 VM write까지 정적으로 해석 가능한 항목을 분리했다.",
            "helper child VM의 +0x1c/+0x20은 display position, +0x80/+0x84 및 +0x74/+0x78 계열은 목표/델타 좌표 후보로 처리한다.",
            "position write가 없는 7개 direct frameScript는 미해석이 아니라 부모 anchor를 그대로 쓰는 이펙트로 분류한다.",
            "position write의 mode high nibble은 source-space 후보를 강하게 시사한다. 특히 0xa*는 target/base actor, 0xb*는 current/secondary actor, 0xe*는 parent/current display source로 분류된다.",
            "같은 dest에 0xa* set 후 0xb* sub가 붙는 패턴은 target-current delta 계산으로 분류된다.",
            "opcode 0x14 schedule target과 0x2d motion mode를 helper/action 맥락으로 재집계하여 이펙트 반복/이동 서브루틴 후보를 분리했다.",
            "display handler table 정적 디스어셈블로 0x2d motion-step 공식과 산술 low-nibble 0x0..0xc 의미를 승격했다.",
            "0x13/0x14/0x15 조건분기는 같은 condition helper 0x0040370a를 쓰며 각각 byte/word/dword 폭으로 비교한다.",
            "이 산출물은 Wine/runtime 관찰을 사용하지 않고 Hwanse2.exe 바이트와 기존 EXE 정적 디코드 JSON만 소비한다.",
        ],
        "knownGaps": known_gaps,
        "rows": rows,
    }


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

    summary = data["summary"]
    counters = "".join(
        f"<tr><td>{esc(row['value'])}</td><td>{esc(row['count'])}</td></tr>"
        for row in summary["statusCounts"]
    )
    source_counters = "".join(
        f"<tr><td>{esc(row['value'])}</td><td>{esc(row['count'])}</td></tr>"
        for row in summary["sourceSpaceCandidateCounts"]
    )
    schedule = data["motionScheduleReview"]
    handler_review = data["handlerSemanticsReview"]
    schedule_summary = schedule["summary"]
    op14_target_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['targetVaHex'])}</code><br>{esc(row['count'])} branches</td>"
        f"<td>{esc(row['helperIds'])}</td>"
        f"<td>{esc(row['modeCounts'])}<br>{esc(row['conditionCounts'])}</td>"
        f"<td>{esc(row['skillLabels'])}</td>"
        f"<td>{esc(row['actionSamples'])}</td>"
        "</tr>"
        for row in schedule["op14TargetRows"][:40]
    )
    motion_mode_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['modeHex'])}</code><br>{esc(row.get('label'))}</td>"
        f"<td>{esc(row.get('confidence'))}</td>"
        f"<td>{esc(row.get('count'))}</td>"
        f"<td>{esc(row.get('helperIds'))}</td>"
        f"<td>{esc(row.get('skillLabels'))}</td>"
        "</tr>"
        for row in schedule["motionModeRows"]
    )
    helper_schedule_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['helperIdHex'])}</code><br>{esc(row.get('helperClass'))}</td>"
        f"<td>{esc(row.get('skillLabels'))}</td>"
        f"<td>{esc(row.get('op14Targets'))}<br>{esc(row.get('op14ConditionCounts'))}</td>"
        f"<td>{esc(row.get('motionModeCounts'))}</td>"
        f"<td>{esc(row.get('patterns'))}</td>"
        "</tr>"
        for row in schedule["helperScheduleMotionRows"][:80]
    )
    subscript_class_rows = "".join(
        f"<tr><td>{esc(row['value'])}</td><td>{esc(row['count'])}</td></tr>"
        for row in schedule_summary["op14TargetSubscriptClassCounts"]
    )
    subscript_row_parts = []
    for row in schedule["op14TargetSubscriptRows"][:120]:
        snippet_html = "<br>".join(
            f"<code>{esc(item.get('vaHex'))}</code> {esc(item.get('opcode'))} {esc(item.get('summary'))}"
            for item in (row.get("snippetRows") or [])[:8]
        )
        subscript_row_parts.append(
            "<tr>"
            f"<td><code>{esc(row['targetVaHex'])}</code><br>{esc(row['subscriptClass'])}<br>{esc(row['decodeStatus'])}</td>"
            f"<td><code>{esc(row.get('helperIdHex'))}</code><br>{esc(row.get('scope'))}</td>"
            f"<td>{esc(row.get('scheduleModeHex'))} / {esc(row.get('scheduleDestHex'))}<br>{esc(row.get('scheduleLayout'))}</td>"
            f"<td>{esc(row.get('skillLabels'))}<br>{esc(row.get('actionSamples'))}</td>"
            f"<td>{snippet_html}</td>"
            "</tr>"
        )
    subscript_rows = "".join(subscript_row_parts)
    handler_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['opcode'])}</code></td>"
        f"<td><code>{esc(row['handlerVaHex'])}</code></td>"
        f"<td>{esc(row['meaning'])}</td>"
        "</tr>"
        for row in handler_review["handlerRows"]
    )
    arithmetic_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['lowNibble'])}</code></td>"
        f"<td>{esc(row['operation'])}</td>"
        f"<td><code>{esc(row['formula'])}</code></td>"
        "</tr>"
        for row in handler_review["arithmeticHelper"]["operations"]
    )
    condition_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['lowNibble'])}</code></td>"
        f"<td>{esc(row['operation'])}</td>"
        f"<td><code>{esc(row['formula'])}</code></td>"
        "</tr>"
        for row in handler_review["conditionHelper"]["operations"]
    )
    branch_handler_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['opcode'])}</code></td>"
        f"<td>{esc(row['width'])}</td>"
        f"<td>{esc(row['immediateLayout'])}</td>"
        "</tr>"
        for row in handler_review["conditionHelper"]["branchHandlers"]
    )
    motion_formula = "".join(
        f"<tr><td>relative 0x00</td><td><code>{esc(item)}</code></td></tr>"
        for item in handler_review["motionStepFormula"]["relativeGroup0x00"]
    ) + "".join(
        f"<tr><td>absolute 0x08</td><td><code>{esc(item)}</code></td></tr>"
        for item in handler_review["motionStepFormula"]["absoluteGroup0x08"]
    )
    prefix_counters = "".join(
        f"<tr><td>{esc(row['value'])}</td><td>{esc(row['count'])}</td></tr>"
        for row in data["sourceSpaceReview"]["modePrefixCounts"][:16]
    )
    gaps = "".join(
        f"<tr><td><code>{esc(row['id'])}</code></td><td>{esc(row['label'])}</td><td>{esc(row['count'])}</td><td>{esc(row['note'])}</td></tr>"
        for row in data["knownGaps"]
    )
    details = "".join(
        "<tr>"
        f"<td>{esc(row['enemyName'])}<br><code>{esc(row['cns'])}</code></td>"
        f"<td>{esc(row['actionName'])}<br>slot <code>{esc(row['visibleSlotHex'])}</code></td>"
        f"<td><code>{esc(row['helperIdHex'])}</code><br>{esc(row['helperClass'])}</td>"
        f"<td>{esc(row['status'])}<br>{esc(row.get('parentAnchor'))}</td>"
        f"<td>{esc(' > '.join(str(x) for x in row.get('scopeChain') or []))}</td>"
        f"<td>{esc(row.get('changedFields'))}<br>{esc(row.get('fields'))}</td>"
        f"<td>{esc(row.get('scriptSourceLabel'))}<br>{esc(row.get('frames'))}</td>"
        "</tr>"
        for row in data["rows"]
    )
    conclusions = "".join(f"<li>{esc(item)}</li>" for item in data["staticConclusions"])
    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>{esc(data['title'])}</title>
  <style>{css}</style>
</head>
<body>
<main>
  <header>
    <div>
      <h1>{esc(data['title'])}</h1>
      <div class="muted">EXE 정적 디코드 JSON만 사용. 런타임/Wine 관찰 없음.</div>
    </div>
    <nav>
      <a href="../web/index.html">홈</a>
      <a href="../web/battle_analysis.html">전투 분석</a>
      <a href="../web/battle_simulator.html">전투 샌드박스</a>
      <a href="battle_helper_transform_solver_review.json">JSON</a>
    </nav>
  </header>
  <section><div class="section-head"><h2>요약</h2></div><div class="body grid">
    <div class="metric"><strong>{esc(summary['previewScripts'])}</strong><span>preview scripts</span></div>
    <div class="metric"><strong>{esc(summary['helperCalls'])}</strong><span>helper calls</span></div>
    <div class="metric"><strong>{esc(summary['helperCallsWithPreviewScript'])}</strong><span>helper calls with preview</span></div>
  </div></section>
  <section><div class="section-head"><h2>정적 결론</h2></div><div class="body"><ul>{conclusions}</ul></div></section>
  <section><div class="section-head"><h2>상태/갭</h2></div><div class="body grid"><table><thead><tr><th>status</th><th>count</th></tr></thead><tbody>{counters}</tbody></table><table><thead><tr><th>id</th><th>항목</th><th>count</th><th>note</th></tr></thead><tbody>{gaps}</tbody></table></div></section>
  <section><div class="section-head"><h2>Source-Space 후보</h2></div><div class="body grid"><table><thead><tr><th>candidate</th><th>count</th></tr></thead><tbody>{source_counters}</tbody></table><table><thead><tr><th>mode prefix</th><th>count</th></tr></thead><tbody>{prefix_counters}</tbody></table><div class="metric"><strong>{esc(summary['targetMinusActorDeltaPairs'])}</strong><span>target-current delta pair patterns</span></div></div></section>
  <section><div class="section-head"><h2>Handler 공식</h2></div><div class="body">
    <div class="grid">
      <table><thead><tr><th>opcode</th><th>handler</th><th>meaning</th></tr></thead><tbody>{handler_rows}</tbody></table>
      <table><thead><tr><th>low</th><th>op</th><th>formula</th></tr></thead><tbody>{arithmetic_rows}</tbody></table>
    </div>
    <div class="grid">
      <table><thead><tr><th>condition low</th><th>op</th><th>formula</th></tr></thead><tbody>{condition_rows}</tbody></table>
      <table><thead><tr><th>branch opcode</th><th>width</th><th>layout</th></tr></thead><tbody>{branch_handler_rows}</tbody></table>
    </div>
    <table><thead><tr><th>motion group</th><th>formula</th></tr></thead><tbody>{motion_formula}</tbody></table>
    <p class="muted">trig helper: x <code>{esc(handler_review['motionStepFormula']['trigXHelperVaHex'])}</code>, y/z <code>{esc(handler_review['motionStepFormula']['trigYHelperVaHex'])}</code>. 이 섹션은 EXE 바이트 정적 디스어셈블에서 도출했으며 런타임 관찰을 쓰지 않는다.</p>
  </div></section>
  <section><div class="section-head"><h2>op14 / motion schedule</h2></div><div class="body">
    <div class="grid">
      <div class="metric"><strong>{esc(schedule_summary['op14ScheduleRows'])}</strong><span>op14 schedule groups</span></div>
      <div class="metric"><strong>{esc(schedule_summary['uniqueOp14Targets'])}</strong><span>unique target child scripts</span></div>
      <div class="metric"><strong>{esc(schedule_summary['helpersWithBothOp14AndMotionStep'])}</strong><span>helpers with op14 + 0x2d</span></div>
      <div class="metric"><strong>{esc(schedule_summary['decodedOp14TargetSubscripts'])}</strong><span>decoded target labels</span></div>
    </div>
    <ul>{''.join(f'<li>{esc(item)}</li>' for item in schedule['staticConclusions'])}</ul>
    <details open><summary>op14 target subscript classes</summary><div class="grid"><table><thead><tr><th>class</th><th>count</th></tr></thead><tbody>{subscript_class_rows}</tbody></table></div></details>
    <details open><summary>Top op14 target groups</summary><div class="scroll"><table><thead><tr><th>target</th><th>helpers</th><th>mode/condition</th><th>skills</th><th>monster actions</th></tr></thead><tbody>{op14_target_rows}</tbody></table></div></details>
    <details open><summary>0x2d motion modes</summary><table><thead><tr><th>mode</th><th>confidence</th><th>count</th><th>helpers</th><th>skills</th></tr></thead><tbody>{motion_mode_rows}</tbody></table></details>
    <details><summary>Decoded op14 target snippets</summary><div class="scroll"><table><thead><tr><th>target/class</th><th>helper/scope</th><th>schedule</th><th>skills/actions</th><th>snippet</th></tr></thead><tbody>{subscript_rows}</tbody></table></div></details>
    <details><summary>Helper schedule/motion relation</summary><div class="scroll"><table><thead><tr><th>helper</th><th>skills</th><th>op14 targets</th><th>motion modes</th><th>patterns</th></tr></thead><tbody>{helper_schedule_rows}</tbody></table></div></details>
  </div></section>
  <section><div class="section-head"><h2>상세</h2></div><div class="body scroll"><table><thead><tr><th>monster</th><th>action</th><th>helper</th><th>status</th><th>scope chain</th><th>fields</th><th>script</th></tr></thead><tbody>{details}</tbody></table></div></section>
</main>
</body>
</html>
"""


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


if __name__ == "__main__":
    main()
