#!/usr/bin/env python3
"""Summarize child target scripts as renderer-ready effect plans.

The target-script expansion report proves that the remaining child targets are
not plain one-frame effects.  This report compresses those static findings into
per helper/target plans that the web renderer can consume without re-reading
every low-level script row.
"""

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"

EXPANSION_JSON = OUT / "battle_child_target_script_expansion_review.json"
SPAWN_TREE_JSON = OUT / "battle_helper_spawn_tree_review.json"
VISUAL_JSON = OUT / "battle_helper_visual_behavior_review.json"
POSITION_JSON = OUT / "battle_helper_position_motion_review.json"

OUT_JSON = OUT / "battle_child_target_renderer_plan_review.json"


FIELD_RE = re.compile(r"\[\+(0x[0-9a-fA-F]+)\]")


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


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


def as_dict(value: Any) -> dict[str, Any]:
    return value if isinstance(value, dict) else {}


def html_escape(value: Any) -> str:
    return html.escape(str(value))


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


def frame_labels(node: dict[str, Any]) -> list[int]:
    values: list[int] = []
    for key in ["initFrames", "directFrames"]:
        for value in as_list(node.get(key)):
            if isinstance(value, int):
                values.append(value)
    for item in as_list(node.get("resolvedFrameScripts")):
        for value in as_list(item.get("frameLabels")):
            if isinstance(value, int):
                values.append(value)
    return unique(values)


def indexes() -> tuple[dict[tuple[int, str], dict[str, Any]], dict[int, dict[str, Any]], dict[int, dict[str, Any]]]:
    spawn_tree = load(SPAWN_TREE_JSON)
    visual = load(VISUAL_JSON)
    position = load(POSITION_JSON)

    node_idx: dict[tuple[int, str], dict[str, Any]] = {}
    for row in as_list(spawn_tree.get("helperRows")):
        helper_id = row.get("helperId")
        if not isinstance(helper_id, int):
            continue
        for node in as_list(row.get("flatNodes")) or as_list(row.get("nodes")):
            target = node.get("targetVaHex")
            if target:
                node_idx[(helper_id, str(target))] = node

    visual_idx = {
        row["helperId"]: row
        for row in as_list(visual.get("rows"))
        if isinstance(row.get("helperId"), int)
    }
    position_idx = {
        row["helperId"]: row
        for row in as_list(position.get("helperRows"))
        if isinstance(row.get("helperId"), int)
    }
    return node_idx, visual_idx, position_idx


def branch_fields(node: dict[str, Any]) -> list[str]:
    fields: list[str] = []
    for branch in as_list(node.get("branchTargets")) + as_list(node.get("branchRows")):
        summary = str(branch.get("summary") or "")
        fields.extend(FIELD_RE.findall(summary))
        left = branch.get("leftSelectorHex")
        if isinstance(left, str) and left.startswith("0x"):
            fields.append(left)
    return unique([field.lower() for field in fields])


def counter_writes_for_field(node: dict[str, Any], visual: dict[str, Any], field_hex: str) -> list[dict[str, Any]]:
    field_hex = field_hex.lower()
    rows: list[dict[str, Any]] = []
    for source_name, source_rows in [
        ("target-node", as_list(node.get("motionWrites"))),
        ("visual-root", as_list(as_dict(visual.get("lifecycle")).get("rootCounterSteps"))),
    ]:
        for write in source_rows:
            if str(write.get("destHex") or write.get("fieldHex") or "").lower() != field_hex:
                continue
            rows.append(
                {
                    "source": source_name,
                    "vaHex": write.get("vaHex"),
                    "operation": write_operation(write),
                    "rawValue": write.get("rawValue"),
                    "immHex": write.get("immHex"),
                    "sourceHex": write.get("sourceHex"),
                    "modeHex": write.get("modeHex"),
                    "summary": write.get("summary"),
                }
            )
    root_sets = as_dict(as_dict(visual.get("lifecycle")).get("rootCounterSets"))
    if field_hex in {str(k).lower(): v for k, v in root_sets.items()}:
        for key, value in root_sets.items():
            if str(key).lower() == field_hex:
                rows.append(
                    {
                        "source": "visual-rootCounterSets",
                        "vaHex": None,
                        "operation": "set",
                        "rawValue": value,
                        "sourceHex": None,
                        "modeHex": None,
                        "summary": f"rootCounterSets {key}={value}",
                    }
                )
    return rows


def write_operation(write: dict[str, Any]) -> str | None:
    operation = write.get("operation")
    if isinstance(operation, str):
        return operation
    summary = str(write.get("summary") or "")
    if summary.startswith("set "):
        return "set"
    if summary.startswith("add "):
        return "add"
    if summary.startswith("sub "):
        return "sub"
    mode = str(write.get("modeHex") or "").lower()
    if mode.endswith("0"):
        return "set"
    if mode.endswith("1"):
        return "add"
    if mode.endswith("2"):
        return "sub"
    return None


def raw_int(write: dict[str, Any]) -> int | None:
    value = write.get("rawValue")
    if isinstance(value, int):
        return value
    imm = write.get("immHex")
    if isinstance(imm, str):
        try:
            return int(imm, 16)
        except ValueError:
            return None
    return None


def infer_counter_plan(node: dict[str, Any], visual: dict[str, Any]) -> dict[str, Any]:
    fields = branch_fields(node)
    counter_rows: list[dict[str, Any]] = []
    for field in fields:
        counter_rows.extend(counter_writes_for_field(node, visual, field))
    initial_candidates = [
        {"fieldHex": field, "value": raw_int(row), "source": row.get("source"), "vaHex": row.get("vaHex")}
        for field in fields
        for row in counter_writes_for_field(node, visual, field)
        if row.get("operation") == "set" and raw_int(row) is not None
    ]
    return {
        "loopFields": fields,
        "counterWrites": counter_rows,
        "initialCandidates": initial_candidates,
    }


def direct_spawn_events_for_target(visual: dict[str, Any], target_va_hex: str) -> list[dict[str, Any]]:
    events = as_list(as_dict(visual.get("directSpawn")).get("events"))
    return [event for event in events if event.get("targetVaHex") == target_va_hex]


def root_spawn_jitter_x_for_target(visual: dict[str, Any], target_va_hex: str) -> dict[str, Any] | None:
    """Extract root-side x jitter applied immediately after spawning target_va_hex.

    The observed root scripts for the Rinshan dragon-rise helpers spawn a child,
    run opcode 0x2b into +0x58, scale/subtract that temporary value, then add it
    to the child's x coordinate.  This is not part of the child target script
    itself, so keep it next to the spawn schedule consumed by the renderer.
    """

    target = str(target_va_hex).lower()
    lifecycle = as_dict(visual.get("lifecycle"))
    for branch in as_list(lifecycle.get("rootBranches")):
        snippet = as_list(branch.get("targetSnippet"))
        for index, item in enumerate(snippet):
            if item.get("category") != "spawn-child-vm":
                continue
            if target not in str(item.get("summary") or "").lower():
                continue
            tail = snippet[index + 1 : index + 10]
            range_value: int | None = None
            multiplier: float | None = None
            subtract: float | None = None
            add_to_x = False
            for op in tail:
                summary = str(op.get("summary") or "")
                if op.get("opcode") == "0x2b" and "rng(range=" in summary:
                    match = re.search(r"rng\(range=(\d+)\)", summary)
                    if match:
                        range_value = int(match.group(1))
                if "+0x58" not in summary:
                    continue
                if "mul display/actor field +0x58" in summary:
                    match = re.search(r"\(([-0-9.]+)px\)", summary)
                    if match:
                        multiplier = float(match.group(1))
                elif "sub display/actor field +0x58" in summary:
                    match = re.search(r"\(([-0-9.]+)px\)", summary)
                    if match:
                        subtract = float(match.group(1))
                elif "add display/actor field +0x1c, source +0x58" in summary:
                    add_to_x = True
            if range_value and multiplier is not None and subtract is not None and add_to_x:
                return {
                    "sourceBranchVaHex": branch.get("vaHex"),
                    "spawnTargetVaHex": target_va_hex,
                    "range": range_value,
                    "multiplierPx": multiplier,
                    "subtractPx": subtract,
                    "formula": "x += rand(range) * multiplierPx - subtractPx",
                    "confidence": "static-exe-root-snippet",
                }
    return None


def spawn_schedule(visual: dict[str, Any], target_va_hex: str) -> dict[str, Any]:
    events = direct_spawn_events_for_target(visual, target_va_hex)
    lifecycle = as_dict(visual.get("lifecycle"))
    return {
        "eventCountForTarget": len(events),
        "ticks": [event.get("tick") for event in events],
        "absoluteTicks": [event.get("absoluteTick") for event in events],
        "frames": unique([event.get("frame") for event in events if event.get("frame") is not None]),
        "spawnVaHexes": unique([event.get("spawnVaHex") for event in events if event.get("spawnVaHex")]),
        "uniformTickDelta": lifecycle.get("uniformTickDelta"),
        "helperEmitCount": lifecycle.get("emitCount"),
        "helperEmitTicks": lifecycle.get("emitTicks"),
        "rootCounterSets": lifecycle.get("rootCounterSets") or {},
        "rootSpawnJitterX": root_spawn_jitter_x_for_target(visual, target_va_hex),
    }


def child_node_summaries(helper_id: int, node: dict[str, Any], node_idx: dict[tuple[int, str], dict[str, Any]]) -> list[dict[str, Any]]:
    rows = []
    for spawn in as_list(node.get("spawnRows")):
        target = spawn.get("targetVaHex")
        child = node_idx.get((helper_id, str(target)), {})
        rows.append(
            {
                "spawnVaHex": spawn.get("vaHex"),
                "targetVaHex": target,
                "childObjectType": spawn.get("childObjectType"),
                "childPrimaryClass": child.get("primaryClass"),
                "childFrames": frame_labels(child),
                "childStopReason": child.get("stopReason"),
                "childPositionWrites": compact_writes(child.get("positionWrites")),
            }
        )
    return rows


def compact_writes(writes: Any) -> list[dict[str, Any]]:
    out = []
    for write in as_list(writes):
        out.append(
            {
                "vaHex": write.get("vaHex"),
                "operation": write.get("operation"),
                "destHex": write.get("destHex"),
                "sourceHex": write.get("sourceHex"),
                "modeHex": write.get("modeHex"),
                "immFixed": write.get("immFixed"),
                "summary": write.get("summary"),
            }
        )
    return out


def compact_branches(branches: Any) -> list[dict[str, Any]]:
    out = []
    for branch in as_list(branches):
        out.append(
            {
                "vaHex": branch.get("vaHex"),
                "category": branch.get("category"),
                "summary": branch.get("summary"),
                "targetVaHex": branch.get("targetVaHex"),
                "comparison": branch.get("comparison"),
                "leftSelectorHex": branch.get("leftSelectorHex"),
                "rightImmediateHex": branch.get("rightImmediateHex"),
            }
        )
    return out


def transform_summary(events: list[dict[str, Any]], expansion_rows: list[dict[str, Any]]) -> dict[str, Any]:
    transforms = []
    for event in events:
        transform = event.get("previewTransform")
        if transform:
            transforms.append(transform)
    for row in expansion_rows:
        transform = row.get("transform")
        if transform:
            transforms.append(transform)
    transforms = unique(transforms)
    return {
        "transforms": transforms,
        "transformStatuses": unique([event.get("transformStatus") for event in events if event.get("transformStatus")]),
        "coordinateSummaries": unique(
            [
                as_dict(event.get("transformScope")).get("coordinateSummary")
                for event in events
                if as_dict(event.get("transformScope")).get("coordinateSummary")
            ]
        ),
    }


def action_for_class(plan_class: str, node: dict[str, Any], visual: dict[str, Any], counter_plan: dict[str, Any], children: list[dict[str, Any]]) -> str:
    frames = frame_labels(node)
    schedule = as_dict(visual.get("lifecycle"))
    if plan_class == "nested-child-spawner":
        repeat = None
        for candidate in counter_plan.get("initialCandidates") or []:
            if candidate.get("value") not in (None, 0):
                repeat = candidate.get("value")
                break
        targets = ", ".join(str(child.get("targetVaHex")) for child in children) or "child target"
        return (
            f"Draw parent frame {frames or '-'} and execute nested spawner; "
            f"repeat child creation {repeat if repeat is not None else 'unknown'} times toward {targets}; "
            "apply target-script random ranges and anchor/link writes."
        )
    if plan_class == "motion-boundary-loop":
        emit_count = schedule.get("emitCount")
        delta = schedule.get("uniformTickDelta")
        return (
            f"Spawn frame {frames or '-'} {emit_count} time(s)"
            f"{f' every {delta} gate tick(s)' if delta is not None else ''}; "
            "advance child with opcode 0x2d motion until y-boundary branches end the child."
        )
    if plan_class == "counter-held-direct-frame":
        counter = counter_plan.get("initialCandidates") or []
        return (
            f"Spawn and hold frame {frames or '-'} using counter {counter or counter_plan.get('loopFields')}; "
            "decrement local counter each VM tick before destroy/end."
        )
    return f"Use direct target frame(s) {frames or '-'} with preserved child VM script semantics."


def build_report() -> dict[str, Any]:
    expansion = load(EXPANSION_JSON)
    node_idx, visual_idx, position_idx = indexes()

    grouped: dict[tuple[int, str, str], list[dict[str, Any]]] = defaultdict(list)
    for row in as_list(expansion.get("rows")):
        helper_id = row.get("helperId")
        target = row.get("targetVaHex")
        plan_class = row.get("targetScriptClass")
        if isinstance(helper_id, int) and target and plan_class:
            grouped[(helper_id, str(target), str(plan_class))].append(row)

    rows = []
    for (helper_id, target_va_hex, plan_class), source_rows in sorted(grouped.items(), key=lambda item: (item[0][0], item[0][1])):
        node = node_idx.get((helper_id, target_va_hex), {})
        visual = visual_idx.get(helper_id, {})
        position = position_idx.get(helper_id, {})
        events = direct_spawn_events_for_target(visual, target_va_hex)
        counter_plan = infer_counter_plan(node, visual)
        child_summaries = child_node_summaries(helper_id, node, node_idx)
        schedule = spawn_schedule(visual, target_va_hex)
        transform = transform_summary(events, source_rows)
        rows.append(
            {
                "helperId": helper_id,
                "targetVaHex": target_va_hex,
                "rendererPlanClass": plan_class,
                "skillLabels": unique(
                    [
                        f"{row.get('ownerName')} {row.get('skillName')} {row.get('skillIdHex')} Lv={row.get('levelOrFixed')}"
                        for row in source_rows
                    ]
                ),
                "eventCount": len(source_rows),
                "nodePrimaryClass": node.get("primaryClass"),
                "nodeTags": node.get("tags") or [],
                "nodeStopReason": node.get("stopReason"),
                "frames": frame_labels(node),
                "spawnSchedule": schedule,
                "counterPlan": counter_plan,
                "randomRanges": node.get("randomRanges") or [],
                "positionWrites": compact_writes(node.get("positionWrites")),
                "motionWrites": compact_writes(node.get("motionWrites")),
                "motionSteps": node.get("motionSteps") or [],
                "branches": compact_branches(node.get("branchTargets") or node.get("branchRows")),
                "spawnChildren": child_summaries,
                "transform": transform,
                "positionPatterns": position.get("patterns") or [],
                "rendererAction": action_for_class(plan_class, node, visual, counter_plan, child_summaries),
                "rendererConfidence": "static-exe-parameterized",
            }
        )

    class_counts = Counter(row["rendererPlanClass"] for row in rows)
    helper_counts = Counter(row["helperId"] for row in rows)
    unresolved_actions = [
        row for row in rows
        if not row["frames"] and not row["spawnChildren"]
    ]
    return {
        "version": 1,
        "kind": "hwanse-battle-child-target-renderer-plan-review",
        "source": "tools/build_battle_child_target_renderer_plan_review.py",
        "runtimeUsed": False,
        "inputs": [
            str(EXPANSION_JSON.relative_to(ROOT)),
            str(SPAWN_TREE_JSON.relative_to(ROOT)),
            str(VISUAL_JSON.relative_to(ROOT)),
            str(POSITION_JSON.relative_to(ROOT)),
        ],
        "status": "renderer-parameter-plan-static",
        "summary": {
            "uniqueHelperTargetPlans": len(rows),
            "rendererPlanClassCounts": dict(sorted(class_counts.items())),
            "helperCounts": dict(sorted((str(k), v) for k, v in helper_counts.items())),
            "unresolvedRendererActions": len(unresolved_actions),
        },
        "interpretationNotes": [
            "This report does not add runtime observations. It reuses static EXE child VM, motion, counter, and spawn-tree evidence.",
            "rendererPlanClass describes how the renderer should execute the target script, not just which CNS frame to draw.",
            "motion-boundary-loop effects must remain alive while opcode 0x2d advances the child until the decoded boundary branch exits.",
            "counter-held-direct-frame effects must preserve the EXE counter hold; otherwise effects like slash streaks disappear too quickly.",
            "nested-child-spawner effects must recursively spawn their nested target children; a single-frame placeholder is known incomplete.",
        ],
        "rows": rows,
    }


def small_json(value: Any) -> str:
    if value in (None, [], {}):
        return "<span class='muted'>none</span>"
    return f"<pre>{html_escape(json.dumps(value, ensure_ascii=False, indent=2))}</pre>"


def html_page(report: dict[str, Any]) -> str:
    cards = "".join(
        f"<div class='card'><b>{html_escape(key)}</b>{small_json(value)}</div>"
        for key, value in report["summary"].items()
    )
    notes = "".join(f"<li>{html_escape(note)}</li>" for note in report["interpretationNotes"])
    trs = []
    for row in report["rows"]:
        trs.append(
            "<tr>"
            f"<td><b>#{row['helperId']}</b><br><code>{html_escape(row['targetVaHex'])}</code><br>{html_escape(row['rendererPlanClass'])}</td>"
            f"<td>{'<br>'.join(html_escape(label) for label in row['skillLabels'])}</td>"
            f"<td><b>frames</b> {html_escape(row['frames'])}<br><b>schedule</b>{small_json(row['spawnSchedule'])}</td>"
            f"<td>{small_json(row['counterPlan'])}<hr>{small_json(row['randomRanges'])}</td>"
            f"<td>{small_json(row['transform'])}<hr>{small_json(row['motionSteps'])}</td>"
            f"<td>{small_json(row['spawnChildren'])}<hr>{html_escape(row['rendererAction'])}</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 Target Renderer Plan 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; }}
    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; }}
    pre {{ margin: 6px 0 0; white-space: pre-wrap; color: #cbd5e1; font-size: 12px; }}
    code {{ color: #ffd58c; }}
    hr {{ border: 0; border-top: 1px solid #2c3444; margin: 8px 0; }}
  </style>
</head>
<body>
  <h1>Battle Child Target Renderer Plan Review</h1>
  <p class="muted">target script expansion 결과를 renderer 파라미터로 압축한 정적 EXE 근거 페이지입니다.</p>
  <p>
    <a href="../web/index.html">index</a> ·
    <a href="battle_child_target_script_expansion_review.json">target script expansion JSON</a> ·
    <a href="battle_child_lifecycle_draw_order_review.json">child lifecycle order JSON</a> ·
    <a href="../web/battle_skill_timeline_review.html">skill timeline</a> ·
    <a href="battle_child_target_renderer_plan_review.json">JSON</a>
  </p>
  <div class="cards">{cards}</div>
  <ul>{notes}</ul>
  <table>
    <thead>
      <tr><th>helper/target</th><th>기술</th><th>frame/schedule</th><th>counter/random</th><th>motion/transform</th><th>renderer action</th></tr>
    </thead>
    <tbody>{''.join(trs)}</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()
