#!/usr/bin/env python3
"""Classify battle display VM movement/placement patterns.

This report is intentionally narrower than battle_skill_pattern_review.py.  It
joins the action payload target scope with the promoted display VM movement
opcodes so the browser preview can stop guessing whether an action should play
in front of a single target, through a target, around a target, or in the field.
"""
from __future__ import annotations

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


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
ACTION_JSON = OUT / "battle_action_mapping.json"
DISPLAY_JSON = OUT / "battle_display_vm_static_decode.json"


POSITION_FIELD_LABELS = {
    "0x00": "display visibility/large offset",
    "0x1c": "actor/display x",
    "0x20": "actor/display y",
    "0x28": "sprite/frame selector",
    "0x80": "child/effect x latch",
    "0x84": "child/effect y latch",
}


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


def hex_byte(value: int | None) -> str:
    return "-" if value is None else f"0x{int(value) & 0xff:02x}"


def helper_id(instr: dict[str, Any]) -> int | None:
    raw = str(instr.get("bytes") or "")
    parts = raw.split()
    if len(parts) >= 3 and parts[0].lower() == "bd":
        try:
            return int(parts[1], 16) | (int(parts[2], 16) << 8)
        except ValueError:
            return None
    match = re.search(r"id=(\d+)", str(instr.get("summary") or ""))
    return int(match.group(1)) if match else None


def helper_evidence(row: dict[str, Any]) -> dict[str, Any]:
    helper_ids = []
    clear_display_lists = []
    child_targets = set()
    spawn_child_targets = set()
    hit_flag_count = 0
    for instr in row.get("rows") or []:
        category = instr.get("category")
        if category == "cleanup/helper":
            value = helper_id(instr)
            if value is not None:
                helper_ids.append(value)
        elif category == "clear-display-list":
            clear_display_lists.append(instr.get("maskHex") or instr.get("summary") or "")
        elif category == "child-write" and instr.get("childTargetVaHex"):
            child_targets.add(instr.get("childTargetVaHex"))
        elif category == "spawn-child-vm" and instr.get("targetVaHex"):
            spawn_child_targets.add(instr.get("targetVaHex"))
        elif (
            category == "actor-flags"
            and int(instr.get("mode") or 0) == 2
            and int(instr.get("mask") or 0) == 0x0C
        ):
            hit_flag_count += 1
    return {
        "helperIds": helper_ids,
        "effectWlkNos": [item.get("wlkNo") for item in row.get("effectSounds") or [] if item.get("wlkNo")],
        "resultWlkNos": [item.get("wlkNo") for item in row.get("sounds") or [] if item.get("wlkNo")],
        "clearDisplayLists": clear_display_lists,
        "childTargets": sorted(child_targets),
        "spawnChildTargets": sorted(spawn_child_targets),
        "hitFlagCount": hit_flag_count,
    }


def scope_class(scopes: list[int]) -> str:
    s = set(scopes or [])
    if s == {0x0A}:
        return "enemy-single"
    if s == {0x06}:
        return "enemy-all"
    if s == {0x09}:
        return "ally-single"
    if s == {0x05}:
        return "ally-all"
    if s == {0x01}:
        return "self"
    if s == {0x01, 0x0A}:
        return "self-then-enemy-single"
    if s == {0x01, 0x06}:
        return "self-then-enemy-all"
    if not s:
        return "unknown"
    return "mixed-" + ",".join(hex_byte(value) for value in sorted(s))


def movement_list(row: dict[str, Any]) -> list[dict[str, Any]]:
    result = []
    for item in row.get("movements") or []:
        result.append(
            {
                "mode": item.get("movementMode"),
                "selector": item.get("selector"),
                "divisor": item.get("divisor"),
                "vaHex": item.get("vaHex"),
                "summary": item.get("summary"),
            }
        )
    return result


def movement_signature(movements: list[dict[str, Any]]) -> str:
    if not movements:
        return "-"
    parts = []
    for movement in movements:
        mode = int(movement.get("mode") or 0)
        selector = int(movement.get("selector") or 0)
        divisor = int(movement.get("divisor") or 0)
        if mode == 0:
            parts.append(f"direct/s{selector}")
        else:
            parts.append(f"m{mode}/s{selector}/d{divisor}")
    return " -> ".join(parts)


def write_summary(row: dict[str, Any]) -> dict[str, Any]:
    writes = []
    for instr in row.get("rows") or []:
        if instr.get("category") != "write":
            continue
        if instr.get("destHex") not in {"0x1c", "0x20"}:
            continue
        writes.append(
            {
                "vaHex": instr.get("vaHex"),
                "opName": instr.get("opName"),
                "destHex": instr.get("destHex"),
                "immFixed": instr.get("immFixed"),
                "summary": instr.get("summary"),
            }
        )
    x_values = [item["immFixed"] for item in writes if item["destHex"] == "0x1c"]
    y_values = [item["immFixed"] for item in writes if item["destHex"] == "0x20"]
    return {
        "count": len(writes),
        "xValues": x_values,
        "yValues": y_values,
        "rows": writes,
    }


def position_write_summary(row: dict[str, Any]) -> dict[str, Any]:
    writes = []
    by_dest: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for instr in row.get("rows") or []:
        if instr.get("category") != "write":
            continue
        dest = instr.get("destHex")
        if dest not in POSITION_FIELD_LABELS:
            continue
        item = {
            "vaHex": instr.get("vaHex"),
            "opName": instr.get("opName"),
            "destHex": dest,
            "destLabel": POSITION_FIELD_LABELS[dest],
            "sourceHex": instr.get("sourceHex"),
            "immediate": bool(instr.get("immediate")),
            "immFixed": instr.get("immFixed"),
            "summary": instr.get("summary"),
        }
        writes.append(item)
        by_dest[dest].append(item)
    return {
        "count": len(writes),
        "destCounts": {dest: len(items) for dest, items in sorted(by_dest.items())},
        "destLabels": {dest: POSITION_FIELD_LABELS[dest] for dest in sorted(by_dest)},
        "rows": writes,
    }


def compact_instruction(instr: dict[str, Any]) -> dict[str, Any]:
    item = {
        "vaHex": instr.get("vaHex"),
        "category": instr.get("category"),
        "opcode": instr.get("opcode"),
        "summary": instr.get("summary"),
    }
    for key in (
        "frame",
        "gate",
        "spriteHex",
        "movementMode",
        "selector",
        "divisor",
        "mode",
        "maskHex",
        "destHex",
        "sourceHex",
        "opName",
        "immFixed",
        "wlkNo",
        "repeatCount",
        "targetVaHex",
    ):
        if key in instr:
            item[key] = instr.get(key)
    return {key: value for key, value in item.items() if value is not None}


def meaningful_context_rows(rows: list[dict[str, Any]], index: int, before: int, after: int) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    skip = {"branch"}
    prev_rows = []
    cursor = index - 1
    while cursor >= 0 and len(prev_rows) < before:
        instr = rows[cursor]
        if instr.get("category") not in skip:
            prev_rows.append(compact_instruction(instr))
        cursor -= 1
    prev_rows.reverse()
    next_rows = []
    cursor = index + 1
    while cursor < len(rows) and len(next_rows) < after:
        instr = rows[cursor]
        if instr.get("category") not in skip:
            next_rows.append(compact_instruction(instr))
        cursor += 1
    return prev_rows, next_rows


def movement_role(movement: dict[str, Any]) -> str:
    mode = int(movement.get("movementMode") or movement.get("mode") or 0)
    selector = int(movement.get("selector") or 0)
    if mode == 0 and selector == 0:
        return "return-home"
    if mode == 0 and selector == 2:
        return "target-side-direct"
    if mode == 0 and selector == 3:
        return "target-front-direct"
    if mode == 0 and selector == 5:
        return "clone/spread-direct"
    if mode != 0 and selector == 1:
        return "target-approach-motion"
    if mode != 0 and selector == 2:
        return "target-pierce-motion"
    if mode != 0 and selector == 3:
        return "target-opposite-side-motion"
    return f"movement-mode-{mode}-selector-{selector}"


def movement_contexts(row: dict[str, Any]) -> list[dict[str, Any]]:
    rows = row.get("rows") or []
    contexts = []
    for index, instr in enumerate(rows):
        if instr.get("category") != "movement":
            continue
        prev_rows, next_rows = meaningful_context_rows(rows, index, before=5, after=8)
        prior_hit_flags = sum(
            1
            for item in rows[:index]
            if item.get("category") == "actor-flags"
            and int(item.get("mode") or 0) == 2
            and int(item.get("mask") or 0) == 0x0C
        )
        following_hit_flags = sum(
            1
            for item in rows[index + 1:index + 9]
            if item.get("category") == "actor-flags"
            and int(item.get("mode") or 0) == 2
            and int(item.get("mask") or 0) == 0x0C
        )
        nearby_writes = [
            compact_instruction(item)
            for item in rows[max(0, index - 5):min(len(rows), index + 9)]
            if item.get("category") == "write" and item.get("destHex") in POSITION_FIELD_LABELS
        ]
        context = compact_instruction(instr)
        context.update(
            {
                "index": index,
                "role": movement_role(instr),
                "priorHitFlagCount": prior_hit_flags,
                "followingHitFlagCount": following_hit_flags,
                "nearbyPositionWrites": nearby_writes,
                "before": prev_rows,
                "after": next_rows,
            }
        )
        contexts.append(context)
    return contexts


def start_placement_policy(target_class: str, position_class: str, contexts: list[dict[str, Any]]) -> str:
    non_reset = [ctx for ctx in contexts if ctx.get("role") != "return-home"]
    if not non_reset:
        if position_class == "single-target-front-local-combo-inferred":
            return "target-front-inferred-before-frames"
        if position_class == "single-target-front-basic-implicit":
            return "target-front-implicit-basic"
        if position_class.startswith("single-target-ranged-"):
            return "caster-ranged-horizontal"
        if position_class == "all-target-screen-or-ranged-effect":
            return "caster-screen-effect"
        if position_class == "all-target-caster-action-effect":
            return "caster-local-offset"
        if position_class.endswith("support"):
            return "support-side"
        if position_class == "all-target-caster-local-offset":
            return "caster-local-offset"
        return "home"
    first = non_reset[0]
    role = first.get("role")
    if position_class.startswith("self-prep-all-target-"):
        return "self-prep-screen-effect-placement"
    if role == "target-front-direct":
        return "target-front-before-frames"
    if role == "target-side-direct":
        return "target-side-before-frames"
    if role == "target-opposite-side-motion":
        return "target-opposite-side-motion-before-frames"
    if role == "clone/spread-direct":
        return "clone-spread-before-frames"
    if role == "target-pierce-motion" and int(first.get("priorHitFlagCount") or 0) > 0:
        return "target-front-then-pierce"
    if role == "target-pierce-motion":
        return "home-then-pierce"
    if role == "target-approach-motion":
        return "home-approach-target"
    return f"{role or 'unknown'}-before-frames"


def infer_position(
    target_class: str,
    movements: list[dict[str, Any]],
    writes: dict[str, Any],
    row: dict[str, Any],
    evidence: dict[str, Any],
) -> tuple[str, str, str, list[str]]:
    """Return (position_class, preview_anchor, confidence, notes)."""
    selectors = [(int(m.get("mode") or 0), int(m.get("selector") or 0), int(m.get("divisor") or 0)) for m in movements]
    non_reset = [(m, s, d) for m, s, d in selectors if not (m == 0 and s == 0)]
    notes: list[str] = []

    if target_class == "enemy-single":
        if any(mode != 0 and selector == 2 for mode, selector, _ in non_reset):
            notes.append("single-target action has target-relative motion selector 2 before returning with selector 0")
            return "target-pierce-motion", "enemy-through", "medium", notes
        if any(mode != 0 and selector == 1 for mode, selector, _ in non_reset):
            notes.append("single-target action has target-relative motion selector 1; divisor changes with speed-scaling skills")
            return "target-approach-motion", "enemy-front", "medium", notes
        if any(mode == 0 and selector == 3 for mode, selector, _ in non_reset):
            notes.append("single-target action uses direct placement selector 3 and then returns with selector 0")
            return "target-front-direct-placement", "enemy-front", "medium", notes
        if any(mode == 0 and selector == 2 for mode, selector, _ in non_reset):
            notes.append("single-target action uses direct placement selector 2/3 pair; likely target-side/orbit placement")
            return "target-side-direct-placement", "enemy-side", "medium", notes
        if writes["count"]:
            if evidence["effectWlkNos"]:
                notes.append("single-target action has local caster writes plus 0x24 effect WLK cues; treat as ranged/effect cast from a horizontal distance, not target-front melee")
                return "single-target-ranged-caster-effect", "enemy-ranged", "medium", notes
            notes.append("single-target action has local pose writes but no 0x24 cast/effect cue; inferred as close target-front combo even though the target placement is implicit")
            return "single-target-front-local-combo-inferred", "enemy-front", "medium", notes
        if evidence["effectWlkNos"] or evidence["helperIds"] or evidence["clearDisplayLists"] or evidence["spawnChildTargets"]:
            notes.append("single-target action has no promoted target placement but does have helper/effect cues; treat as ranged/engine-side effect from horizontal caster position")
            return "single-target-ranged-engine-effect", "enemy-ranged", "medium", notes
        if evidence["hitFlagCount"] or evidence["resultWlkNos"]:
            notes.append("single-target action has hit/result evidence but no movement opcode; treat as implicit target-front basic/local strike selected by the surrounding battle actor placement.")
            return "single-target-front-basic-implicit", "enemy-front", "medium", notes
        notes.append("single-target action has no promoted movement, placement, hit, or helper opcode; projectile/engine-side target binding remains unresolved")
        return "no-promoted-target-placement", "caster", "low", notes

    if target_class == "self-then-enemy-single":
        if any(mode == 0 and selector == 3 for mode, selector, _ in non_reset):
            notes.append("self-prep plus single-target hit uses direct placement selector 3")
            return "self-prep-target-front-direct", "enemy-front", "medium", notes
        notes.append("self-prep plus target payload, but no promoted target movement opcode")
        return "self-prep-caster-local", "caster", "low", notes

    if target_class == "self-then-enemy-all":
        if any(mode == 0 and selector == 5 for mode, selector, _ in non_reset):
            notes.append("self-prep plus all-enemy payload uses direct selector 5; this matches drink/prep animation followed by screen/all-target effect placement.")
            return "self-prep-all-target-screen-effect", "caster-special", "medium", notes
        if writes["count"]:
            notes.append("self-prep plus all-enemy payload has local caster writes before the all-target result.")
            return "self-prep-all-target-caster-effect", "caster", "medium", notes
        notes.append("self-prep plus all-enemy payload has no promoted movement; keep caster-side effect preview.")
        return "self-prep-all-target-no-promoted-position", "caster", "low", notes

    if target_class == "enemy-all":
        if any(mode == 0 and selector == 5 for mode, selector, _ in non_reset):
            notes.append("all-enemy action uses direct selector 5; this appears in 분신술 clone/spread setup")
            return "all-target-clone-placement", "caster-special", "medium", notes
        if non_reset:
            notes.append("all-enemy action has non-reset movement, but selector meaning is not promoted")
            return "all-target-special-placement", "caster-special", "low", notes
        if writes["count"]:
            notes.append("all-enemy action has no target-front movement, but +0x1c/+0x20 local writes move the caster pose/effect during the action")
            return "all-target-caster-action-effect", "caster", "medium", notes
        if evidence["effectWlkNos"] or evidence["helperIds"] or evidence["clearDisplayLists"] or evidence["spawnChildTargets"]:
            notes.append("all-enemy action has helper/effect cues but no actor placement; treat as screen/ranged effect, not a center-placement rule")
            return "all-target-screen-or-ranged-effect", "caster", "medium", notes
        notes.append("all-enemy action has no promoted target-front movement or local position write; caster-side/in-place preview is the conservative choice")
        return "all-target-no-promoted-position", "caster", "medium", notes

    if target_class in {"self", "ally-single", "ally-all"}:
        notes.append("support/self action has no enemy target scope; preview stays on caster/allied side")
        return f"{target_class}-support", "caster", "medium", notes

    notes.append("target scope not yet interpreted")
    return "unknown-target-scope", "caster", "low", notes


def row_key(row: dict[str, Any]) -> tuple[str, str]:
    return str(row.get("ownerKey")), str(row.get("skillIdHex"))


def build() -> dict[str, Any]:
    mapping = json.loads(ACTION_JSON.read_text(encoding="utf-8"))
    display = json.loads(DISPLAY_JSON.read_text(encoding="utf-8"))
    display_by_key = {row_key(row): row for row in display.get("decodedRows") or []}

    rows = []
    for action in mapping.get("playerRows") or []:
        key = row_key(action)
        decoded = display_by_key.get(key)
        if not decoded:
            continue
        movements = movement_list(decoded)
        writes = write_summary(decoded)
        position_writes = position_write_summary(decoded)
        contexts = movement_contexts(decoded)
        evidence = helper_evidence(decoded)
        target = scope_class(action.get("targetScopes") or [])
        position, anchor, confidence, notes = infer_position(target, movements, writes, action, evidence)
        start_policy = start_placement_policy(target, position, contexts)
        row = {
            "ownerKey": action.get("ownerKey"),
            "ownerName": action.get("ownerName"),
            "skillIdHex": action.get("skillIdHex"),
            "skillName": action.get("name"),
            "familyName": decoded.get("familyName"),
            "levelOrFixed": action.get("levelOrFixed"),
            "markerHex": action.get("markerHex"),
            "mpCost": action.get("mpCost"),
            "effectCount": action.get("effectCount"),
            "targetScopes": action.get("targetScopes") or [],
            "targetScopeHex": [hex_byte(value) for value in action.get("targetScopes") or []],
            "targetClass": target,
            "families": action.get("families") or [],
            "statuses": action.get("statuses") or [],
            "entryStartVaHex": decoded.get("entryStartVaHex"),
            "movementSignature": movement_signature(movements),
            "movements": movements,
            "writeSummary": writes,
            "positionWriteSummary": position_writes,
            "movementContexts": contexts,
            "frameSequence": decoded.get("frameSequence") or [],
            "soundWlkNos": [item.get("wlkNo") for item in decoded.get("sounds") or []],
            "effectWlkNos": [item.get("wlkNo") for item in decoded.get("effectSounds") or []],
            "helperEvidence": evidence,
            "positionClass": position,
            "previewAnchor": anchor,
            "startPlacementPolicy": start_policy,
            "confidence": confidence,
            "notes": notes,
        }
        rows.append(row)

    movement_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for row in rows:
        movement_groups[f"{row['targetClass']} | {row['positionClass']} | {row['movementSignature']}"].append(row)

    group_rows = []
    for key, items in sorted(movement_groups.items(), key=lambda item: (item[0], item[1][0]["ownerKey"], item[1][0]["skillIdHex"])):
        group_rows.append(
            {
                "key": key,
                "count": len(items),
                "targetClass": items[0]["targetClass"],
                "movementSignature": items[0]["movementSignature"],
                "positionClasses": sorted({item["positionClass"] for item in items}),
                "previewAnchors": sorted({item["previewAnchor"] for item in items}),
                "startPlacementPolicies": sorted({item["startPlacementPolicy"] for item in items}),
                "confidenceCounts": dict(Counter(item["confidence"] for item in items)),
                "examples": [
                    {
                        "ownerName": item["ownerName"],
                        "skillName": item["skillName"],
                        "skillIdHex": item["skillIdHex"],
                        "effectCount": item["effectCount"],
                    }
                    for item in items[:10]
                ],
            }
        )

    return {
        "version": 1,
        "kind": "hwanse-battle-movement-pattern-review",
        "source": ["out/battle_action_mapping.json", "out/battle_display_vm_static_decode.json"],
        "status": "scope-plus-display-vm-movement-patterns",
        "summary": {
            "decodedActionRows": len(rows),
            "targetClassCounts": dict(Counter(row["targetClass"] for row in rows)),
            "positionClassCounts": dict(Counter(row["positionClass"] for row in rows)),
            "movementGroupCount": len(group_rows),
            "confidenceCounts": dict(Counter(row["confidence"] for row in rows)),
            "startPlacementPolicyCounts": dict(Counter(row["startPlacementPolicy"] for row in rows)),
            "rangeClassCounts": dict(Counter(row["positionClass"] for row in rows if "ranged" in row["positionClass"] or "front-local-combo" in row["positionClass"])),
        },
        "interpretationNotes": [
            "targetScopes 0x0a is promoted as single-enemy target, 0x06 as all-enemy target, 0x09/0x05/0x01 as ally/self support scopes.",
            "0xbc movement selector 0 is a return/reset marker in promoted player skill streams.",
            "Nonzero movement selector 1/2 marks target-relative motion for single-target skills; divisor is speed/timing, not hit count.",
            "Direct placement selector 3 appears in single-target skills that need a target-front placement without a moving dash.",
            "Direct placement selector 2 appears in orbit/side style skills and usually prepares child/effect position fields +0x80/+0x84.",
            "Direct placement selector 5 appears in 분신술 and is treated as clone/spread setup.",
            "The actor/display fields +0x1c/+0x20 are local x/y offsets. Fields +0x80/+0x84 are promoted as child/effect x/y latches when they copy from +0x1c/+0x20.",
            "All-enemy skills split into no-position-opcode, screen/ranged effect, caster action/effect, and clone/spread setup rows. They must not be collapsed to a single center-placement rule.",
            "Single-target skills also split by evidence. Local pose writes without 0x24 effect cues are treated as implicit close target-front combos; local/no-placement rows with 0x24 or helper-only effect cues are treated as ranged/engine-side effects from a horizontal distance.",
            "This means target scope is not a placement rule. Personal skills may be melee, dash/pierce, orbit, or ranged beam/effect depending on display VM movement/write/helper evidence.",
        ],
        "rows": rows,
        "movementGroups": group_rows,
    }


def vector(value: Any) -> str:
    if isinstance(value, list):
        return ", ".join(vector(item) for item in value) or "-"
    return str(value)


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Movement Pattern Review",
        "",
        f"- status: `{report['status']}`",
        f"- decoded action rows: `{report['summary']['decodedActionRows']}`",
        f"- movement groups: `{report['summary']['movementGroupCount']}`",
        "",
        "## Interpretation",
        "",
    ]
    lines.extend(f"- {note}" for note in report["interpretationNotes"])
    lines.extend(
        [
            "",
            "## Movement Groups",
            "",
            "| target | movement | count | position | anchor | start policy | confidence | examples |",
            "| --- | --- | ---: | --- | --- | --- | --- | --- |",
        ]
    )
    for group in report["movementGroups"]:
        examples = "; ".join(f"{item['ownerName']} {item['skillName']} {item['skillIdHex']}" for item in group["examples"])
        lines.append(
            f"| `{group['targetClass']}` | `{group['movementSignature']}` | {group['count']} | "
            f"{', '.join('`'+item+'`' for item in group['positionClasses'])} | "
            f"{', '.join('`'+item+'`' for item in group['previewAnchors'])} | "
            f"{', '.join('`'+item+'`' for item in group['startPlacementPolicies'])} | "
            f"`{group['confidenceCounts']}` | {examples} |"
        )
    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"])
    group_rows = []
    for group in report["movementGroups"]:
        examples = "<br>".join(
            f"{esc(item['ownerName'])} {esc(item['skillName'])} <code>{esc(item['skillIdHex'])}</code> hit {esc(item['effectCount'])}"
            for item in group["examples"]
        )
        group_rows.append(
            "<tr>"
            f"<td><code>{esc(group['targetClass'])}</code></td>"
            f"<td><code>{esc(group['movementSignature'])}</code></td>"
            f"<td>{esc(group['count'])}</td>"
            f"<td>{esc(vector(group['positionClasses']))}</td>"
            f"<td>{esc(vector(group['previewAnchors']))}</td>"
            f"<td>{esc(vector(group['startPlacementPolicies']))}</td>"
            f"<td>{esc(group['confidenceCounts'])}</td>"
            f"<td>{examples}</td>"
            "</tr>"
        )
    skill_rows = []
    for row in report["rows"]:
        notes_text = "<br>".join(esc(note) for note in row["notes"])
        write_text = (
            f"x={esc(vector(row['writeSummary']['xValues']))}<br>y={esc(vector(row['writeSummary']['yValues']))}"
            if row["writeSummary"]["count"]
            else "-"
        )
        position_write_text = "<br>".join(
            f"<code>{esc(dest)}</code> {esc(label)} x{esc(count)}"
            for dest, count in row["positionWriteSummary"]["destCounts"].items()
            for label in [row["positionWriteSummary"]["destLabels"].get(dest, "")]
        ) or "-"
        helper = row.get("helperEvidence") or {}
        helper_text = "<br>".join(
            [
                f"0xbd: {esc(vector(helper.get('helperIds') or []))}",
                f"0x24: {esc(vector(['#'+str(item) for item in helper.get('effectWlkNos') or []]))}",
                f"0xc2: {esc(vector(['#'+str(item) for item in helper.get('resultWlkNos') or []]))}",
                f"clear: {esc(vector(helper.get('clearDisplayLists') or []))}",
                f"spawn: {esc(vector(helper.get('spawnChildTargets') or []))}",
                f"child: {esc(vector(helper.get('childTargets') or []))}",
            ]
        )
        context_text = "<br><br>".join(
            "<b>"
            + esc(ctx["role"])
            + "</b> "
            + f"<code>{esc(ctx.get('vaHex'))}</code> "
            + f"hitBefore={esc(ctx.get('priorHitFlagCount'))} hitAfter={esc(ctx.get('followingHitFlagCount'))}<br>"
            + esc(" / ".join(item.get("summary", "") for item in ctx.get("nearbyPositionWrites", [])) or "-")
            for ctx in row["movementContexts"]
        ) or "-"
        skill_rows.append(
            "<tr>"
            f"<td>{esc(row['ownerName'])}</td>"
            f"<td>{esc(row['skillName'])}<br><code>{esc(row['skillIdHex'])}</code></td>"
            f"<td><code>{esc(row['targetClass'])}</code><br>{esc(vector(row['targetScopeHex']))}</td>"
            f"<td><code>{esc(row['movementSignature'])}</code></td>"
            f"<td>{write_text}</td>"
            f"<td>{helper_text}</td>"
            f"<td>{position_write_text}</td>"
            f"<td><code>{esc(row['positionClass'])}</code><br>anchor <code>{esc(row['previewAnchor'])}</code><br>start <code>{esc(row['startPlacementPolicy'])}</code><br>{esc(row['confidence'])}</td>"
            f"<td>{context_text}</td>"
            f"<td>{notes_text}</td>"
            f"<td>{esc(vector(row['frameSequence'][:28]))}</td>"
            "</tr>"
        )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" href="../favicon.ico">
  <title>Battle Movement Pattern Review</title>
  <style>
    body {{ margin: 20px; background: #101114; color: #f1f3f5; font-family: system-ui, sans-serif; }}
    a {{ color: #9ecbff; }} code {{ color: #ffd37a; }}
    table {{ width: 100%; border-collapse: collapse; margin: 14px 0 24px; }}
    th, td {{ border: 1px solid #30343d; padding: 6px 8px; font-size: 12px; vertical-align: top; }}
    th {{ background: #1a1d24; color: #bac2cf; position: sticky; top: 0; z-index: 2; }}
    tr:nth-child(even) td {{ background: #141820; }}
    .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px; }}
    .panel {{ border: 1px solid #30343d; border-radius: 8px; padding: 12px; background: #151821; }}
    .wide {{ overflow: auto; max-height: 78vh; border: 1px solid #30343d; }}
  </style>
</head>
<body>
  <h1>Battle Movement Pattern Review</h1>
  <p><a href="../web/index.html">홈</a> · <a href="../web/battle_simulator.html">전투 기술 실행</a> · <a href="battle_display_vm_static_decode.html">VM 정적 디코드</a> · <a href="battle_movement_pattern_review.json">JSON</a> · <a href="battle_movement_pattern_review.md">MD</a></p>
  <div class="grid">
    <section class="panel"><h2>Summary</h2><table><tbody>{summary_rows}</tbody></table></section>
    <section class="panel"><h2>Interpretation</h2><ul>{notes}</ul></section>
  </div>
  <h2>Movement Groups</h2>
  <div class="wide">
    <table><thead><tr><th>target</th><th>movement</th><th>count</th><th>position</th><th>anchor</th><th>start policy</th><th>confidence</th><th>examples</th></tr></thead><tbody>{''.join(group_rows)}</tbody></table>
  </div>
  <h2>Skill Rows</h2>
  <div class="wide">
    <table><thead><tr><th>actor</th><th>skill</th><th>target</th><th>movement</th><th>local writes</th><th>helper/effect</th><th>position writes</th><th>position class</th><th>movement context</th><th>notes</th><th>frames</th></tr></thead><tbody>{''.join(skill_rows)}</tbody></table>
  </div>
</body>
</html>
"""


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


if __name__ == "__main__":
    main()
