#!/usr/bin/env python3
"""Review battle helper position and motion primitives.

The effect object reports already prove which helper scripts own frameScript
effects.  This review focuses on the next layer: where those helper effects are
placed and how they move.  It intentionally keeps the result conservative.
Opcode fields are grouped into a small coordinate model, while helper-level
patterns remain "promotion hints" until the browser runner validates them.
"""
from __future__ import annotations

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


ROOT = Path(__file__).resolve().parents[1]
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_position_motion_review.json"
OUT_MD = OUT / "battle_helper_position_motion_review.md"
OUT_HTML = OUT / "battle_helper_position_motion_review.html"


FIELD_ROLES: dict[str, dict[str, str]] = {
    "0x1c": {
        "label": "display.x",
        "role": "display object's current x coordinate",
        "confidence": "confirmed",
    },
    "0x20": {
        "label": "display.y",
        "role": "display object's current y coordinate",
        "confidence": "confirmed",
    },
    "0x58": {
        "label": "child/randomTemp",
        "role": "spawned child object handle or RNG result scratch",
        "confidence": "confirmed",
    },
    "0x68": {
        "label": "delta.x scratch",
        "role": "temporary x difference used before motion interpolation",
        "confidence": "strong",
    },
    "0x6c": {
        "label": "delta.y scratch",
        "role": "temporary y difference or vertical sweep step",
        "confidence": "strong",
    },
    "0x74": {
        "label": "motion.dx / offset.x",
        "role": "x offset accumulator or horizontal motion delta",
        "confidence": "strong",
    },
    "0x78": {
        "label": "motion.dy / offset.y",
        "role": "y offset accumulator or vertical motion delta",
        "confidence": "strong",
    },
    "0x80": {
        "label": "motion.targetX",
        "role": "cached target/base x coordinate",
        "confidence": "strong",
    },
    "0x84": {
        "label": "motion.targetY",
        "role": "cached target/base y coordinate or screen height constant",
        "confidence": "strong",
    },
    "0x88": {
        "label": "motion.extra",
        "role": "rare auxiliary motion field",
        "confidence": "weak",
    },
    "0x8c": {
        "label": "child.paramX",
        "role": "child motion parameter, often randomized angle/radius seed",
        "confidence": "strong",
    },
    "0x8e": {
        "label": "child.paramX2",
        "role": "secondary child motion parameter copied from +0x8c",
        "confidence": "medium",
    },
    "0x90": {
        "label": "child.paramY",
        "role": "child motion parameter, phase or y-side parameter",
        "confidence": "strong",
    },
    "0x92": {
        "label": "child.paramW",
        "role": "child size/effect width or angular parameter",
        "confidence": "medium",
    },
    "0x94": {
        "label": "child.paramH",
        "role": "child size/effect height, phase, or loop parameter",
        "confidence": "medium",
    },
    "0x96": {
        "label": "child.stepCount",
        "role": "child step/count field used by repeating spawn or sweep helpers",
        "confidence": "strong",
    },
    "0xa0": {
        "label": "parentObject",
        "role": "parent display object pointer",
        "confidence": "confirmed",
    },
    "0xa4": {
        "label": "childObject",
        "role": "linked child display object pointer",
        "confidence": "confirmed",
    },
    "0xa8": {
        "label": "parentActor",
        "role": "battle actor anchor pointer",
        "confidence": "confirmed",
    },
}


MOTION_MODE_ROLES: dict[str, dict[str, str]] = {
    "0x03": {
        "label": "relative trig x+y",
        "role": "relative two-axis motion; used by dash/scatter helpers such as 쾌진격 child effects",
        "confidence": "strong",
    },
    "0x09": {
        "label": "absolute trig x",
        "role": "absolute/base anchored horizontal oscillation or wave step",
        "confidence": "medium",
    },
    "0x0b": {
        "label": "absolute trig x+y",
        "role": "absolute/base anchored two-axis step",
        "confidence": "medium",
    },
}


OPCODE_SEMANTICS: list[dict[str, str]] = [
    {
        "opcode": "0x11",
        "name": "child field arithmetic write",
        "meaning": "writes display child/motion fields such as +0x8c/+0x90/+0x92/+0x94/+0x96; immediate forms are 8 bytes and source-field forms are 4 bytes",
        "confidence": "confirmed",
    },
    {
        "opcode": "0x12",
        "name": "display/actor field arithmetic write",
        "meaning": "writes the current display object's coordinate/cache fields such as +0x1c/+0x20/+0x68/+0x6c/+0x74/+0x80/+0x84",
        "confidence": "confirmed",
    },
    {
        "opcode": "0x13",
        "name": "byte conditional branch",
        "meaning": "byte-width conditional branch; observed cases test parentActor.byte[+0x04] against immediate actor-kind values and branch target is stored at +4",
        "confidence": "confirmed",
    },
    {
        "opcode": "0x14",
        "name": "word conditional branch",
        "meaning": "word-width conditional branch; immediate form stores target at +8, source-field form stores target at +4; used as loop/schedule control for child motion scripts",
        "confidence": "confirmed",
    },
    {
        "opcode": "0x15",
        "name": "field conditional branch",
        "meaning": "conditional branch comparing a display/motion field against an immediate fixed-point value, bit mask, or another field; observed target dword is stored at +8",
        "confidence": "confirmed",
    },
    {
        "opcode": "0x2b",
        "name": "rng to +0x58",
        "meaning": "calls RNG helper 0x00427730 with the script word as range/modulo and stores the result into display +0x58",
        "confidence": "confirmed",
    },
    {
        "opcode": "0x2d",
        "name": "motion step",
        "meaning": "advances a display object using relative/absolute trig motion; low bits select x/y/z axes and the 0x08 mode group selects absolute/base anchored motion",
        "confidence": "strong",
    },
]


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 read_json(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8"))


def compact(values: Iterable[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:
    parts = [skill.get("ownerName"), skill.get("skillName"), skill.get("skillIdHex")]
    level = skill.get("levelOrFixed")
    if level is not None:
        parts.append(f"L{level}")
    return " ".join(str(part) for part in parts if part)


def labels_from_helper(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 row_key(row: dict[str, Any]) -> str:
    return "|".join(
        str(row.get(key) or "")
        for key in ("category", "destHex", "sourceHex", "modeHex", "immHex", "targetVaHex")
    )


def extract_summary_hex(row: dict[str, Any], label: str) -> str | None:
    match = re.search(rf"{re.escape(label)}=(0x[0-9a-fA-F]+)", str(row.get("summary") or ""))
    return match.group(1).lower() if match else None


def extract_summary_value(row: dict[str, Any], label: str) -> str | None:
    match = re.search(rf"{re.escape(label)}=([A-Za-z0-9_+.-]+)", str(row.get("summary") or ""))
    return match.group(1) if match else None


def branch_operands(row: dict[str, Any]) -> tuple[str, str, str]:
    left = str(row.get("leftSource") or "-")
    right = str(row.get("rightSource") or "-")
    comparison = str(row.get("comparison") or "-")
    if row.get("opcode") == "0x15" and (left == "-" or right == "-"):
        match = re.search(
            r"conditional branch (.+?)\s+(bit-test|==|!=|>=|<=|>|<)\s+(.+?);",
            str(row.get("summary") or ""),
        )
        if match:
            left = match.group(1)
            comparison = match.group(2)
            right_raw = match.group(3)
            right = f"imm={right_raw.lower()}" if re.fullmatch(r"0x[0-9a-fA-F]+", right_raw) else right_raw
    return left, comparison, right


def is_zero_hex(value: Any) -> bool:
    if value in (None, ""):
        return True
    try:
        return int(str(value), 16) == 0
    except ValueError:
        return False


def looks_like_helper_script_va(value: Any) -> bool:
    if value in (None, ""):
        return False
    try:
        parsed = int(str(value), 16)
    except ValueError:
        return False
    return 0x004B0000 <= parsed < 0x004C0000 and parsed % 4 == 0


def op14_branch_layout(row: dict[str, Any]) -> dict[str, Any]:
    """Normalize opcode 0x14's branch target layout after handler promotion."""
    target = str(row.get("branchTargetVaHex") or row.get("targetVaHex") or "").lower()
    offset = row.get("branchTargetOffset")
    layout = "word-imm-target-at+8" if offset == 8 else "word-source-target-at+4" if offset == 4 else "word-target-unknown"
    return {
        "op14Layout": layout,
        "op14TargetVaHex": target,
        "op14RawTargetVaHex": target,
        "op14ControlHex": "",
        "op14LayoutConfidence": "confirmed" if target else "weak",
        "op14Condition": row.get("summary") or "",
    }


def conditional_branch_layout(row: dict[str, Any]) -> dict[str, Any]:
    opcode = row.get("opcode")
    if opcode == "0x14":
        return op14_branch_layout(row)
    target = str(row.get("branchTargetVaHex") or row.get("targetVaHex") or "").lower()
    offset = row.get("branchTargetOffset")
    if opcode == "0x13":
        layout = "byte-imm-target-at+4" if offset == 4 else "byte-target-unknown"
    elif opcode == "0x15":
        layout = "field-branch-target-at+8" if offset == 8 else "field-branch-target-unknown"
    else:
        layout = "conditional-target-unknown"
    return {
        "op14Layout": layout,
        "op14TargetVaHex": target,
        "op14RawTargetVaHex": target,
        "op14ControlHex": "",
        "op14LayoutConfidence": "confirmed" if target else "weak",
        "op14Condition": row.get("summary") or "",
    }


def row_brief(row: dict[str, Any]) -> dict[str, Any]:
    keys = (
        "vaHex",
        "opcode",
        "category",
        "summary",
        "destHex",
        "sourceHex",
        "modeHex",
        "mode",
        "immHex",
        "immFixed",
        "comparison",
        "conditionOpHex",
        "operandWidth",
        "leftSource",
        "rightSource",
        "leftSelectorHex",
        "rightSelectorHex",
        "rightImmediateHex",
        "branchTargetOffset",
        "branchTargetVaHex",
        "childTargetVaHex",
        "op14TargetVaHex",
        "op14RawTargetVaHex",
        "op14Layout",
        "op14LayoutConfidence",
        "targetVaHex",
        "aux",
        "auxHex",
        "op14ControlHex",
        "axes",
        "modeGroupHex",
        "randomRange",
        "randomRangeHex",
    )
    out = {key: row.get(key) for key in keys if row.get(key) not in (None, "", [])}
    if row.get("opcode") in {"0x13", "0x14", "0x15"}:
        layout = conditional_branch_layout(row)
        out.update({key: value for key, value in layout.items() if value not in (None, "", [])})
        if layout.get("op14TargetVaHex"):
            out["childTargetVaHex"] = layout["op14TargetVaHex"]
    if row.get("modeHex"):
        out.update(mode_semantics(row))
    return out


def mode_semantics(row: dict[str, Any]) -> dict[str, Any]:
    mode = row.get("mode")
    if mode is None and row.get("modeHex"):
        try:
            mode = int(str(row["modeHex"]), 16)
        except ValueError:
            mode = None
    if mode is None:
        return {}
    low = mode & 0x0F
    operation = OPERATION_NAMES.get(low, f"op{low:x}")
    opcode = row.get("opcode")
    if opcode in {"0x13", "0x14", "0x15"}:
        operation = str(row.get("comparison") or operation)
        operand = f"{row.get('leftSource') or 'left'} vs {row.get('rightSource') or 'right'}"
    else:
        operand = "immediate-fixed16" if row.get("immHex") else "source-field"
    confidence = "confirmed" if low in OPERATION_NAMES and operand in {"immediate-fixed16", "source-field"} else "weak"
    if opcode in {"0x13", "0x14", "0x15"}:
        confidence = "confirmed"
    return {
        "modeLowNibble": f"0x{low:x}",
        "operation": operation,
        "operandClass": operand,
        "modeSemantics": f"{operation}({operand})",
        "modeConfidence": confidence,
    }


def mode_summary_confidence(opcode: str, operation: str) -> str:
    if operation not in OPERATION_NAMES.values():
        return "weak"
    if opcode == "0x14":
        return "confirmed"
    return "confirmed"


def unique_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    out: list[dict[str, Any]] = []
    seen: set[str] = set()
    for row in rows:
        key = row_key(row)
        if key in seen:
            continue
        seen.add(key)
        out.append(row)
    return out


def walk_spawn_tree(nodes: list[dict[str, Any]], callback, prefix: str = "spawn") -> None:
    for index, node in enumerate(nodes or [], 1):
        label = f"{prefix}.{index} {node.get('targetVaHex') or ''}".strip()
        callback(label, node.get("decoded") or {})
        walk_spawn_tree(node.get("children") or [], callback, label)


def helper_scopes(helper: dict[str, Any]) -> list[dict[str, Any]]:
    scopes = [{"scope": "root", "decoded": helper.get("root") or {}}]

    def add_spawn(label: str, decoded: dict[str, Any]) -> None:
        scopes.append({"scope": label, "decoded": decoded})

    walk_spawn_tree(helper.get("spawnTree") or [], add_spawn)
    return scopes


def collect_scope_rows(decoded: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
    return {
        "positionWrites": [row_brief(row) for row in decoded.get("positionWrites") or []],
        "motionWrites": [row_brief(row) for row in decoded.get("motionWrites") or []],
        "branchRows": [row_brief(row) for row in decoded.get("branchRows") or []],
        "motionSteps": [row_brief(row) for row in decoded.get("motionSteps") or []],
        "anchorWrites": [row_brief(row) for row in decoded.get("anchorWrites") or []],
        "randomRanges": [row_brief(row) for row in decoded.get("randomRanges") or []],
        "keyEvents": [row_brief(row) for row in decoded.get("keyEvents") or []],
        "frameScripts": [
            {
                "targetVaHex": row.get("targetVaHex"),
                "frameLabels": row.get("frameLabels") or [],
                "durationGate": row.get("durationGate"),
                "positionWrites": [row_brief(item) for item in row.get("positionWrites") or []],
            }
            for row in decoded.get("frameScripts") or []
        ],
    }


def field_tag(row: dict[str, Any]) -> str:
    dest = row.get("destHex")
    role = FIELD_ROLES.get(str(dest).lower())
    if not role:
        return str(dest or "-")
    return f"{dest} {role['label']}"


def has_dest(rows: list[dict[str, Any]], *dests: str) -> bool:
    wanted = {dest.lower() for dest in dests}
    return any(str(row.get("destHex") or "").lower() in wanted for row in rows)


def has_source(rows: list[dict[str, Any]], *sources: str) -> bool:
    wanted = {source.lower() for source in sources}
    return any(str(row.get("sourceHex") or "").lower() in wanted for row in rows)


def motion_mode_labels(rows: list[dict[str, Any]]) -> list[str]:
    labels = []
    for row in rows:
        mode = str(row.get("modeHex") or "").lower()
        role = MOTION_MODE_ROLES.get(mode)
        axes = "+".join(row.get("axes") or []) or "-"
        labels.append(f"{mode} {role['label'] if role else 'unknown'} axes={axes}")
    return labels


def classify_scope(rows: dict[str, list[dict[str, Any]]]) -> list[str]:
    position = rows["positionWrites"]
    motion = rows["motionWrites"]
    steps = rows["motionSteps"]
    randoms = rows["randomRanges"]
    frame_scripts = rows["frameScripts"]
    tags: list[str] = []

    if has_dest(position, "0x1c") or has_dest(position, "0x20"):
        tags.append("writes-display-position")
    if has_dest(position, "0x68", "0x6c") and has_dest(position, "0x80", "0x84"):
        tags.append("delta-to-target-buffer")
    if has_dest(position, "0x74", "0x78"):
        tags.append("offset-accumulator")
    if has_dest(position, "0x80", "0x84"):
        tags.append("target-coordinate-cache")
    if any(str(row.get("immFixed")) in {"480.0", "384.0"} for row in position):
        tags.append("screen-sized-sweep")
    if randoms:
        tags.append("rng-driven-placement")
    if frame_scripts:
        tags.append("frameScript-positioned-effect")
    if steps:
        modes = {str(row.get("modeHex") or "").lower() for row in steps}
        if "0x03" in modes:
            tags.append("relative-trig-motion")
        if "0x09" in modes or "0x0b" in modes:
            tags.append("absolute-trig-motion")
    if has_dest(motion, "0x96"):
        tags.append("step-count-or-repeat-control")
    if has_dest(motion, "0x8c", "0x90", "0x92", "0x94"):
        tags.append("child-motion-parameterized")
    if has_source(position, "0x58") or has_source(motion, "0x58"):
        tags.append("uses-rng-or-child-temp")
    return sorted(set(tags))


def scope_anchor(rows: dict[str, list[dict[str, Any]]]) -> str:
    key_rows = rows["keyEvents"]
    if any(row.get("category") == "parent-actor" for row in key_rows):
        return "current actor slot"
    if any(row.get("category") == "global-parent-anchor" for row in key_rows):
        return "global/screen anchor"
    if any(row.get("destHex") == "0xa8" and row.get("sourceHex") == "0xa4" for row in key_rows):
        return "spawned child parent"
    if any(row.get("destHex") == "0xa8" and row.get("sourceHex") == "0xa0" for row in key_rows):
        return "parent object actor"
    return "-"


def scope_coordinate_summary(rows: dict[str, list[dict[str, Any]]]) -> str:
    parts: list[str] = []
    for dest in ("0x1c", "0x20", "0x68", "0x6c", "0x74", "0x78", "0x80", "0x84"):
        relevant = [row for row in rows["positionWrites"] if row.get("destHex") == dest]
        if not relevant:
            continue
        short = []
        for row in relevant[:4]:
            source = row.get("sourceHex")
            imm = row.get("immFixed")
            mode = row.get("modeHex")
            if imm is not None:
                short.append(f"{mode}:{imm:g}")
            elif source:
                short.append(f"{mode}:{source}")
            else:
                short.append(str(mode or "?"))
        role = FIELD_ROLES.get(dest, {}).get("label", dest)
        parts.append(f"{role} [{', '.join(short)}]")
    return "; ".join(parts) or "-"


def build() -> dict[str, Any]:
    effect = read_json(EFFECT_JSON)
    frame = read_json(FRAME_JSON)
    helper_rows = []
    pattern_counts: Counter[str] = Counter()
    field_counts: Counter[str] = Counter()
    mode_counts: Counter[str] = Counter()
    helper_by_motion_mode: defaultdict[str, set[int]] = defaultdict(set)
    helper_by_pattern: defaultdict[str, set[int]] = defaultdict(set)
    mode_semantic_counts: Counter[tuple[str, str, str, str]] = Counter()
    mode_semantic_examples: defaultdict[tuple[str, str, str, str], list[dict[str, Any]]] = defaultdict(list)
    branch_counts: Counter[tuple[str, str, str, str, str, str, str, str]] = Counter()
    branch_examples: defaultdict[tuple[str, str, str, str, str, str, str, str], list[dict[str, Any]]] = defaultdict(list)
    helper_opcode_counts: Counter[str] = Counter()

    for helper in effect.get("helperRows") or []:
        scopes = []
        helper_patterns: set[str] = set()
        helper_fields: Counter[str] = Counter()
        helper_modes: Counter[str] = Counter()
        for scope in helper_scopes(helper):
            rows = collect_scope_rows(scope["decoded"])
            patterns = classify_scope(rows)
            anchor = scope_anchor(rows)
            coord = scope_coordinate_summary(rows)
            semantic_rows = rows["positionWrites"] + rows["motionWrites"] + rows["anchorWrites"] + rows["branchRows"]
            for row in semantic_rows:
                opcode = str(row.get("opcode") or "")
                if opcode:
                    helper_opcode_counts[opcode] += 1
                mode_hex = str(row.get("modeHex") or "")
                if mode_hex:
                    key = (
                        opcode,
                        mode_hex,
                        str(row.get("operation") or ""),
                        str(row.get("operandClass") or ""),
                    )
                    mode_semantic_counts[key] += 1
                    if len(mode_semantic_examples[key]) < 8:
                        mode_semantic_examples[key].append(
                            {
                                "helperId": helper.get("helperId"),
                                "scope": scope["scope"],
                                "destHex": row.get("destHex"),
                                "sourceHex": row.get("sourceHex"),
                                "immFixed": row.get("immFixed"),
                                "summary": row.get("summary"),
                            }
                        )
                if opcode in {"0x13", "0x14", "0x15"}:
                    target = row.get("op14TargetVaHex") or row.get("childTargetVaHex") or row.get("targetVaHex") or "-"
                    raw_target = row.get("op14RawTargetVaHex") or "-"
                    layout = row.get("op14Layout") or "-"
                    left, comparison, right = branch_operands(row)
                    key14 = (
                        opcode,
                        str(left),
                        str(comparison),
                        str(right),
                        mode_hex,
                        str(target),
                        str(raw_target),
                        str(layout),
                    )
                    branch_counts[key14] += 1
                    if len(branch_examples[key14]) < 8:
                        branch_examples[key14].append(
                            {
                                "helperId": helper.get("helperId"),
                                "scope": scope["scope"],
                                "layoutConfidence": row.get("op14LayoutConfidence"),
                                "summary": row.get("summary"),
                            }
                        )
                dest = str(row.get("destHex") or "").lower()
                if dest:
                    helper_fields[dest] += 1
                    field_counts[dest] += 1
            for row in rows["motionSteps"]:
                mode = str(row.get("modeHex") or "").lower()
                if mode:
                    helper_modes[mode] += 1
                    mode_counts[mode] += 1
                    helper_by_motion_mode[mode].add(int(helper["helperId"]))
            for pattern in patterns:
                helper_patterns.add(pattern)
                pattern_counts[pattern] += 1
                helper_by_pattern[pattern].add(int(helper["helperId"]))
            if any(rows[key] for key in ("positionWrites", "motionWrites", "branchRows", "motionSteps", "randomRanges", "frameScripts")):
                scopes.append(
                    {
                        "scope": scope["scope"],
                        "anchor": anchor,
                        "patterns": patterns,
                        "coordinateSummary": coord,
                        "positionWrites": unique_rows(rows["positionWrites"]),
                        "motionWrites": unique_rows(rows["motionWrites"]),
                        "branchRows": unique_rows(rows["branchRows"]),
                        "motionSteps": unique_rows(rows["motionSteps"]),
                        "randomRanges": rows["randomRanges"],
                        "frameScripts": rows["frameScripts"],
                    }
                )

        helper_rows.append(
            {
                "helperId": helper.get("helperId"),
                "helperClass": helper.get("helperClass"),
                "functionVaHex": helper.get("functionVaHex"),
                "childScriptVaHex": helper.get("childScriptVaHex"),
                "skillLabels": labels_from_helper(helper),
                "patterns": sorted(helper_patterns),
                "fieldCounts": dict(sorted(helper_fields.items())),
                "motionModeCounts": dict(sorted(helper_modes.items())),
                "scopes": scopes,
            }
        )

    field_rows = [
        {
            "fieldHex": field,
            **FIELD_ROLES.get(field, {"label": field, "role": "unclassified", "confidence": "unknown"}),
            "writeCount": count,
        }
        for field, count in sorted(field_counts.items())
    ]
    mode_rows = [
        {
            "modeHex": mode,
            **MOTION_MODE_ROLES.get(mode, {"label": mode, "role": "unclassified", "confidence": "unknown"}),
            "count": count,
            "helperIds": sorted(helper_by_motion_mode.get(mode, [])),
        }
        for mode, count in sorted(mode_counts.items())
    ]
    pattern_rows = [
        {"pattern": pattern, "count": count, "helperIds": sorted(helper_by_pattern.get(pattern, []))}
        for pattern, count in sorted(pattern_counts.items())
    ]
    mode_semantic_rows = [
        {
            "opcode": opcode,
            "modeHex": mode_hex,
            "operation": operation,
            "operandClass": operand,
            "count": count,
            "confidence": mode_summary_confidence(opcode, operation),
            "examples": mode_semantic_examples[(opcode, mode_hex, operation, operand)],
        }
        for (opcode, mode_hex, operation, operand), count in sorted(mode_semantic_counts.items())
    ]
    branch_rows = [
        {
            "opcode": opcode,
            "leftSource": left,
            "comparison": comparison,
            "rightSource": right,
            "modeHex": mode_hex,
            "targetVaHex": target,
            "rawTargetVaHex": raw_target,
            "layout": layout,
            "count": count,
            "examples": branch_examples[(opcode, left, comparison, right, mode_hex, target, raw_target, layout)],
        }
        for (opcode, left, comparison, right, mode_hex, target, raw_target, layout), count in sorted(branch_counts.items())
    ]

    return {
        "version": 1,
        "kind": "hwanse-battle-helper-position-motion-review",
        "source": [
            "out/battle_effect_object_review.json",
            "out/battle_helper_frame_script_review.json",
        ],
        "status": "helper-position-motion-field-review",
        "runtimeUsed": False,
        "summary": {
            "helpers": len(helper_rows),
            "helpersWithPositionOrMotion": sum(1 for row in helper_rows if row["scopes"]),
            "helpersWithMotionStep": sum(1 for row in helper_rows if row["motionModeCounts"]),
            "fieldWriteCounts": dict(sorted(field_counts.items())),
            "motionModeCounts": dict(sorted(mode_counts.items())),
            "patternCounts": dict(sorted(pattern_counts.items())),
            "frameScriptRows": len(frame.get("frameScriptRows") or []),
            "helperOpcodeCounts": dict(sorted(helper_opcode_counts.items())),
            "modeSemanticRows": len(mode_semantic_rows),
            "conditionalBranchRows": len(branch_rows),
            "op14BranchRows": sum(1 for row in branch_rows if row["opcode"] == "0x14"),
            "op13BranchRows": sum(1 for row in branch_rows if row["opcode"] == "0x13"),
            "op15BranchRows": sum(1 for row in branch_rows if row["opcode"] == "0x15"),
        },
        "interpretationNotes": [
            "+0x1c/+0x20 are confirmed display x/y fields from the existing child-script decoder.",
            "+0x80/+0x84 behave as cached target/base x/y fields in many helpers, often copied from +0x1c/+0x20 before motion.",
            "+0x68/+0x6c appear in dash helpers as temporary delta fields derived from target-current coordinate differences.",
            "+0x74/+0x78 are offset/delta accumulators. They are frequently mixed back into +0x1c/+0x20.",
            "0x2d has only three observed modes here: 0x03 relative x+y, 0x09 absolute/base x, and 0x0b absolute/base x+y.",
            "child-write 0x11 operates on child motion fields, but opcode 0x13/0x14/0x15 are not writes. Static handler table confirms they are conditional branches.",
            "0x13 is a byte-width conditional branch. All observed helper rows test parentActor.byte[+0x04] against actor-kind immediate values and use target dword at +4.",
            "0x14 immediate form uses a word right operand at +4 and target dword at +8; source-field forms use byte +3 as right selector and target dword at +4.",
            "0x15 is a field conditional branch. Observed rows compare display/motion fields against immediate fixed-point values, bit masks, or another field and use target dword at +8.",
            "0x13/0x14/0x15 are still grouped here because they control helper loop/schedule targets used by motion scripts, not because they write motion fields.",
            "The report is ready for runner promotion, but it does not yet emulate every branch target function or exact trig formula.",
        ],
        "opcodeSemanticsRows": OPCODE_SEMANTICS,
        "modeSemanticRows": mode_semantic_rows,
        "conditionalBranchScheduleRows": branch_rows,
        "op14ScheduleRows": branch_rows,
        "fieldRows": field_rows,
        "motionModeRows": mode_rows,
        "patternRows": pattern_rows,
        "helperRows": helper_rows,
    }


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Helper Position/Motion Review",
        "",
        f"- status: `{report['status']}`",
        f"- helpers: `{report['summary']['helpers']}`",
        f"- helpers with position/motion: `{report['summary']['helpersWithPositionOrMotion']}`",
        f"- helpers with motion-step: `{report['summary']['helpersWithMotionStep']}`",
        "",
        "## Interpretation",
        "",
    ]
    lines.extend(f"- {note}" for note in report["interpretationNotes"])
    lines.extend(
        [
            "",
            "## Opcode Semantics",
            "",
            "| opcode | name | confidence | meaning |",
            "| --- | --- | --- | --- |",
        ]
    )
    for row in report["opcodeSemanticsRows"]:
        lines.append(f"| `{row['opcode']}` | {row['name']} | {row['confidence']} | {row['meaning']} |")
    lines.extend(
        [
            "",
            "## Mode Semantics",
            "",
            "| opcode | mode | operation | operand | count | confidence | example |",
            "| --- | --- | --- | --- | ---: | --- | --- |",
        ]
    )
    for row in report["modeSemanticRows"]:
        example = row["examples"][0] if row.get("examples") else {}
        lines.append(
            f"| `{row['opcode']}` | `{row['modeHex']}` | {row['operation']} | {row['operandClass']} | "
            f"{row['count']} | {row['confidence']} | helper {example.get('helperId', '-')} {example.get('scope', '-')} · {example.get('summary', '-')} |"
        )
    lines.extend(
        [
            "",
            "## Conditional Branch Targets",
            "",
            "| opcode | left | cmp | right | mode | layout | normalized target | raw target | count | sample helpers |",
            "| --- | --- | --- | --- | --- | --- | --- | --- | ---: | --- |",
        ]
    )
    for row in report["op14ScheduleRows"]:
        helpers = compact([item.get("helperId") for item in row.get("examples") or []])
        lines.append(
            f"| `{row['opcode']}` | {row['leftSource']} | `{row['comparison']}` | {row['rightSource']} | `{row['modeHex']}` | {row['layout']} | "
            f"`{row['targetVaHex']}` | `{row['rawTargetVaHex']}` | {row['count']} | {helpers} |"
        )
    lines.extend(
        [
            "",
            "## Field Roles",
            "",
            "| field | label | count | confidence | role |",
            "| --- | --- | ---: | --- | --- |",
        ]
    )
    for row in report["fieldRows"]:
        lines.append(
            f"| `{row['fieldHex']}` | {row['label']} | {row['writeCount']} | {row['confidence']} | {row['role']} |"
        )
    lines.extend(
        [
            "",
            "## Motion Modes",
            "",
            "| mode | label | count | helpers | role |",
            "| --- | --- | ---: | --- | --- |",
        ]
    )
    for row in report["motionModeRows"]:
        lines.append(
            f"| `{row['modeHex']}` | {row['label']} | {row['count']} | {compact(row['helperIds'])} | {row['role']} |"
        )
    lines.extend(
        [
            "",
            "## Helper Patterns",
            "",
            "| helper | class | skills | patterns | key coordinate summary |",
            "| ---: | --- | --- | --- | --- |",
        ]
    )
    for row in report["helperRows"]:
        coord = compact([scope.get("coordinateSummary") for scope in row["scopes"] if scope.get("coordinateSummary") != "-"], limit=3)
        lines.append(
            f"| {row['helperId']} | `{row['helperClass']}` | {compact(row['skillLabels'], 4)} | {compact(row['patterns'], 6)} | {coord} |"
        )
    return "\n".join(lines) + "\n"


def html_page(report: dict[str, Any]) -> str:
    summary_rows = "".join(
        f"<tr><td>{esc(key)}</td><td><code>{esc(value)}</code></td></tr>"
        for key, value in report["summary"].items()
    )
    notes = "".join(f"<li>{esc(note)}</li>" for note in report["interpretationNotes"])
    opcode_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['opcode'])}</code></td>"
        f"<td>{esc(row['name'])}</td>"
        f"<td><span class=\"tag\">{esc(row['confidence'])}</span></td>"
        f"<td>{esc(row['meaning'])}</td>"
        "</tr>"
        for row in report["opcodeSemanticsRows"]
    )
    mode_semantic_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['opcode'])}</code></td>"
        f"<td><code>{esc(row['modeHex'])}</code></td>"
        f"<td>{esc(row['operation'])}</td>"
        f"<td>{esc(row['operandClass'])}</td>"
        f"<td>{esc(row['count'])}</td>"
        f"<td><span class=\"tag\">{esc(row['confidence'])}</span></td>"
        f"<td>{esc((row.get('examples') or [{}])[0].get('summary', '-'))}</td>"
        "</tr>"
        for row in report["modeSemanticRows"]
    )
    op14_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['opcode'])}</code></td>"
        f"<td>{esc(row['leftSource'])}</td>"
        f"<td><code>{esc(row['comparison'])}</code></td>"
        f"<td>{esc(row['rightSource'])}</td>"
        f"<td><code>{esc(row['modeHex'])}</code></td>"
        f"<td>{esc(row['layout'])}</td>"
        f"<td><code>{esc(row['targetVaHex'])}</code></td>"
        f"<td><code>{esc(row['rawTargetVaHex'])}</code></td>"
        f"<td>{esc(row['count'])}</td>"
        f"<td>{esc(compact([item.get('helperId') for item in row.get('examples') or []]))}</td>"
        "</tr>"
        for row in report["op14ScheduleRows"]
    )
    field_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['fieldHex'])}</code></td>"
        f"<td>{esc(row['label'])}</td>"
        f"<td>{esc(row['writeCount'])}</td>"
        f"<td><span class=\"tag\">{esc(row['confidence'])}</span></td>"
        f"<td>{esc(row['role'])}</td>"
        "</tr>"
        for row in report["fieldRows"]
    )
    mode_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['modeHex'])}</code></td>"
        f"<td>{esc(row['label'])}</td>"
        f"<td>{esc(row['count'])}</td>"
        f"<td>{esc(compact(row['helperIds']))}</td>"
        f"<td>{esc(row['role'])}</td>"
        "</tr>"
        for row in report["motionModeRows"]
    )
    pattern_rows = "".join(
        "<tr>"
        f"<td>{esc(row['pattern'])}</td>"
        f"<td>{esc(row['count'])}</td>"
        f"<td>{esc(compact(row['helperIds'], 20))}</td>"
        "</tr>"
        for row in report["patternRows"]
    )
    helper_rows = []
    for row in report["helperRows"]:
        scope_bits = []
        for scope in row["scopes"]:
            motion = "<br>".join(
                esc(f"{item.get('modeHex')} {item.get('summary')}")
                for item in scope.get("motionSteps") or []
            ) or "-"
            pos = "<br>".join(
                esc(f"{field_tag(item)} {item.get('summary')}")
                for item in (scope.get("positionWrites") or [])[:10]
            ) or "-"
            child = "<br>".join(
                esc(f"{field_tag(item)} {item.get('summary')}")
                for item in (scope.get("motionWrites") or [])[:10]
            ) or "-"
            scripts = "<br>".join(
                esc(f"{item.get('targetVaHex')} [{compact(item.get('frameLabels') or [])}]")
                for item in scope.get("frameScripts") or []
            ) or "-"
            scope_bits.append(
                "<details>"
                f"<summary><code>{esc(scope['scope'])}</code> · {esc(scope['anchor'])} · {esc(compact(scope['patterns']))}</summary>"
                "<table class=\"nested\"><tbody>"
                f"<tr><th>coord</th><td>{esc(scope['coordinateSummary'])}</td></tr>"
                f"<tr><th>position</th><td>{pos}</td></tr>"
                f"<tr><th>child motion</th><td>{child}</td></tr>"
                f"<tr><th>motion step</th><td>{motion}</td></tr>"
                f"<tr><th>frameScript</th><td>{scripts}</td></tr>"
                "</tbody></table>"
                "</details>"
            )
        helper_rows.append(
            "<tr>"
            f"<td><code>{esc(row['helperId'])}</code></td>"
            f"<td><code>{esc(row['helperClass'])}</code><br><small>{esc(row.get('functionVaHex'))}</small></td>"
            f"<td>{esc(compact(row['skillLabels'], 8))}</td>"
            f"<td>{esc(compact(row['patterns'], 8))}</td>"
            f"<td>{''.join(scope_bits) or '-'}</td>"
            "</tr>"
        )
    helper_table = "".join(helper_rows)
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Battle Helper Position/Motion Review</title>
  <style>
    body {{ margin: 0; padding: 24px; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f6f7f9; color: #151923; }}
    h1, h2 {{ margin: 0 0 12px; }}
    h2 {{ margin-top: 24px; font-size: 18px; }}
    p, li {{ color: #4b5565; }}
    a {{ color: #1f6feb; text-decoration: none; }}
    a:hover {{ text-decoration: underline; }}
    table {{ width: 100%; border-collapse: collapse; margin: 12px 0 20px; background: #fff; border: 1px solid #d9dee7; }}
    th, td {{ border: 1px solid #d9dee7; padding: 7px 8px; vertical-align: top; text-align: left; font-size: 13px; }}
    th {{ background: #eef2f8; color: #344054; }}
    code {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }}
    details {{ margin: 0 0 6px; }}
    summary {{ cursor: pointer; color: #25324a; }}
    .nested {{ margin: 6px 0 10px; }}
    .nested th {{ width: 110px; }}
    .tag {{ display: inline-block; padding: 2px 6px; border-radius: 999px; background: #eef2f8; color: #31415f; font-size: 12px; }}
    .links {{ display: flex; flex-wrap: wrap; gap: 10px; margin: 8px 0 18px; }}
  </style>
</head>
<body>
  <h1>Battle Helper Position/Motion Review</h1>
  <div class="links">
    <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_resource_flow_review.html">helper random/motion/control</a>
  </div>
  <p>0x11/0x12/0x2d position/motion primitives and 0x13/0x14/0x15 branch-loop controls grouped into a runner-ready coordinate model.</p>
  <h2>Summary</h2>
  <table><tbody>{summary_rows}</tbody></table>
  <h2>Interpretation</h2>
  <ul>{notes}</ul>
  <h2>Opcode Semantics</h2>
  <table><thead><tr><th>opcode</th><th>name</th><th>confidence</th><th>meaning</th></tr></thead><tbody>{opcode_rows}</tbody></table>
  <h2>Mode Semantics</h2>
  <table><thead><tr><th>opcode</th><th>mode</th><th>operation</th><th>operand</th><th>count</th><th>confidence</th><th>example</th></tr></thead><tbody>{mode_semantic_rows}</tbody></table>
  <h2>Conditional Branch Targets</h2>
  <table><thead><tr><th>opcode</th><th>left</th><th>cmp</th><th>right</th><th>mode</th><th>layout</th><th>normalized target</th><th>raw target</th><th>count</th><th>helpers</th></tr></thead><tbody>{op14_rows}</tbody></table>
  <h2>Field Roles</h2>
  <table><thead><tr><th>field</th><th>label</th><th>count</th><th>confidence</th><th>role</th></tr></thead><tbody>{field_rows}</tbody></table>
  <h2>Motion Modes</h2>
  <table><thead><tr><th>mode</th><th>label</th><th>count</th><th>helpers</th><th>role</th></tr></thead><tbody>{mode_rows}</tbody></table>
  <h2>Pattern Groups</h2>
  <table><thead><tr><th>pattern</th><th>count</th><th>helpers</th></tr></thead><tbody>{pattern_rows}</tbody></table>
  <h2>Helper Rows</h2>
  <table><thead><tr><th>helper</th><th>class</th><th>skills</th><th>patterns</th><th>scopes</th></tr></thead><tbody>{helper_table}</tbody></table>
</body>
</html>
"""


def main() -> None:
    report = build()
    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.relative_to(ROOT)}")
    print(f"wrote {OUT_MD.relative_to(ROOT)}")
    print(f"wrote {OUT_HTML.relative_to(ROOT)}")


if __name__ == "__main__":
    main()
