#!/usr/bin/env python3
"""Expand battle child visual spawn evidence into renderer-facing plans.

This is the next layer after battle_child_spawn_stream_review: it keeps the
EXE-derived spawn events in timeline order and annotates what is known about
draw order, child lifetime, nested frame scripts, and remaining renderer gaps.
"""

from __future__ import annotations

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


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

CHILD_STREAM = OUT / "battle_child_spawn_stream_review.json"
HELPER_VISUAL = OUT / "battle_helper_visual_behavior_review.json"
HELPER_SYNC = OUT / "battle_helper_sync_timing_review.json"
HELPER_OBJECT = OUT / "battle_effect_object_review.json"

OUT_JSON = OUT / "battle_child_lifecycle_draw_order_review.json"


def load(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8"))


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


def as_list(value: Any) -> list[Any]:
    return value if isinstance(value, list) else []


def nested_by_target(visual: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
    out: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for item in as_list(visual.get("nestedVisualFrames")):
        target = item.get("targetVaHex")
        if target:
            out[str(target)].append(item)
    for item in as_list(visual.get("frameScripts")):
        target = item.get("targetVaHex")
        if target:
            out[str(target)].append(item)
    return out


def frame_script_duration(items: list[dict[str, Any]]) -> int | None:
    durations = [item.get("durationGate") for item in items if isinstance(item.get("durationGate"), int)]
    if durations:
        return max(durations)
    gates = []
    for item in items:
        if isinstance(item.get("gateSequence"), list):
            gates.extend(g for g in item["gateSequence"] if isinstance(g, int))
        if isinstance(item.get("events"), list):
            gates.extend(event.get("gate") for event in item["events"] if isinstance(event.get("gate"), int))
    return sum(gates) if gates else None


def frame_labels(items: list[dict[str, Any]]) -> list[str]:
    labels: list[str] = []
    for item in items:
        if isinstance(item.get("frameLabels"), list):
            labels.extend(str(label) for label in item["frameLabels"])
        elif isinstance(item.get("frames"), list):
            labels.extend(str(label) for label in item["frames"])
        elif item.get("frame") is not None:
            labels.append(str(item.get("frame")))
        elif isinstance(item.get("events"), list):
            for event in item["events"]:
                labels.append(str(event.get("label") or event.get("frame")))
    return labels


def target_frame_script_items(target: str, visual: dict[str, Any]) -> list[dict[str, Any]]:
    matches = []
    for item in as_list(visual.get("nestedVisualFrames")):
        if str(item.get("targetVaHex")) == target and (
            item.get("source") == "spawn-node-frameScript" or item.get("frameSequence")
        ):
            matches.append(item)
    for item in as_list(visual.get("frameScripts")):
        if str(item.get("targetVaHex")) == target:
            matches.append(item)
    return matches


def sibling_frame_script_items(target: str, event: dict[str, Any], visual: dict[str, Any]) -> list[dict[str, Any]]:
    """Return frameScript siblings following the direct init frame in spawn-tree order.

    Some helpers spawn a direct init-frame script, then a sibling frameScript
    target is the actual continuing animation.  The static report carries
    nested paths such as root.1/root.2; path order is the best static draw-order
    evidence available without runtime.
    """

    nested = as_list(visual.get("nestedVisualFrames"))
    target_indexes = [i for i, item in enumerate(nested) if str(item.get("targetVaHex")) == target]
    if not target_indexes:
        return []
    first = target_indexes[0]
    siblings = []
    source_prefix = str(nested[first].get("path") or "").split(".")[0]
    for item in nested[first + 1 :]:
        path = str(item.get("path") or "")
        if source_prefix and not path.startswith(source_prefix):
            break
        if item.get("source") == "spawn-node-frameScript" or item.get("frameSequence"):
            siblings.append(item)
    return siblings[:2]


def lifetime_for_event(event: dict[str, Any], visual: dict[str, Any]) -> dict[str, Any]:
    target = str(event.get("targetVaHex") or "")
    exact_scripts = target_frame_script_items(target, visual)
    sibling_scripts = sibling_frame_script_items(target, event, visual)
    scripts = exact_scripts or sibling_scripts
    duration = frame_script_duration(scripts)
    if duration is not None:
        return {
            "class": "frameScript-duration",
            "durationGate": duration,
            "source": "target-frameScript" if exact_scripts else "spawn-tree-sibling-frameScript",
            "frameLabels": frame_labels(scripts)[:16],
            "confidence": "static-confirmed",
        }
    stop_reason = event.get("targetStopReason")
    if stop_reason:
        return {
            "class": "target-script-stopReason",
            "durationGate": None,
            "source": str(stop_reason),
            "frameLabels": [str(event.get("label") or event.get("frame"))],
            "confidence": "lifetime-not-fully-expanded",
        }
    return {
        "class": "single-init-frame",
        "durationGate": None,
        "source": "direct init frame only",
        "frameLabels": [str(event.get("label") or event.get("frame"))],
        "confidence": "needs-child-script-expansion",
    }


def event_transform(event: dict[str, Any]) -> dict[str, Any]:
    preview = event.get("previewTransform") or {}
    scope = event.get("transformScope") or {}
    return {
        "status": event.get("transformStatus"),
        "offsetX": preview.get("offsetX"),
        "offsetY": preview.get("offsetY"),
        "targetAnchorX": preview.get("targetAnchorX"),
        "targetAnchorY": preview.get("targetAnchorY"),
        "basis": preview.get("basis"),
        "coordinateSummary": preview.get("coordinateSummary") or scope.get("coordinateSummary"),
    }


def ordered_spawn_plan(helper_id: int, visual: dict[str, Any], sync: dict[str, Any]) -> list[dict[str, Any]]:
    events = as_list((visual.get("directSpawn") or {}).get("events"))
    if not events:
        events = as_list(sync.get("directSpawnFrameEvents"))
    plan = []
    for index, event in enumerate(events):
        lifetime = lifetime_for_event(event, visual)
        plan.append(
            {
                "order": index,
                "tick": event.get("absoluteTick", event.get("tick", 0)),
                "spawnVaHex": event.get("spawnVaHex"),
                "targetVaHex": event.get("targetVaHex"),
                "asset": event.get("asset"),
                "frame": event.get("frame"),
                "label": event.get("label"),
                "reason": event.get("reason"),
                "repeatIndex": event.get("repeatIndex"),
                "drawOrderEvidence": "same-tick order follows decoded directSpawn event order",
                "lifetime": lifetime,
                "transform": event_transform(event),
            }
        )
    plan.sort(key=lambda item: (item.get("tick") or 0, item["order"]))
    return plan


def helper_row(helper_id: int, visual_idx: dict[int, dict[str, Any]], sync_idx: dict[int, dict[str, Any]], object_idx: dict[int, dict[str, Any]]) -> dict[str, Any]:
    visual = visual_idx.get(helper_id, {})
    sync = sync_idx.get(helper_id, {})
    obj = object_idx.get(helper_id, {})
    direct = visual.get("directSpawn") or {}
    lifecycle = visual.get("lifecycle") or {}
    root = obj.get("root") or {}
    plan = ordered_spawn_plan(helper_id, visual, sync)
    lifetime_classes = Counter(item["lifetime"]["class"] for item in plan)
    transform_status = Counter(str((item.get("transform") or {}).get("status") or "unreported") for item in plan)
    return {
        "helperId": helper_id,
        "behaviorClass": visual.get("behaviorClass") or obj.get("helperClass"),
        "childScriptVaHex": visual.get("childScriptVaHex") or sync.get("childScriptVaHex") or obj.get("childScriptVaHex"),
        "directSpawnCount": direct.get("count") or len(plan),
        "emitTicks": direct.get("ticks") or lifecycle.get("emitTicks") or [],
        "uniformTickDelta": lifecycle.get("uniformTickDelta"),
        "rootCounterSets": lifecycle.get("rootCounterSets") or {},
        "rootBranchRoles": sorted({str(item.get("role")) for item in as_list(lifecycle.get("rootBranches")) if item.get("role")}),
        "rootStopReason": root.get("stopReason"),
        "rootOpcodeCounts": root.get("opcodeCounts") or {},
        "spawnPlan": plan,
        "lifetimeClassCounts": dict(sorted(lifetime_classes.items())),
        "transformStatusCounts": dict(sorted(transform_status.items())),
        "drawOrderStatus": draw_order_status(plan),
        "rendererReadiness": renderer_readiness(plan, lifecycle, root),
    }


def draw_order_status(plan: list[dict[str, Any]]) -> str:
    if not plan:
        return "no-direct-child-spawn"
    ticks = Counter(item.get("tick") for item in plan)
    if any(count > 1 for count in ticks.values()):
        return "same-tick-order-needs-list-order-preserved"
    return "tick-order-sufficient"


def renderer_readiness(plan: list[dict[str, Any]], lifecycle: dict[str, Any], root: dict[str, Any]) -> dict[str, Any]:
    gaps = []
    if any(item["lifetime"]["confidence"] != "static-confirmed" for item in plan):
        gaps.append("child lifetime is not fully expanded for every direct spawn")
    if any((item.get("transform") or {}).get("status") != "matched-position-motion-scope" for item in plan):
        gaps.append("some child transforms lack matched position/motion scope")
    if as_list(lifecycle.get("rootBranches")):
        gaps.append("root branch/counter loop must be preserved")
    if (root.get("opcodeCounts") or {}).get("0x07"):
        gaps.append("spawn-child-vm opcode 0x07 creates independent display objects")
    return {
        "status": "renderer-plan-ready" if not gaps and plan else "renderer-needs-lifecycle-work",
        "gaps": gaps,
    }


def build_report() -> dict[str, Any]:
    child_stream = load(CHILD_STREAM)
    visual_idx = index_by_helper(HELPER_VISUAL, "rows")
    sync_idx = index_by_helper(HELPER_SYNC, "rows")
    object_idx = index_by_helper(HELPER_OBJECT, "helperRows")
    rows = []
    for skill in child_stream.get("rows", []):
        helpers = [helper_row(helper_id, visual_idx, sync_idx, object_idx) for helper_id in skill.get("helperIds", [])]
        if not any(helper.get("spawnPlan") or helper.get("rootBranchRoles") for helper in helpers):
            continue
        rows.append(
            {
                "recordKey": skill.get("recordKey"),
                "ownerName": skill.get("ownerName"),
                "skillName": skill.get("skillName"),
                "skillIdHex": skill.get("skillIdHex"),
                "levelOrFixed": skill.get("levelOrFixed"),
                "renderTrack": skill.get("renderTrack"),
                "effectAnimationClass": skill.get("effectAnimationClass"),
                "executionRequirements": skill.get("executionRequirements") or [],
                "renderStatus": skill.get("renderStatus"),
                "helpers": helpers,
            }
        )

    readiness = Counter()
    draw_order = Counter()
    lifetime = Counter()
    direct_spawn_helpers = 0
    total_spawn_events = 0
    for row in rows:
        for helper in row["helpers"]:
            readiness[helper["rendererReadiness"]["status"]] += 1
            draw_order[helper["drawOrderStatus"]] += 1
            lifetime.update(helper["lifetimeClassCounts"])
            total_spawn_events += len(helper["spawnPlan"])
            if helper["spawnPlan"]:
                direct_spawn_helpers += 1

    return {
        "version": 1,
        "kind": "hwanse-battle-child-lifecycle-draw-order-review",
        "source": "tools/build_battle_child_lifecycle_draw_order_review.py",
        "runtimeUsed": False,
        "inputs": [
            str(CHILD_STREAM.relative_to(ROOT)),
            str(HELPER_VISUAL.relative_to(ROOT)),
            str(HELPER_SYNC.relative_to(ROOT)),
            str(HELPER_OBJECT.relative_to(ROOT)),
        ],
        "status": "static-child-lifecycle-reviewed",
        "summary": {
            "skillRows": len(rows),
            "helpersWithDirectSpawn": direct_spawn_helpers,
            "totalDirectSpawnEvents": total_spawn_events,
            "rendererReadinessCounts": dict(sorted(readiness.items())),
            "drawOrderStatusCounts": dict(sorted(draw_order.items())),
            "lifetimeClassCounts": dict(sorted(lifetime.items())),
        },
        "interpretationNotes": [
            "draw order is derived from decoded directSpawn event order within each helper tick.",
            "frameScript-duration rows have static gate duration evidence and are the safest to render as independent child objects.",
            "target-script-stopReason rows expose the child script target and stop/loop reason, but exact lifetime still needs deeper expansion of that child script.",
            "Same-tick spawn events must preserve decoded list order; sorting by frame id would be wrong.",
        ],
        "rows": rows,
    }


def render_list(items: list[Any], limit: int = 12) -> str:
    if not items:
        return "<span class='muted'>none</span>"
    shown = "<br>".join(html.escape(str(item)) for item in items[:limit])
    if len(items) > limit:
        shown += f"<br><span class='muted'>+{len(items) - limit} more</span>"
    return shown


def render_dict(value: dict[str, Any]) -> str:
    if not value:
        return "<span class='muted'>none</span>"
    return "<br>".join(f"<code>{html.escape(str(k))}</code>: {html.escape(str(v))}" for k, v in value.items())


def render_plan(plan: list[dict[str, Any]]) -> str:
    if not plan:
        return "<span class='muted'>no direct child spawn</span>"
    rows = []
    for item in plan[:24]:
        lifetime = item["lifetime"]
        transform = item["transform"]
        rows.append(
            "<tr>"
            f"<td>{item['order']}</td>"
            f"<td>{html.escape(str(item.get('tick')))}</td>"
            f"<td><code>{html.escape(str(item.get('targetVaHex')))}</code><br>{html.escape(str(item.get('reason')))}</td>"
            f"<td>{html.escape(str(item.get('label') or item.get('frame')))}</td>"
            f"<td>{html.escape(lifetime['class'])}<br>{html.escape(str(lifetime.get('durationGate')))}<br><span class='muted'>{html.escape(lifetime['confidence'])}</span></td>"
            f"<td>{html.escape(str(transform.get('status')))}<br>{html.escape(str(transform.get('coordinateSummary')))}</td>"
            "</tr>"
        )
    if len(plan) > 24:
        rows.append(f"<tr><td colspan='6' class='muted'>+{len(plan) - 24} more spawn events</td></tr>")
    return "<table class='plan'><thead><tr><th>#</th><th>tick</th><th>target</th><th>frame</th><th>lifetime</th><th>transform</th></tr></thead><tbody>" + "".join(rows) + "</tbody></table>"


def render_html(report: dict[str, Any]) -> str:
    cards = "".join(
        f"<div class='card'><b>{html.escape(k)}</b><pre>{html.escape(json.dumps(v, ensure_ascii=False, indent=2))}</pre></div>"
        for k, v in report["summary"].items()
    )
    notes = "".join(f"<li>{html.escape(note)}</li>" for note in report["interpretationNotes"])
    row_html = []
    for row in report["rows"]:
        helper_html = []
        for helper in row["helpers"]:
            readiness = helper["rendererReadiness"]
            helper_html.append(
                "<details class='helper' open>"
                f"<summary>helper #{helper['helperId']} · {html.escape(str(helper.get('behaviorClass')))} · {html.escape(readiness['status'])}</summary>"
                "<div class='helper-grid'>"
                f"<div><b>script</b><br><code>{html.escape(str(helper.get('childScriptVaHex')))}</code><br>{html.escape(str(helper.get('rootStopReason')))}</div>"
                f"<div><b>emit</b><br>count {helper['directSpawnCount']}<br>delta {html.escape(str(helper.get('uniformTickDelta')))}<br>{render_list(helper.get('emitTicks') or [], 10)}</div>"
                f"<div><b>counters</b><br>{render_dict(helper.get('rootCounterSets') or {})}</div>"
                f"<div><b>branch roles</b><br>{render_list(helper.get('rootBranchRoles') or [], 8)}</div>"
                f"<div><b>gaps</b><br>{render_list(readiness.get('gaps') or [], 8)}</div>"
                "</div>"
                f"{render_plan(helper.get('spawnPlan') or [])}"
                "</details>"
            )
        row_html.append(
            "<tr>"
            f"<td><b>{html.escape(row['ownerName'])}</b><br>{html.escape(row['skillName'])}<br><code>{html.escape(row['skillIdHex'])}</code> Lv={html.escape(str(row.get('levelOrFixed')))}</td>"
            f"<td>{html.escape(row['effectAnimationClass'])}<br>{render_list(row.get('executionRequirements') or [], 5)}</td>"
            f"<td>{''.join(helper_html)}</td>"
            "</tr>"
        )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Battle Child Lifecycle Draw Order Review</title>
  <style>
    body {{ margin: 0; padding: 24px; background: #101218; color: #edf1f7; font: 14px/1.5 system-ui, sans-serif; }}
    a {{ color: #8ec5ff; }}
    h1 {{ margin: 0 0 8px; font-size: 24px; }}
    .muted {{ color: #9aa4b2; }}
    .cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 12px; margin: 16px 0; }}
    .card {{ border: 1px solid #2c3444; border-radius: 8px; padding: 12px; background: #171c25; }}
    pre {{ white-space: pre-wrap; margin: 8px 0 0; color: #b9c4d6; }}
    table {{ width: 100%; border-collapse: collapse; margin-top: 16px; }}
    th, td {{ border-top: 1px solid #2c3444; padding: 10px; vertical-align: top; }}
    th {{ text-align: left; position: sticky; top: 0; background: #101218; }}
    code {{ color: #ffd58c; }}
    details.helper {{ margin: 0 0 10px; border: 1px solid #2c3444; border-radius: 8px; background: #151a22; }}
    details.helper summary {{ cursor: pointer; padding: 8px 10px; }}
    .helper-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 8px; padding: 0 10px 10px; }}
    .helper-grid > div {{ background: #0f131a; border-radius: 6px; padding: 8px; }}
    table.plan {{ margin: 0 10px 10px; width: calc(100% - 20px); font-size: 12px; }}
    table.plan th {{ position: static; background: #111722; }}
  </style>
</head>
<body>
  <h1>Battle Child Lifecycle Draw Order Review</h1>
  <p class="muted">child spawn stream의 생성 순서, 수명 근거, draw order 근거를 renderer 구현 단위로 펼친 정적 분석입니다.</p>
  <p><a href="../web/index.html">index</a> · <a href="battle_child_spawn_stream_review.json">child spawn stream JSON</a> · <a href="../web/battle_skill_timeline_review.html">skill timeline</a></p>
  <div class="cards">{cards}</div>
  <ul>{notes}</ul>
  <table>
    <thead><tr><th>기술</th><th>분류</th><th>child lifecycle / draw order</th></tr></thead>
    <tbody>{''.join(row_html)}</tbody>
  </table>
</body>
</html>
"""


def main() -> None:
    report = build_report()
    OUT_JSON.write_text(json.dumps(report, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
    print(json.dumps(report["summary"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
