#!/usr/bin/env python3
"""Build a skill-facing review of battle effect animation patterns.

The existing helper reports decode separate layers: helper child scripts,
frameScript streams, spawn trees, transform hints, and per-skill actor timelines.
This report joins those layers specifically around visual effect animation:

* which helper objects a skill calls,
* whether the helper is a one-shot frame, frameScript animation, blink, or
  repeated child-spawn stream,
* which btl_efc frames/gates are statically visible,
* which counter/loop fields drive repeated effect bursts.

It does not invent missing runtime timing.  Confirmed runner obligations are
separated from true review warnings so EXE-derived spawn/random/motion behavior
does not keep confirmed skills in a false "needs review" state.
"""

from __future__ import annotations

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


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


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


def key_for(row: dict[str, Any]) -> str:
    return f"{row.get('ownerKey')}:{str(row.get('skillIdHex')).lower()}"


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


def sprite_asset(sprite_hex: str | None) -> str:
    # All currently decoded helper effect frameScript rows use sprite id 0x1a,
    # which is the btl_efc effect sheet in existing direct-spawn evidence.
    if str(sprite_hex).lower() == "0x1a":
        return "btl_efc"
    return f"sprite:{sprite_hex or '-'}"


def frame_label(frame: Any, gate: Any = None, sprite_hex: str | None = None) -> str:
    base = f"{sprite_asset(sprite_hex)}#{frame}"
    return f"{base}@{gate}" if gate not in (None, "") else base


def index_by_helper(rows: list[dict[str, Any]]) -> dict[int, dict[str, Any]]:
    out: dict[int, dict[str, Any]] = {}
    for row in rows:
        helper_id = row.get("helperId")
        if isinstance(helper_id, int):
            out[helper_id] = row
    return out


def event_preview(event: dict[str, Any]) -> dict[str, Any]:
    transform = event.get("previewTransform") or {}
    return {
        "tick": event.get("tick"),
        "absoluteTick": event.get("absoluteTick"),
        "asset": event.get("asset") or sprite_asset(event.get("spriteHex")),
        "frame": event.get("frame"),
        "label": frame_label(event.get("frame"), event.get("gate"), event.get("spriteHex")),
        "targetVaHex": event.get("targetVaHex"),
        "reason": event.get("reason"),
        "offsetX": transform.get("offsetX"),
        "offsetY": transform.get("offsetY"),
        "targetAnchorX": transform.get("targetAnchorX"),
        "targetAnchorY": transform.get("targetAnchorY"),
        "transformStatus": event.get("transformStatus"),
    }


def framescript_preview(row: dict[str, Any], source: str) -> dict[str, Any]:
    frames = row.get("frameGateSequence") or []
    if not frames and row.get("frames"):
        frames = row.get("frames") or []
    sequence = []
    for item in frames:
        if isinstance(item, str):
            frame_raw, _, gate_raw = item.partition("@")
            try:
                frame = int(frame_raw)
            except ValueError:
                frame = frame_raw
            try:
                gate = int(gate_raw) if gate_raw else None
            except ValueError:
                gate = gate_raw
            sequence.append(
                {
                    "asset": "btl_efc",
                    "frame": frame,
                    "gate": gate,
                    "label": frame_label(frame, gate, "0x1a"),
                    "vaHex": None,
                }
            )
            continue
        sequence.append(
            {
                "asset": sprite_asset(item.get("spriteHex")),
                "frame": item.get("frame"),
                "gate": item.get("gate"),
                "label": frame_label(item.get("frame"), item.get("gate"), item.get("spriteHex")),
                "vaHex": item.get("vaHex"),
            }
        )
    return {
        "source": source,
        "targetVaHex": row.get("targetVaHex"),
        "startTick": row.get("startTick"),
        "durationGate": row.get("durationGate"),
        "stopReason": row.get("stopReason"),
        "displayFlagBlink": bool(row.get("displayFlagBlink")),
        "blinkSegments": row.get("blinkSegments") or [],
        "frameSequence": [item["frame"] for item in sequence],
        "gateSequence": [item["gate"] for item in sequence],
        "labels": [item["label"] for item in sequence],
        "events": sequence,
    }


def spawn_node_preview(node: dict[str, Any]) -> dict[str, Any]:
    frame_scripts = []
    for script in node.get("resolvedFrameScripts") or []:
        if script.get("missing"):
            frame_scripts.append(
                {
                    "source": "spawn-tree",
                    "targetVaHex": script.get("targetVaHex"),
                    "missing": True,
                }
            )
        else:
            frame_scripts.append(framescript_preview(script, "spawn-tree"))
    return {
        "path": node.get("path"),
        "targetVaHex": node.get("targetVaHex"),
        "primaryClass": node.get("primaryClass"),
        "tags": node.get("tags") or [],
        "directFrames": node.get("directFrames") or [],
        "initFrames": node.get("initFrames") or [],
        "frameScripts": frame_scripts,
        "randomRanges": node.get("randomRanges") or [],
        "motionSteps": node.get("motionSteps") or [],
        "positionWrites": node.get("positionWrites") or [],
        "motionWrites": node.get("motionWrites") or [],
        "paletteEvents": node.get("paletteEvents") or [],
        "childrenCount": node.get("childrenCount") or 0,
    }


def init_frame_preview(frame: Any, source: str) -> dict[str, Any]:
    if not isinstance(frame, dict):
        frame = {"spriteHex": "0x1a", "frame": frame}
    return {
        "source": source,
        "asset": sprite_asset(frame.get("spriteHex")),
        "frame": frame.get("frame"),
        "gate": frame.get("gate"),
        "label": frame_label(frame.get("frame"), frame.get("gate"), frame.get("spriteHex")),
        "vaHex": frame.get("vaHex"),
        "selectorHex": frame.get("selectorHex"),
    }


def classify_helper(
    visual: dict[str, Any],
    spawn: dict[str, Any] | None,
    init_frames: list[dict[str, Any]] | None = None,
    body: dict[str, Any] | None = None,
    palette_events: list[dict[str, Any]] | None = None,
) -> str:
    behavior = visual.get("behaviorClass")
    direct = visual.get("directSpawn") or {}
    frame_scripts = visual.get("frameScripts") or []
    lifecycle = visual.get("lifecycle") or {}
    body_class = (body or {}).get("bodyClass")
    if palette_events:
        return "palette-flash-effect"
    if behavior == "display-flag-blink-frameScript":
        return "blink-frameScript"
    if lifecycle.get("rootCounterSets") and direct.get("count", 0) > 1:
        return "counter-driven-burst-spawn"
    if direct.get("count", 0) > 1:
        return "direct-spawn-stream"
    if frame_scripts:
        return "frameScript-sequence"
    if init_frames:
        if body_class == "target-range child visual spawner":
            return "target-range-init-frame-effect"
        return "init-frame-effect"
    if direct.get("count", 0) == 1 and spawn and spawn.get("frameScriptTargets"):
        return "one-shot-spawn-with-frameScript"
    if direct.get("count", 0) == 1:
        return "one-shot-direct-frame"
    if behavior == "actor-flag-sync":
        return "actor-synchronized-no-independent-frame"
    if behavior == "no-visual-frame-detected":
        return "no-visual-frame-detected"
    if body:
        return "body-only-helper-unresolved-visual"
    return behavior or "unknown"


def execution_requirements(helper: dict[str, Any]) -> list[str]:
    requirements: list[str] = []
    if helper["animationClass"] == "palette-flash-effect":
        requirements.append("palette transform effect; no CNS frame stream")
    if helper["animationClass"] in {"counter-driven-burst-spawn", "direct-spawn-stream"}:
        requirements.append("runner must instantiate child objects over time")
    if helper.get("randomRanges"):
        requirements.append("random placement/range present")
    if helper.get("childMotionLoops"):
        requirements.append("child motion loop present")
    if helper.get("blinkFrameScripts"):
        requirements.append("display flag blink controls visibility")
    return unique(requirements)


def review_flags(helper: dict[str, Any]) -> list[str]:
    flags: list[str] = []
    if helper["animationClass"] in {"no-visual-frame-detected", "unknown"}:
        flags.append("visual frames not decoded")
    if helper["animationClass"] == "body-only-helper-unresolved-visual":
        flags.append("helper body present but visual row missing")
    if helper.get("missingFrameScripts"):
        flags.append("frameScript target missing in decoded table")
    return unique(flags)


def build_helper_rows() -> dict[int, dict[str, Any]]:
    visual_report = load_json("battle_helper_visual_behavior_review.json")
    spawn_report = load_json("battle_helper_spawn_tree_review.json")
    frame_report = load_json("battle_helper_frame_script_review.json")
    child_report = load_json("battle_helper_child_script_review.json")
    body_report = load_json("battle_helper_body_review.json")

    visual_by_id = index_by_helper(visual_report.get("rows") or [])
    spawn_by_id = index_by_helper(spawn_report.get("helperRows") or [])
    child_by_id = index_by_helper(child_report.get("helperRows") or [])
    body_by_id = index_by_helper(body_report.get("helperRows") or [])

    global_frame_by_target = {
        row.get("targetVaHex"): row
        for row in frame_report.get("frameScriptRows") or []
        if row.get("targetVaHex")
    }

    helper_rows: dict[int, dict[str, Any]] = {}
    helper_ids = sorted(set(visual_by_id) | set(spawn_by_id) | set(child_by_id) | set(body_by_id))
    for helper_id in helper_ids:
        visual = visual_by_id.get(helper_id, {})
        spawn = spawn_by_id.get(helper_id)
        body = body_by_id.get(helper_id, {})
        child = child_by_id.get(helper_id, {})
        direct = visual.get("directSpawn") or {}
        lifecycle = visual.get("lifecycle") or {}

        direct_events = [event_preview(event) for event in direct.get("events") or []]
        init_frames: list[dict[str, Any]] = []
        for frame in (child.get("decoded") or {}).get("initFrames") or []:
            init_frames.append(init_frame_preview(frame, "child-init"))

        frame_scripts = [framescript_preview(script, "helper-visual") for script in visual.get("frameScripts") or []]
        # Some frameScript targets are present only through spawn-tree rows.
        spawn_nodes = [spawn_node_preview(node) for node in (spawn or {}).get("flatNodes") or []]
        for node in spawn_nodes:
            for frame in node.get("initFrames") or []:
                init_frames.append(init_frame_preview(frame, "spawn-node-init"))
            for frame in node.get("directFrames") or []:
                init_frames.append(init_frame_preview(frame, "spawn-node-direct"))
            for script in node.get("frameScripts") or []:
                if script not in frame_scripts:
                    frame_scripts.append(script)

        # If a child script mentions frameScript targets but visual/spawn summary
        # did not attach the decoded rows, add global decoded rows when possible.
        for target in (child.get("decoded") or {}).get("frameScriptTargets") or []:
            target_hex = target.get("targetVaHex")
            if not target_hex:
                continue
            if any(script.get("targetVaHex") == target_hex for script in frame_scripts):
                continue
            decoded = global_frame_by_target.get(target_hex)
            if decoded:
                frame_scripts.append(framescript_preview(decoded, "child-target-global"))
            else:
                frame_scripts.append({"source": "child-target-global", "targetVaHex": target_hex, "missing": True})

        missing_frame_scripts = [
            script.get("targetVaHex")
            for script in frame_scripts
            if script.get("missing")
        ]
        frame_labels = []
        for script in frame_scripts:
            frame_labels.extend(script.get("labels") or [])
        frame_labels.extend(event.get("label") for event in direct_events if event.get("label"))
        frame_labels.extend(frame.get("label") for frame in init_frames if frame.get("label"))

        random_ranges = []
        random_ranges.extend((child.get("decoded") or {}).get("randomRanges") or [])
        for node in spawn_nodes:
            random_ranges.extend(node.get("randomRanges") or [])
        palette_events = []
        palette_events.extend((child.get("decoded") or {}).get("paletteEvents") or [])
        for node in spawn_nodes:
            palette_events.extend(node.get("paletteEvents") or [])

        helper = {
            "helperId": helper_id,
            "childScriptVaHex": visual.get("childScriptVaHex") or child.get("childScriptVaHex") or body.get("childScriptVaHex"),
            "skillLabels": visual.get("skillLabels") or [skill.get("skillName") for skill in body.get("skillRows") or [] if skill.get("skillName")],
            "bodyClass": child.get("bodyClass") or (spawn or {}).get("bodyClass") or body.get("bodyClass"),
            "behaviorClass": visual.get("behaviorClass") or body.get("bodyClass"),
            "animationClass": classify_helper(visual, spawn, init_frames, body, palette_events),
            "directSpawn": {
                "count": direct.get("count") or 0,
                "frameTimeline": direct.get("frameTimeline") or [],
                "ticks": direct.get("ticks") or [],
                "uniformTickDelta": lifecycle.get("uniformTickDelta"),
                "events": direct_events,
            },
            "initFrames": init_frames,
            "frameScripts": frame_scripts,
            "spawnNodes": spawn_nodes,
            "rootCounterSets": lifecycle.get("rootCounterSets") or {},
            "rootBranches": lifecycle.get("rootBranches") or [],
            "rootCounterSteps": lifecycle.get("rootCounterSteps") or [],
            "childMotionLoops": lifecycle.get("childMotionLoops") or [],
            "randomRanges": random_ranges,
            "paletteEvents": palette_events,
            "blinkFrameScripts": [
                script
                for script in frame_scripts
                if script.get("displayFlagBlink")
            ],
            "missingFrameScripts": [value for value in missing_frame_scripts if value],
            "effectFrameLabels": sorted(set(str(item) for item in frame_labels if item)),
            "notes": visual.get("notes") or [],
            "interpretation": lifecycle.get("interpretation") or [],
        }
        helper["executionRequirements"] = execution_requirements(helper)
        helper["reviewFlags"] = review_flags(helper)
        helper_rows[helper_id] = helper
    return helper_rows


def build_skill_rows(helper_rows: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
    complete = load_json("battle_skill_complete_pattern_review.json")
    rows: list[dict[str, Any]] = []
    for row in complete.get("rows") or []:
        helpers = [
            helper_rows[helper_id]
            for helper_id in row.get("helperIds") or []
            if helper_id in helper_rows
        ]
        classes = sorted(set(helper["animationClass"] for helper in helpers))
        frame_labels: list[str] = []
        flags: list[str] = []
        requirements: list[str] = []
        for helper in helpers:
            frame_labels.extend(helper.get("effectFrameLabels") or [])
            flags.extend(helper.get("reviewFlags") or [])
            requirements.extend(helper.get("executionRequirements") or [])
        if row.get("helperIds") and not helpers:
            flags.append("helper id present but no helper animation row")
        if not row.get("helperIds"):
            effect_class = "actor-only-or-no-helper"
        elif any(cls in {"counter-driven-burst-spawn", "direct-spawn-stream"} for cls in classes):
            effect_class = "spawn-stream-effect"
        elif any(cls in {"frameScript-sequence", "blink-frameScript", "one-shot-spawn-with-frameScript"} for cls in classes):
            effect_class = "frameScript-effect"
        elif any(cls == "one-shot-direct-frame" for cls in classes):
            effect_class = "one-shot-effect"
        elif any("init-frame-effect" in cls for cls in classes):
            effect_class = "init-frame-effect"
        elif any(cls == "palette-flash-effect" for cls in classes):
            effect_class = "palette-effect"
        elif any(cls == "no-visual-frame-detected" for cls in classes):
            effect_class = "helper-without-decoded-visual"
        elif all(cls == "actor-synchronized-no-independent-frame" for cls in classes):
            effect_class = "actor-synchronized-helper"
        else:
            effect_class = "helper-effect-other"

        rows.append(
            {
                "recordKey": row.get("recordKey") or key_for(row),
                "ownerKey": row.get("ownerKey"),
                "ownerName": row.get("ownerName"),
                "skillName": row.get("skillName"),
                "familyName": row.get("familyName"),
                "skillIdHex": row.get("skillIdHex"),
                "levelOrFixed": row.get("levelOrFixed"),
                "renderTrack": row.get("renderTrack"),
                "implementationState": row.get("implementationState"),
                "actorFrameSequence": row.get("frameSequence") or [],
                "actorExpandedFrameGateSequence": row.get("expandedFrameGateSequence") or [],
                "effectWlkNos": row.get("effectWlkNos") or [],
                "resultWlkNos": row.get("resultWlkNos") or [],
                "helperIds": row.get("helperIds") or [],
                "effectAnimationClass": effect_class,
                "helperAnimationClasses": classes,
                "effectFrameLabels": sorted(set(frame_labels)),
                "helperAnimations": [
                    {
                        "helperId": helper["helperId"],
                        "animationClass": helper["animationClass"],
                        "behaviorClass": helper["behaviorClass"],
                        "effectFrameLabels": helper["effectFrameLabels"],
                        "directSpawn": helper["directSpawn"],
                        "initFrames": helper["initFrames"],
                        "frameScripts": helper["frameScripts"],
                        "rootCounterSets": helper["rootCounterSets"],
                        "childMotionLoops": helper["childMotionLoops"],
                        "paletteEvents": helper["paletteEvents"],
                        "executionRequirements": helper["executionRequirements"],
                        "reviewFlags": helper["reviewFlags"],
                    }
                    for helper in helpers
                ],
                "executionRequirements": unique(requirements),
                "reviewFlags": unique(flags),
            }
        )
    return rows


def build() -> dict[str, Any]:
    helper_rows_by_id = build_helper_rows()
    skill_rows = build_skill_rows(helper_rows_by_id)
    helper_rows = list(helper_rows_by_id.values())
    report = {
        "version": 1,
        "kind": "hwanse-battle-effect-animation-pattern-review",
        "status": "effect-helper-animation-patterns-joined-by-skill",
        "runtimeUsed": False,
        "source": "tools/build_battle_effect_animation_pattern_review.py",
        "inputs": [
            "out/battle_skill_complete_pattern_review.json",
            "out/battle_helper_visual_behavior_review.json",
            "out/battle_helper_child_script_review.json",
            "out/battle_helper_frame_script_review.json",
            "out/battle_helper_spawn_tree_review.json",
            "out/battle_helper_body_review.json",
        ],
        "interpretationNotes": [
            "이 보고서는 기존 EXE 정적 분석 산출물을 재결합한다. 새 런타임 추측을 추가하지 않는다.",
            "effectAnimationClass는 runner 구현을 위한 시각 패턴 분류다. 실제 ms 환산은 gate/tick 그대로 보존한다.",
            "counter-driven-burst-spawn은 root +0x94/+0x96/+0x8e 같은 카운터로 child effect를 반복 생성하는 계열이다.",
            "frameScript-effect는 btl_efc frame/gate sequence가 직접 확인되는 계열이다.",
        ],
        "summary": {
            "skillRows": len(skill_rows),
            "helperRows": len(helper_rows),
            "skillsWithHelpers": sum(1 for row in skill_rows if row["helperIds"]),
            "skillsWithEffectFrames": sum(1 for row in skill_rows if row["effectFrameLabels"]),
            "skillsWithReviewFlags": sum(1 for row in skill_rows if row["reviewFlags"]),
            "skillsWithExecutionRequirements": sum(1 for row in skill_rows if row["executionRequirements"]),
            "helperAnimationClassCounts": dict(Counter(row["animationClass"] for row in helper_rows)),
            "skillEffectClassCounts": dict(Counter(row["effectAnimationClass"] for row in skill_rows)),
            "executionRequirementCounts": dict(
                Counter(req for row in skill_rows for req in row["executionRequirements"])
            ),
            "reviewFlagCounts": dict(Counter(flag for row in skill_rows for flag in row["reviewFlags"])),
            "topEffectFrames": Counter(label for row in skill_rows for label in row["effectFrameLabels"]).most_common(30),
        },
        "helperRows": helper_rows,
        "skillRows": skill_rows,
    }
    return report


def main() -> None:
    report = build()
    OUT_JSON.write_text(json.dumps(report, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
    print("wrote out/battle_effect_animation_pattern_review.json")


if __name__ == "__main__":
    main()
