#!/usr/bin/env python3
"""Classify EXE-derived battle helper visual behavior.

The lower-level battle helper reports already decode separate pieces:

* 0x20 frameScript frame/gate rows,
* child display VM spawn trees,
* direct btl_efc frame spawns,
* position/motion/random fields.

This pass joins those facts into a compact visual-behavior report.  It does not
invent final screen coordinates; it records what the EXE proves about how a
helper is presented: single flash, display-flag blink, repeated particle spawn,
or randomized/moving child spawn.
"""

from __future__ import annotations

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

from build_battle_helper_child_script_review import (
    EXE,
    decode_child_instruction,
    read_bytes,
    read_sections,
)


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
OUT_JSON = OUT / "battle_helper_visual_behavior_review.json"
OUT_MD = OUT / "battle_helper_visual_behavior_review.md"
OUT_HTML = OUT / "battle_helper_visual_behavior_review.html"

_EXE_DATA: bytes | None = None
_SECTIONS: list[dict[str, Any]] | None = None


def read_json(name: str) -> dict[str, Any]:
    return json.loads((OUT / name).read_text(encoding="utf-8"))


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


def exe_data() -> bytes:
    global _EXE_DATA
    if _EXE_DATA is None:
        _EXE_DATA = EXE.read_bytes()
    return _EXE_DATA


def exe_sections() -> list[dict[str, Any]]:
    global _SECTIONS
    if _SECTIONS is None:
        _SECTIONS = read_sections(exe_data())
    return _SECTIONS


def compact(values: list[Any] | tuple[Any, ...] | set[Any], limit: int = 12) -> 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 not in (None, ""))


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


def frame_label(frame: dict[str, Any]) -> str:
    frame_id = frame.get("frame")
    gate = frame.get("gate")
    if frame_id is None:
        return ""
    return f"{frame_id}@{gate}" if gate is not None else str(frame_id)


def blink_segments_from_frame_script(row: dict[str, Any]) -> list[dict[str, Any]]:
    """Extract alternating display flag writes around frameScript frames.

    The exact semantic name of display/actor +0x00 bit 0x10000000 is still kept
    as a candidate.  The sequence itself is EXE-derived: frame rows are followed
    by op6/op7 writes with masks 0xefffffff / 0x10000000.
    """

    decoded = row.get("decoded") or {}
    rows = decoded.get("rows") or []
    tick = 0
    current_frame: dict[str, Any] | None = None
    segments: list[dict[str, Any]] = []
    toggles: list[dict[str, Any]] = []
    for instr in rows:
        category = instr.get("category")
        if category == "frame":
            frame = {
                "tick": tick,
                "frame": instr.get("frame"),
                "spriteHex": instr.get("spriteHex"),
                "gate": instr.get("gate") or 0,
                "vaHex": instr.get("vaHex"),
            }
            segments.append(frame)
            current_frame = frame
            tick += int(instr.get("gate") or 0)
            continue
        if (
            category == "write"
            and instr.get("destHex") == "0x00"
            and instr.get("opName") in {"op6", "op7"}
            and str(instr.get("immHex") or "").lower() in {"0xefffffff", "0x10000000"}
        ):
            toggles.append(
                {
                    "tick": tick,
                    "vaHex": instr.get("vaHex"),
                    "opName": instr.get("opName"),
                    "immHex": instr.get("immHex"),
                    "afterFrame": frame_label(current_frame or {}),
                    "summary": instr.get("summary"),
                }
            )
    if not toggles:
        return []

    for index, seg in enumerate(segments):
        if index == 0:
            seg["visibleCandidate"] = True
            seg["visibilityBasis"] = "initial-display-object-state"
        else:
            previous_op = segments[index - 1].get("postGateFlagOp")
            if previous_op == "op6":
                seg["visibleCandidate"] = False
                seg["visibilityBasis"] = "previous-op6-clears-0x10000000"
            elif previous_op == "op7":
                seg["visibleCandidate"] = True
                seg["visibilityBasis"] = "previous-op7-sets-0x10000000"
            else:
                seg["visibleCandidate"] = True
                seg["visibilityBasis"] = "no-previous-flag-toggle"
        next_toggle = next(
            (
                toggle
                for toggle in toggles
                if toggle["tick"] == seg["tick"] + int(seg.get("gate") or 0)
            ),
            None,
        )
        if next_toggle:
            seg["postGateFlagOp"] = next_toggle["opName"]
            seg["postGateMaskHex"] = next_toggle["immHex"]
        seg["segmentIndex"] = index
    return segments


def summarize_frame_scripts(frame_by_target: dict[str, dict[str, Any]], timelines: list[dict[str, Any]]) -> list[dict[str, Any]]:
    out: list[dict[str, Any]] = []
    for timeline in timelines:
        target = timeline.get("targetVaHex")
        frame_row = frame_by_target.get(str(target))
        frame_events = timeline.get("frameEvents") or []
        frames = [frame_label(item) for item in frame_events]
        blink_segments = blink_segments_from_frame_script(frame_row or {}) if frame_row else []
        out.append(
            {
                "targetVaHex": target,
                "startTick": timeline.get("startTick"),
                "durationGate": timeline.get("durationGate"),
                "stopReason": timeline.get("stopReason"),
                "frames": frames,
                "frameCount": len(frame_events),
                "loopEvents": timeline.get("loopEvents") or [],
                "displayFlagBlink": bool(blink_segments),
                "blinkFlagFieldHex": "0x00" if blink_segments else None,
                "blinkFlagMaskHex": "0x10000000" if blink_segments else None,
                "blinkSegments": blink_segments,
            }
        )
    return out


def _numeric_coord_value(raw: str) -> float | None:
    value = raw.strip().lower()
    if not value:
        return None
    if value.startswith("-0x"):
        try:
            return float(-int(value[3:], 16))
        except ValueError:
            return None
    if value.startswith("0x"):
        # Small field selectors such as 0x1c/0x20 are source references, not
        # immediate pixel coordinates. Large fixed-point immediates are not
        # emitted in coordinateSummary today, but keep a conservative decoder.
        parsed = int(value, 16)
        if parsed < 0x1000:
            return None
        if parsed & 0x80000000:
            parsed -= 0x100000000
        return parsed / 65536.0
    try:
        return float(value)
    except ValueError:
        return None


def _axis_offset_from_summary(summary: str, axis: str) -> float:
    import re

    match = re.search(rf"display\.{axis}\s+\[([^\]]+)\]", summary or "")
    if not match:
        return 0.0
    offset = 0.0
    for item in match.group(1).split(","):
        if ":" not in item:
            continue
        mode_raw, value_raw = item.strip().split(":", 1)
        try:
            mode = int(mode_raw.strip(), 16)
        except ValueError:
            continue
        value = _numeric_coord_value(value_raw)
        op = mode & 0x0F
        if value is None:
            if op == 0:
                # set(source-field) resets the preview basis to the target or
                # parent anchor.
                offset = 0.0
            continue
        if op == 0:
            offset = value
        elif op == 1:
            offset += value
        elif op == 2:
            offset -= value
    return offset


def preview_transform_from_scope(scope: dict[str, Any] | None) -> dict[str, Any]:
    summary = (scope or {}).get("coordinateSummary") or ""
    if not summary:
        return {}
    offset_x = _axis_offset_from_summary(summary, "x")
    offset_y = _axis_offset_from_summary(summary, "y")
    target_x = "motion.targetX" in summary
    target_y = "motion.targetY" in summary
    return {
        "offsetX": round(offset_x, 4),
        "offsetY": round(offset_y, 4),
        "targetAnchorX": target_x,
        "targetAnchorY": target_y,
        "basis": "display.x/y coordinateSummary",
        "coordinateSummary": summary,
    }


def raw_int(value: Any) -> int | None:
    if value in (None, ""):
        return None
    try:
        return int(str(value), 16)
    except (TypeError, ValueError):
        return None


def write_raw_value(write: dict[str, Any]) -> int | None:
    if not write.get("immHex"):
        return None
    return raw_int(write.get("immHex"))


def compact_branch_snippet(target_va_hex: str | None, limit: int = 10) -> list[dict[str, Any]]:
    if not target_va_hex:
        return []
    target = raw_int(target_va_hex)
    if target is None:
        return []

    out: list[dict[str, Any]] = []
    pos = target
    seen: set[int] = set()
    for _ in range(limit):
        if pos in seen:
            out.append({"vaHex": f"0x{pos:08x}", "category": "loop", "summary": "loop to previously decoded address"})
            break
        seen.add(pos)
        raw = read_bytes(exe_data(), exe_sections(), pos, 96)
        if not raw:
            break
        row = decode_child_instruction(raw, pos)
        out.append(
            {
                "vaHex": row.get("vaHex") or f"0x{pos:08x}",
                "opcode": row.get("opcode"),
                "category": row.get("category"),
                "summary": row.get("summary"),
            }
        )
        length = int(row.get("length") or 0)
        if length <= 0 or row.get("opcode") in {"0x00", "0xff"}:
            break
        pos += length
    return out


def branch_left_field(branch: dict[str, Any]) -> str | None:
    value = branch.get("leftSelectorHex")
    return str(value).lower() if value else None


def lifecycle_interpretation(
    emit_count: int,
    root_counter_sets: dict[str, int],
    root_branches: list[dict[str, Any]],
    child_motion_loops: list[dict[str, Any]],
) -> list[str]:
    notes: list[str] = []
    if root_counter_sets.get("0x94") is not None:
        notes.append(f"+0x94 raw {root_counter_sets['0x94']} acts as burst/current-counter seed for +0x8e")
    if root_counter_sets.get("0x96") is not None:
        notes.append(f"+0x96 raw {root_counter_sets['0x96']} acts as outer repeat counter")
    branch_roles = {branch.get("role") for branch in root_branches}
    if "burst-spawn-while-current-counter-nonzero" in branch_roles:
        notes.append("root op14 on +0x8e keeps spawning child pieces inside the current burst")
    if "outer-repeat-step-while-repeat-counter-nonzero" in branch_roles:
        notes.append("root op14 on +0x96 yields one VM tick, reseeds +0x8e from +0x94, then emits another burst")
    if child_motion_loops:
        notes.append("spawned child scripts have their own +0x90 phase loop around opcode 0x2d motion-step")
    if emit_count:
        notes.append(f"static direct-spawn expansion currently exposes {emit_count} child frame events")
    return notes


def summarize_lifecycle(direct: dict[str, Any], pos: dict[str, Any]) -> dict[str, Any]:
    events = direct.get("events") or []
    ticks = [event.get("absoluteTick", event.get("tick")) for event in events if event.get("absoluteTick", event.get("tick")) is not None]
    frames = [event.get("frame") for event in events if event.get("frame") is not None]
    tick_deltas = [b - a for a, b in zip(ticks, ticks[1:]) if isinstance(a, int) and isinstance(b, int)]
    root = next((scope for scope in pos.get("scopes") or [] if scope.get("scope") == "root"), {})
    spawn_scopes = [scope for scope in pos.get("scopes") or [] if str(scope.get("scope") or "").startswith("spawn.")]

    root_counter_sets: dict[str, int] = {}
    root_counter_steps: list[dict[str, Any]] = []
    for write in root.get("motionWrites") or []:
        field = str(write.get("destHex") or "").lower()
        if field not in {"0x8e", "0x90", "0x92", "0x94", "0x96"}:
            continue
        raw = write_raw_value(write)
        if raw is not None and write.get("operation") == "set":
            root_counter_sets[field] = raw
        root_counter_steps.append(
            {
                "vaHex": write.get("vaHex"),
                "fieldHex": field,
                "operation": write.get("operation"),
                "sourceHex": write.get("sourceHex"),
                "rawValue": raw,
                "summary": write.get("summary"),
            }
        )

    root_branches = []
    for branch in root.get("branchRows") or []:
        target = branch.get("targetVaHex") or branch.get("op14TargetVaHex")
        field = branch_left_field(branch)
        role = "unknown-root-branch"
        if field == "0x8e":
            role = "burst-spawn-while-current-counter-nonzero"
        elif field == "0x96":
            role = "outer-repeat-step-while-repeat-counter-nonzero"
        elif field == "0x90":
            role = "yield-loop-while-frame-or-phase-counter-nonzero"
        root_branches.append(
            {
                "vaHex": branch.get("vaHex"),
                "fieldHex": field,
                "role": role,
                "targetVaHex": target,
                "condition": branch.get("summary"),
                "targetSnippet": compact_branch_snippet(target, 8),
            }
        )

    child_motion_loops = []
    for scope in spawn_scopes:
        for branch in scope.get("branchRows") or []:
            target = branch.get("targetVaHex") or branch.get("op14TargetVaHex")
            field = branch_left_field(branch)
            if field != "0x90":
                continue
            child_motion_loops.append(
                {
                    "scope": scope.get("scope"),
                    "role": "child-motion-loop-until-phase-counter-zero",
                    "targetVaHex": target,
                    "condition": branch.get("summary"),
                    "motionSteps": scope.get("motionSteps") or [],
                    "targetSnippet": compact_branch_snippet(target, 10),
                }
            )

    return {
        "emitCount": len(events),
        "emitFrames": sorted(set(frames)),
        "firstFrame": frames[0] if frames else None,
        "repeatedFrames": sorted(set(frames[1:])) if len(frames) > 1 else [],
        "emitTicks": ticks,
        "tickDeltas": tick_deltas,
        "uniformTickDelta": tick_deltas[0] if tick_deltas and all(delta == tick_deltas[0] for delta in tick_deltas) else None,
        "rootCounterSets": root_counter_sets,
        "rootCounterSteps": root_counter_steps,
        "rootBranches": root_branches,
        "childMotionLoops": child_motion_loops,
        "interpretation": lifecycle_interpretation(len(events), root_counter_sets, root_branches, child_motion_loops),
    }


def summarize_direct_spawns(events: list[dict[str, Any]]) -> dict[str, Any]:
    decorated_events: list[dict[str, Any]] = []
    for event in events:
        copied = dict(event)
        copied["previewTransform"] = preview_transform_from_scope(copied.get("transformScope"))
        decorated_events.append(copied)
    frames = [event.get("frame") for event in events if event.get("frame") is not None]
    ticks = [event.get("absoluteTick", event.get("tick")) for event in events]
    target_counts = Counter(str(event.get("targetVaHex")) for event in events)
    transform_patterns = Counter(
        str(((event.get("transformScope") or {}).get("patternSummary") or ""))
        for event in events
        if event.get("transformScope")
    )
    coords = [
        (event.get("transformScope") or {}).get("coordinateSummary")
        for event in events
        if (event.get("transformScope") or {}).get("coordinateSummary")
    ]
    return {
        "count": len(events),
        "frames": sorted(set(frames)),
        "frameTimeline": [f"{frame}@{tick}" for frame, tick in zip(frames, ticks)],
        "ticks": ticks,
        "targetVaCounts": dict(target_counts),
        "targetStopReasons": sorted(set(str(event.get("targetStopReason")) for event in events if event.get("targetStopReason"))),
        "transformPatternCounts": dict(transform_patterns),
        "coordinateSummaries": sorted(set(coords)),
        "events": decorated_events,
    }


def summarize_spawn_tree(row: dict[str, Any] | None) -> dict[str, Any]:
    if not row:
        return {}
    nodes = []
    for node in row.get("flatNodes") or []:
        direct_frames = node.get("directFrames") or []
        resolved_frame_scripts = []
        for frame_script in node.get("resolvedFrameScripts") or []:
            resolved_frame_scripts.append(
                {
                    "targetVaHex": frame_script.get("targetVaHex"),
                    "stopReason": frame_script.get("stopReason"),
                    "durationGate": frame_script.get("durationGate"),
                    "frameLabels": frame_script.get("frameLabels") or [],
                    "frameSequence": frame_script.get("frameSequence") or [],
                    "gateSequence": frame_script.get("gateSequence") or [],
                    "spriteHexSequence": frame_script.get("spriteHexSequence") or [],
                }
            )
        nodes.append(
            {
                "path": node.get("path"),
                "targetVaHex": node.get("targetVaHex"),
                "primaryClass": node.get("primaryClass"),
                "tags": node.get("tags") or [],
                "directFrames": direct_frames,
                "initFrames": node.get("initFrames") or [],
                "resolvedFrameScripts": resolved_frame_scripts,
                "randomRanges": node.get("randomRanges") or [],
                "positionWrites": node.get("positionWrites") or [],
                "motionWrites": node.get("motionWrites") or [],
                "motionSteps": node.get("motionSteps") or [],
                "branchRows": node.get("branchRows") or [],
            }
        )
    return {
        "spawnNodeCount": row.get("spawnNodeCount") or 0,
        "maxDepth": row.get("maxDepth") or 0,
        "tags": row.get("tags") or [],
        "spawnTargets": row.get("spawnTargets") or [],
        "frameScriptTargets": row.get("frameScriptTargets") or [],
        "nodes": nodes,
    }


def summarize_nested_visual_frames(spawn: dict[str, Any]) -> list[dict[str, Any]]:
    visuals: list[dict[str, Any]] = []
    for node in spawn.get("nodes") or []:
        node_base = {
            "path": node.get("path"),
            "targetVaHex": node.get("targetVaHex"),
            "primaryClass": node.get("primaryClass"),
        }
        for frame in node.get("initFrames") or []:
            visuals.append({**node_base, "source": "spawn-node-init-frame", "frame": frame})
        for frame in node.get("directFrames") or []:
            visuals.append({**node_base, "source": "spawn-node-direct-frame", "frame": frame})
        for frame_script in node.get("resolvedFrameScripts") or []:
            frame_labels = frame_script.get("frameLabels") or []
            if not frame_labels:
                continue
            visuals.append(
                {
                    **node_base,
                    "source": "spawn-node-frameScript",
                    "targetVaHex": frame_script.get("targetVaHex") or node.get("targetVaHex"),
                    "frameLabels": frame_labels,
                    "frameSequence": frame_script.get("frameSequence") or [],
                    "gateSequence": frame_script.get("gateSequence") or [],
                    "durationGate": frame_script.get("durationGate"),
                }
            )
    return visuals


def summarize_position(row: dict[str, Any] | None) -> dict[str, Any]:
    if not row:
        return {}
    scopes = []
    for scope in row.get("scopes") or []:
        scopes.append(
            {
                "scope": scope.get("scope"),
                "anchor": scope.get("anchor"),
                "patterns": scope.get("patterns") or [],
                "coordinateSummary": scope.get("coordinateSummary"),
                "randomRanges": scope.get("randomRanges") or [],
                "branchRows": scope.get("branchRows") or [],
                "positionWrites": scope.get("positionWrites") or [],
                "motionWrites": scope.get("motionWrites") or [],
                "motionSteps": scope.get("motionSteps") or [],
                "frameScripts": scope.get("frameScripts") or [],
            }
        )
    return {
        "patterns": row.get("patterns") or [],
        "fieldCounts": row.get("fieldCounts") or {},
        "motionModeCounts": row.get("motionModeCounts") or {},
        "scopes": scopes,
    }


def summarize_init_frames(row: dict[str, Any] | None) -> list[dict[str, Any]]:
    if not row:
        return []
    decoded = row.get("decoded") or {}
    frames: list[dict[str, Any]] = []
    for write in decoded.get("initWrites") or []:
        frame = write.get("frame")
        if not frame:
            continue
        frames.append(
            {
                "vaHex": write.get("vaHex"),
                "sprite": frame.get("sprite"),
                "spriteHex": frame.get("spriteHex"),
                "frame": frame.get("frame"),
                "selectorHex": frame.get("selectorHex"),
                "destHex": write.get("destHex"),
                "summary": write.get("summary"),
            }
        )
    return frames


def classify(
    row: dict[str, Any],
    frame_scripts: list[dict[str, Any]],
    direct: dict[str, Any],
    spawn: dict[str, Any],
    pos: dict[str, Any],
    init_frames: list[dict[str, Any]],
    nested_visual_frames: list[dict[str, Any]],
) -> tuple[str, list[str]]:
    notes: list[str] = []
    if any(item.get("displayFlagBlink") for item in frame_scripts):
        notes.append("frameScript toggles display/actor +0x00 bit 0x10000000 through op6/op7 writes")
        return "display-flag-blink-frameScript", notes
    patterns = set(pos.get("patterns") or [])
    spawn_tags = set(spawn.get("tags") or [])
    if "rng-driven-placement" in patterns and direct.get("count", 0) >= 2:
        notes.append("root loop uses RNG ranges and repeated child display spawns")
        return "randomized-child-spawn-stream", notes
    if direct.get("count", 0) >= 4 and ("direct-frame-emitter" in spawn_tags or "loop-controller" in spawn_tags):
        notes.append("helper expands one call into multiple direct btl_efc child frame spawns")
        return "repeated-direct-frame-spawn", notes
    if frame_scripts and sum(item.get("frameCount") or 0 for item in frame_scripts) == 1:
        notes.append("single 0x20 frameScript frame with EXE gate")
        return "single-frameScript-flash", notes
    if frame_scripts:
        notes.append("multi-frame 0x20 frameScript without display flag blink")
        return "multi-frameScript-effect", notes
    if direct.get("count", 0):
        notes.append("direct child frame spawn without decoded 0x20 frameScript")
        return "direct-frame-spawn", notes
    if init_frames:
        notes.append("child init-block writes display.spriteFrame +0x28 directly")
        if row.get("bodyClass") == "target-range child visual spawner":
            return "target-range-init-frame-effect", notes
        return "init-frame-effect", notes
    if nested_visual_frames:
        notes.append("spawn tree child node contains init-frame/frameScript visual evidence")
        if any(item.get("source") == "spawn-node-frameScript" for item in nested_visual_frames):
            return "nested-frameScript-spawn-effect", notes
        return "nested-spawn-tree-effect", notes
    if row.get("combinedActorFlagEvents"):
        notes.append("helper mainly synchronizes actor flags/waits")
        return "actor-flag-sync", notes
    return "no-visual-frame-detected", notes


def build() -> dict[str, Any]:
    frame = read_json("battle_helper_frame_script_review.json")
    sync = read_json("battle_helper_sync_timing_review.json")
    spawn = read_json("battle_helper_spawn_tree_review.json")
    pos = read_json("battle_helper_position_motion_review.json")
    child = read_json("battle_helper_child_script_review.json")

    frame_by_target = {
        str(row.get("targetVaHex")): row
        for row in frame.get("frameScriptRows") or []
        if row.get("targetVaHex")
    }
    spawn_by_helper = {
        int(row["helperId"]): row
        for row in spawn.get("helperRows") or []
        if isinstance(row.get("helperId"), int)
    }
    pos_by_helper = {
        int(row["helperId"]): row
        for row in pos.get("helperRows") or []
        if isinstance(row.get("helperId"), int)
    }
    child_by_helper = {
        int(row["helperId"]): row
        for row in child.get("helperRows") or []
        if isinstance(row.get("helperId"), int)
    }

    rows: list[dict[str, Any]] = []
    class_counts: Counter[str] = Counter()
    for row in sync.get("rows") or []:
        helper_id = row.get("helperId")
        if not isinstance(helper_id, int):
            continue
        frame_scripts = summarize_frame_scripts(frame_by_target, row.get("frameScriptTimelines") or [])
        direct = summarize_direct_spawns(row.get("directSpawnFrameEvents") or [])
        spawn_row = spawn_by_helper.get(helper_id)
        spawn_summary = summarize_spawn_tree(spawn_row)
        pos_summary = summarize_position(pos_by_helper.get(helper_id))
        init_frames = summarize_init_frames(child_by_helper.get(helper_id))
        nested_visual_frames = summarize_nested_visual_frames(spawn_summary)
        lifecycle = summarize_lifecycle(direct, pos_summary)
        behavior_class, notes = classify(row, frame_scripts, direct, spawn_summary, pos_summary, init_frames, nested_visual_frames)
        labels = sorted(set(row_skill_labels(row) + row_skill_labels(spawn_row or {}) + row_skill_labels(pos_by_helper.get(helper_id) or {})))
        class_counts[behavior_class] += 1
        rows.append(
            {
                "helperId": helper_id,
                "childScriptVaHex": row.get("childScriptVaHex"),
                "skillLabels": labels,
                "behaviorClass": behavior_class,
                "notes": notes,
                "runtimeSignals": row.get("runtimeSignals") or [],
                "syncSignals": row.get("syncSignals") or [],
                "initFrames": init_frames,
                "nestedVisualFrames": nested_visual_frames,
                "frameScripts": frame_scripts,
                "directSpawn": direct,
                "lifecycle": lifecycle,
                "spawnTree": spawn_summary,
                "positionMotion": pos_summary,
                "actorFlagEvents": row.get("combinedActorFlagEvents") or [],
            }
        )

    interesting = [
        row
        for row in rows
        if row["behaviorClass"] != "no-visual-frame-detected"
    ]
    return {
        "version": 1,
        "kind": "hwanse-battle-helper-visual-behavior-review",
        "source": [
            "out/battle_helper_frame_script_review.json",
            "out/battle_helper_sync_timing_review.json",
            "out/battle_helper_spawn_tree_review.json",
            "out/battle_helper_position_motion_review.json",
            "out/battle_helper_child_script_review.json",
        ],
        "status": "exe-derived-helper-visual-behavior",
        "summary": {
            "helpers": len(rows),
            "helpersWithVisualEvidence": len(interesting),
            "behaviorClassCounts": dict(class_counts),
            "helpersWithBlinkFlag": sum(1 for row in rows if any(fs.get("displayFlagBlink") for fs in row["frameScripts"])),
            "helpersWithDirectSpawnStream": sum(1 for row in rows if row["directSpawn"]["count"] >= 2),
            "helpersWithInitFrame": sum(1 for row in rows if row.get("initFrames")),
            "helpersWithNestedVisualFrame": sum(1 for row in rows if row.get("nestedVisualFrames")),
            "helpersWithRngPlacement": sum(1 for row in rows if "rng-driven-placement" in (row["positionMotion"].get("patterns") or [])),
            "helpersWithChildMotionLoops": sum(1 for row in rows if row["lifecycle"].get("childMotionLoops")),
            "helpersWithRootRepeatCounters": sum(
                1
                for row in rows
                if {"0x8e", "0x96"} & set((row["lifecycle"].get("rootCounterSets") or {}).keys())
            ),
        },
        "interpretationNotes": [
            "display-flag blink is grounded by EXE frameScript opcode 0x12 writes to display/actor +0x00 using op6 0xefffffff and op7 0x10000000. The exact field name is kept conservative, but the alternating visibility-style presentation is confirmed.",
            "randomized child spawn streams are grounded by helper child scripts that repeatedly spawn display objects, use random ranges, and write display.x/y or motion target fields.",
            "Final screen coordinates are still target-relative and runtime dependent. This report is about visual behavior, not final battle layout.",
            "For helper roots, +0x94/+0x96 can be raw counters rather than fixed-point pixels. The report keeps both decoded summaries and raw integer counter interpretation.",
            "Some helpers do not use 0x20 frameScript or direct spawned frame rows; they write display.spriteFrame +0x28 inside an 0x08 init-block. Those rows are counted as init-frame visual evidence.",
            "Some root helpers only seed a controller child; the visual frames live in nested spawn-tree children. Nested initFrames/resolved frameScripts are counted as nested visual evidence, not as unresolved no-frame helpers.",
        ],
        "rows": rows,
    }


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Helper Visual Behavior Review",
        "",
        "EXE helper/frameScript/spawn-tree evidence를 합쳐 helper 이펙트가 어떻게 표시되는지 분류한다.",
        "",
        "## Summary",
        "",
        f"- helpers: `{report['summary']['helpers']}`",
        f"- helpers with visual evidence: `{report['summary']['helpersWithVisualEvidence']}`",
        f"- class counts: `{report['summary']['behaviorClassCounts']}`",
        f"- blink flag helpers: `{report['summary']['helpersWithBlinkFlag']}`",
        f"- direct spawn stream helpers: `{report['summary']['helpersWithDirectSpawnStream']}`",
        f"- init-frame helpers: `{report['summary']['helpersWithInitFrame']}`",
        f"- nested visual helpers: `{report['summary']['helpersWithNestedVisualFrame']}`",
        f"- RNG placement helpers: `{report['summary']['helpersWithRngPlacement']}`",
        f"- child motion loop helpers: `{report['summary']['helpersWithChildMotionLoops']}`",
        f"- root repeat counter helpers: `{report['summary']['helpersWithRootRepeatCounters']}`",
        "",
        "## Key Examples",
        "",
    ]
    for helper_id in (112, 113, 114, 95, 96, 97, 98):
        row = next((item for item in report["rows"] if item["helperId"] == helper_id), None)
        if not row:
            continue
        frames = []
        for fs in row["frameScripts"]:
            frames.extend(fs.get("frames") or [])
        lines.extend(
            [
                f"### helper {helper_id}",
                "",
                f"- class: `{row['behaviorClass']}`",
                f"- skills: {compact(row['skillLabels'])}",
                f"- frameScripts: {compact(frames)}",
                f"- direct spawns: `{row['directSpawn']['count']}` frames {compact(row['directSpawn']['frames'])}",
                f"- lifecycle: {compact(row.get('lifecycle', {}).get('interpretation') or [], 4)}",
                f"- patterns: {compact(row['positionMotion'].get('patterns') or [])}",
                f"- notes: {compact(row['notes'])}",
                "",
            ]
        )
    lines.extend(["## Rows", "", "| helper | class | skills | frameScripts | direct spawns | patterns | notes |", "|---:|---|---|---|---|---|---|"])
    for row in report["rows"]:
        if row["behaviorClass"] == "no-visual-frame-detected":
            continue
        frames = []
        for fs in row["frameScripts"]:
            frames.extend(fs.get("frames") or [])
        init_frames = [f"{item.get('spriteHex')}#{item.get('frame')}" for item in row.get("initFrames") or []]
        nested_frames = []
        for item in row.get("nestedVisualFrames") or []:
            if item.get("frameLabels"):
                nested_frames.extend([f"{item.get('path')}:{label}" for label in item.get("frameLabels") or []])
            elif item.get("frame") is not None:
                nested_frames.append(f"{item.get('path')}:{item.get('frame')}")
        lifecycle = row.get("lifecycle") or {}
        lines.append(
            "| "
            + " | ".join(
                [
                    str(row["helperId"]),
                    row["behaviorClass"],
                    compact(row["skillLabels"], 4),
                    compact(frames or init_frames or nested_frames, 8),
                    f"{row['directSpawn']['count']} / {compact(row['directSpawn']['frames'], 8)}",
                    compact(lifecycle.get("interpretation") or row["positionMotion"].get("patterns") or [], 5),
                    compact(row["notes"], 3),
                ]
            )
            + " |"
        )
    return "\n".join(lines) + "\n"


def html_page(report: dict[str, Any]) -> str:
    rows_html = []
    for row in report["rows"]:
        if row["behaviorClass"] == "no-visual-frame-detected":
            continue
        frames: list[str] = []
        blink = []
        for fs in row["frameScripts"]:
            frames.extend(fs.get("frames") or [])
            if fs.get("displayFlagBlink"):
                blink.append(f"{fs.get('targetVaHex')} bit {fs.get('blinkFlagMaskHex')}")
        init_frames = [f"{item.get('spriteHex')}#{item.get('frame')}" for item in row.get("initFrames") or []]
        nested_frames = []
        for item in row.get("nestedVisualFrames") or []:
            if item.get("frameLabels"):
                nested_frames.extend([f"{item.get('path')}:{label}" for label in item.get("frameLabels") or []])
            elif item.get("frame") is not None:
                nested_frames.append(f"{item.get('path')}:{item.get('frame')}")
        lifecycle = row.get("lifecycle") or {}
        rows_html.append(
            "<tr>"
            f"<td>{row['helperId']}</td>"
            f"<td><code>{esc(row['behaviorClass'])}</code></td>"
            f"<td>{esc(compact(row['skillLabels'], 6))}</td>"
            f"<td>{esc(compact(frames or init_frames or nested_frames, 12))}</td>"
            f"<td>{row['directSpawn']['count']}<br><small>{esc(compact(row['directSpawn']['frameTimeline'], 10))}</small></td>"
            f"<td>{esc(compact(lifecycle.get('interpretation') or row['positionMotion'].get('patterns') or [], 8))}</td>"
            f"<td>{esc(compact(blink, 4))}</td>"
            f"<td>{esc(compact(row['notes'], 4))}</td>"
            "</tr>"
        )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <title>Battle Helper Visual Behavior Review</title>
  <style>
    body {{ font-family: system-ui, sans-serif; margin: 24px; background: #f7f5ef; color: #211f1a; }}
    a {{ color: #744100; }}
    table {{ border-collapse: collapse; width: 100%; background: #fffdf8; }}
    th, td {{ border: 1px solid #d8cfbd; padding: 7px 8px; vertical-align: top; font-size: 13px; }}
    th {{ background: #ece2cf; position: sticky; top: 0; }}
    code {{ background: #eee6d7; padding: 1px 4px; border-radius: 3px; }}
    .summary {{ display: flex; gap: 12px; flex-wrap: wrap; margin: 12px 0 18px; }}
    .card {{ background: #fffdf8; border: 1px solid #d8cfbd; padding: 10px 12px; border-radius: 6px; }}
    .wide {{ overflow-x: auto; }}
  </style>
</head>
<body>
  <h1>Battle Helper Visual Behavior Review</h1>
  <p><a href="../web/index.html">홈</a> · <a href="../web/battle_skill_timeline_review.html">skill timeline</a> · <a href="battle_helper_sync_timing_review.html">helper sync</a> · <a href="battle_helper_visual_behavior_review.json">JSON</a> · <a href="battle_helper_visual_behavior_review.md">MD</a></p>
  <div class="summary">
    <div class="card">helpers<br><b>{report['summary']['helpers']}</b></div>
    <div class="card">visual evidence<br><b>{report['summary']['helpersWithVisualEvidence']}</b></div>
    <div class="card">blink flag<br><b>{report['summary']['helpersWithBlinkFlag']}</b></div>
    <div class="card">spawn stream<br><b>{report['summary']['helpersWithDirectSpawnStream']}</b></div>
    <div class="card">init-frame<br><b>{report['summary']['helpersWithInitFrame']}</b></div>
    <div class="card">nested visual<br><b>{report['summary']['helpersWithNestedVisualFrame']}</b></div>
    <div class="card">RNG placement<br><b>{report['summary']['helpersWithRngPlacement']}</b></div>
    <div class="card">motion loops<br><b>{report['summary']['helpersWithChildMotionLoops']}</b></div>
    <div class="card">repeat counters<br><b>{report['summary']['helpersWithRootRepeatCounters']}</b></div>
  </div>
  <p>깜빡임은 frameScript 안의 <code>display/actor +0x00</code> op6/op7 토글로, 비늘/파티클류는 반복 child spawn과 random/motion 필드로 분리했다.</p>
  <div class="wide"><table>
    <thead><tr><th>helper</th><th>class</th><th>skills</th><th>frameScript frames</th><th>direct spawn stream</th><th>lifecycle / position-motion</th><th>blink flag</th><th>notes</th></tr></thead>
    <tbody>{''.join(rows_html)}</tbody>
  </table></div>
</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("wrote out/battle_helper_visual_behavior_review.{json,md,html}")


if __name__ == "__main__":
    main()
