#!/usr/bin/env python3
"""Build a focused report for 0x2d motion-boundary-loop target renderers.

This is a static EXE-derived review.  It does not run the game.  The goal is to
separate the remaining target renderer gap into concrete, inspectable pieces:
which child target script is spawned, which 0x2d motion mode it uses, and which
branch bounds terminate the child loop.
"""

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"

PLAN_JSON = OUT / "battle_child_target_renderer_plan_review.json"
POSITION_JSON = OUT / "battle_helper_position_motion_review.json"
VISUAL_JSON = OUT / "battle_helper_visual_behavior_review.json"
GAP_JSON = OUT / "battle_child_target_renderer_gap_review.json"

OUT_JSON = OUT / "battle_motion_boundary_loop_review.json"


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


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


def fixed16_signed(value: str | None) -> float | None:
    if not value:
        return None
    number = int(str(value), 16)
    if number & 0x80000000:
        number -= 0x100000000
    return number / 65536


def compact(values: list[Any] | tuple[Any, ...], limit: int = 10) -> 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 index_by_helper(rows: list[dict[str, Any]]) -> dict[int, dict[str, Any]]:
    indexed: dict[int, dict[str, Any]] = {}
    for row in rows:
        try:
            indexed[int(row.get("helperId"))] = row
        except (TypeError, ValueError):
            continue
    return indexed


def find_target_scope(helper_row: dict[str, Any] | None, target_va: str) -> dict[str, Any]:
    if not helper_row:
        return {}
    target = target_va.lower()
    for scope in helper_row.get("scopes") or []:
        if target in str(scope.get("scope") or "").lower():
            return scope
    return {}


def visual_events_for_target(visual_row: dict[str, Any] | None, target_va: str) -> list[dict[str, Any]]:
    if not visual_row:
        return []
    target = target_va.lower()
    return [
        event
        for event in ((visual_row.get("directSpawn") or {}).get("events") or [])
        if str(event.get("targetVaHex") or "").lower() == target
    ]


def branch_role(branch: dict[str, Any]) -> str:
    threshold = fixed16_signed(branch.get("immHex"))
    if threshold == 480:
        return "screen-bottom-continue-bound"
    if threshold == -128:
        return "offscreen-top-despawn-bound"
    if "+0x90" in str(branch.get("summary") or ""):
        return "local-repeat-countdown"
    return "branch"


def normalized_branch(branch: dict[str, Any]) -> dict[str, Any]:
    return {
        "vaHex": branch.get("vaHex"),
        "opcode": branch.get("opcode"),
        "comparison": branch.get("comparison"),
        "immHex": branch.get("immHex"),
        "thresholdPx": fixed16_signed(branch.get("immHex")),
        "targetVaHex": branch.get("targetVaHex") or branch.get("branchTargetVaHex"),
        "role": branch_role(branch),
        "summary": branch.get("summary"),
        "confidence": branch.get("op14LayoutConfidence") or branch.get("modeConfidence"),
    }


def write_summary(write: dict[str, Any]) -> dict[str, Any]:
    return {
        "vaHex": write.get("vaHex"),
        "destHex": write.get("destHex"),
        "sourceHex": write.get("sourceHex"),
        "modeHex": write.get("modeHex"),
        "operation": write.get("operation"),
        "immFixed": write.get("immFixed"),
        "summary": write.get("summary"),
        "confidence": write.get("modeConfidence"),
    }


def event_schedule(events: list[dict[str, Any]]) -> dict[str, Any]:
    ticks = [event.get("tick") for event in events if event.get("tick") is not None]
    frames = sorted({event.get("frame") for event in events if event.get("frame") is not None})
    deltas = [b - a for a, b in zip(ticks, ticks[1:]) if isinstance(a, int) and isinstance(b, int)]
    return {
        "eventCount": len(events),
        "frames": frames,
        "ticks": ticks,
        "tickDeltas": deltas,
        "uniformTickDelta": deltas[0] if deltas and all(delta == deltas[0] for delta in deltas) else None,
        "firstTick": ticks[0] if ticks else None,
        "lastTick": ticks[-1] if ticks else None,
    }


def expanded_preview_count(plan_row: dict[str, Any], schedule: dict[str, Any]) -> int:
    plan_schedule = plan_row.get("spawnSchedule") or {}
    root = plan_schedule.get("rootCounterSets") or {}
    target_events = int(schedule.get("eventCount") or plan_schedule.get("eventCountForTarget") or 0)
    root_repeat = int(root.get("0x96") or 1)
    phase_span = int(root.get("0x94") or target_events or 0)
    if root_repeat > 1 and target_events > 1:
        return min(target_events, phase_span) * root_repeat
    return target_events


def root_lifecycle(visual_row: dict[str, Any] | None) -> dict[str, Any]:
    if not visual_row:
        return {}
    direct = visual_row.get("directSpawn") or {}
    events = direct.get("events") or []
    target_counts = direct.get("targetVaCounts") or {}
    return {
        "behaviorClass": visual_row.get("behaviorClass"),
        "directSpawnCount": direct.get("count"),
        "frameTimeline": direct.get("frameTimeline") or [],
        "ticks": direct.get("ticks") or [],
        "targetVaCounts": target_counts,
        "targetStopReasons": direct.get("targetStopReasons") or [],
        "transformPatternCounts": direct.get("transformPatternCounts") or {},
        "coordinateSummaries": direct.get("coordinateSummaries") or [],
        "notes": visual_row.get("notes") or [],
        "syncSignals": visual_row.get("syncSignals") or [],
    }


def build_row(
    plan_row: dict[str, Any],
    position_by_helper: dict[int, dict[str, Any]],
    visual_by_helper: dict[int, dict[str, Any]],
) -> dict[str, Any]:
    helper_id = int(plan_row.get("helperId"))
    target_va = str(plan_row.get("targetVaHex"))
    position_row = position_by_helper.get(helper_id)
    visual_row = visual_by_helper.get(helper_id)
    scope = find_target_scope(position_row, target_va)
    events = visual_events_for_target(visual_row, target_va)
    branches = [normalized_branch(item) for item in scope.get("branchRows") or []]
    motion_steps = scope.get("motionSteps") or plan_row.get("motionSteps") or []
    schedule = event_schedule(events)
    plan_schedule = plan_row.get("spawnSchedule") or {}
    mode_counts = Counter(str(step.get("modeHex")) for step in motion_steps if step.get("modeHex"))
    axis_counts = Counter(axis for step in motion_steps for axis in (step.get("axes") or []))
    offset = {}
    if events:
        offset = events[0].get("previewTransform") or {}
    elif plan_row.get("positionWrites"):
        for write in plan_row.get("positionWrites") or []:
            if write.get("destHex") == "0x1c":
                offset["offsetX"] = -float(write.get("immFixed") or 0)
            if write.get("destHex") == "0x20":
                offset["offsetY"] = -float(write.get("immFixed") or 0)

    local_repeat_branches = [branch for branch in branches if branch.get("role") == "local-repeat-countdown"]
    y_branches = [branch for branch in branches if branch.get("role") != "local-repeat-countdown"]
    projection_status = "x-only-absolute-trig-with-y-boundary" if mode_counts == Counter({"0x09": len(motion_steps)}) else "review"
    return {
        "helperId": helper_id,
        "targetVaHex": target_va,
        "skillLabels": plan_row.get("skillLabels") or [],
        "rendererPlanClass": plan_row.get("rendererPlanClass"),
        "rendererAction": plan_row.get("rendererAction"),
        "frames": plan_row.get("frames") or [],
        "spawnSchedule": {
            **schedule,
            "rootCounterSets": plan_schedule.get("rootCounterSets") or {},
            "rootSpawnJitterX": plan_schedule.get("rootSpawnJitterX"),
            "expandedPreviewCount": expanded_preview_count(plan_row, schedule),
        },
        "rootLifecycle": root_lifecycle(visual_row),
        "targetScope": scope.get("scope"),
        "targetCoordinateSummary": scope.get("coordinateSummary"),
        "previewTransform": offset,
        "positionWrites": [write_summary(write) for write in (scope.get("positionWrites") or plan_row.get("positionWrites") or [])],
        "motionWrites": [write_summary(write) for write in (scope.get("motionWrites") or plan_row.get("motionWrites") or [])],
        "motionSteps": motion_steps,
        "motionModeCounts": dict(sorted(mode_counts.items())),
        "motionAxisCounts": dict(sorted(axis_counts.items())),
        "boundaryBranches": y_branches,
        "localRepeatBranches": local_repeat_branches,
        "projectionModel": {
            "status": projection_status,
            "opcode": "0x2d",
            "handlerVaHex": "0x00405a09",
            "modeHex": "0x09",
            "modeMeaning": "absolute-base x-axis trig motion",
            "xFormula": "display.x = display.+0x80 + trigX(display.+0x8c) * signed(display.+0x92)",
            "yFormula": "mode 0x09 does not update display.y; display.y is only checked by boundary branches",
            "baseFields": {"+0x80": "cached x from +0x1c", "+0x84": "cached y from +0x20"},
            "phaseField": "+0x8c, usually copied/added from +0x8e immediately before 0x2d",
            "amplitudeField": "+0x92 for x-axis motion",
            "rootCounterModel": {
                "+0x94": "inner burst/phase span copied to +0x8e before repeated child spawns",
                "+0x96": "outer repeat countdown for additional burst cycles",
                "+0x92": "outer-cycle amplitude accumulator; incremented as +0x96 steps down",
            },
            "rootJitterX": plan_schedule.get("rootSpawnJitterX"),
            "boundaryPx": {"bottomExclusive": 480, "topDespawn": -128},
            "implementationHint": (
                "Use +0x94 as the inner burst span and +0x96 as the outer repeat count when both are present. "
                "For each child, start from the previewTransform offset plus root x-jitter, cache +0x80/+0x84, "
                "apply the x-only 0x2d formula per child tick, and remove it when the y branch range fails. "
                "Rows with +0x90 local repeat branches also keep a short inner countdown before despawn."
            ),
            "confidence": {
                "opcodeFormula": "confirmed-from-handler",
                "modeAndAxes": "confirmed-from-decoded-script",
                "yBoundaryValues": "confirmed-fixed16-immediates",
                "phaseAmplitudeCounters": "confirmed-root-counter-writes",
                "exactRuntimeRngSamples": "partial; root x-jitter range is known but exact samples depend on RNG seed",
            },
        },
    }


def build_report() -> dict[str, Any]:
    plan = load_json(PLAN_JSON)
    position = load_json(POSITION_JSON)
    visual = load_json(VISUAL_JSON)
    gap = load_json(GAP_JSON)
    position_by_helper = index_by_helper(position.get("helperRows") or [])
    visual_by_helper = index_by_helper(visual.get("rows") or [])
    rows = [
        build_row(row, position_by_helper, visual_by_helper)
        for row in plan.get("rows") or []
        if row.get("rendererPlanClass") == "motion-boundary-loop"
    ]
    helper_counts = Counter(row["helperId"] for row in rows)
    mode_counts: Counter[str] = Counter()
    axis_counts: Counter[str] = Counter()
    threshold_counts: Counter[str] = Counter()
    status_counts: Counter[str] = Counter()
    for row in rows:
        mode_counts.update(row["motionModeCounts"])
        axis_counts.update(row["motionAxisCounts"])
        status_counts.update([row["projectionModel"]["status"]])
        for branch in row["boundaryBranches"]:
            threshold_counts.update([str(branch.get("thresholdPx"))])
    return {
        "version": 1,
        "kind": "hwanse-battle-motion-boundary-loop-review",
        "source": "tools/build_battle_motion_boundary_loop_review.py",
        "runtimeUsed": False,
        "inputs": [
            str(PLAN_JSON.relative_to(ROOT)),
            str(POSITION_JSON.relative_to(ROOT)),
            str(VISUAL_JSON.relative_to(ROOT)),
            str(GAP_JSON.relative_to(ROOT)),
        ],
        "status": "motion-boundary-loop-semantics-grounded",
        "summary": {
            "motionBoundaryRows": len(rows),
            "helpers": sorted(helper_counts),
            "helperRowCounts": dict(sorted(helper_counts.items())),
            "motionModeCounts": dict(sorted(mode_counts.items())),
            "motionAxisCounts": dict(sorted(axis_counts.items())),
            "branchThresholdPxCounts": dict(sorted(threshold_counts.items())),
            "projectionStatusCounts": dict(sorted(status_counts.items())),
            "remainingGapBeforeThisReview": (gap.get("summary") or {}).get("remainingGapCounts") or {},
        },
        "opcode2dSemantics": {
            "handlerVaHex": "0x00405a09",
            "relativeGroup0x00": [
                "if mode&0x01: display.x += trigX(+0x8c) * signed(+0x92)",
                "if mode&0x02: display.y -= trigY(+0x8e) * signed(+0x94)",
                "if mode&0x04: display.z(+0x24) += trigY(+0x90) * signed(+0x96)",
            ],
            "absoluteGroup0x08": [
                "if mode&0x01: display.x = +0x80 + trigX(+0x8c) * signed(+0x92)",
                "if mode&0x02: display.y = +0x84 - trigY(+0x8e) * signed(+0x94)",
                "if mode&0x04: display.z(+0x24) = +0x88 + trigY(+0x90) * signed(+0x96)",
            ],
            "currentRowsMode": "0x09 = absolute group 0x08 + x axis bit 0x01",
            "currentRowsFormula": "display.x = +0x80 + trigX(+0x8c) * signed(+0x92); display.y unchanged by 0x2d",
            "trigXHelperVaHex": "0x00428070",
            "trigYHelperVaHex": "0x0042819b",
        },
        "interpretationNotes": [
            "The seven remaining target renderer gap rows are not unknown frame selections. They are child particles using already decoded direct frames plus a common opcode 0x2d motion loop.",
            "All seven rows use mode 0x09, so the child motion step updates x only from cached base +0x80 and phase/amplitude fields. The y coordinate is still important because the child loop tests y against 480 and -128 fixed16 bounds.",
            "Helpers 95~98 are the 암각·영상승룡파/bingjo-family target renderer stream. Helper 95 emits one target every two ticks; helpers 96~98 emit one leading target and then a denser repeated child stream.",
            "This resolves the semantic shape of apply-motion-step-until-branch. What remains before pixel-perfect rendering is exact phase/amplitude initialization for every child instance, especially parent/root RNG-fed fields.",
        ],
        "rows": rows,
    }


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


def html_page(report: dict[str, Any]) -> str:
    cards = "".join(
        f"<div class='card'><b>{esc(key)}</b>{small_json(value)}</div>"
        for key, value in report["summary"].items()
    )
    notes = "".join(f"<li>{esc(note)}</li>" for note in report["interpretationNotes"])
    rows_html = []
    for row in report["rows"]:
        branches = [
            f"{branch.get('role')} {branch.get('comparison')} {branch.get('thresholdPx')}px -> {branch.get('targetVaHex')}"
            for branch in row["boundaryBranches"]
        ]
        repeats = [
            f"{branch.get('comparison')} {branch.get('summary')}"
            for branch in row["localRepeatBranches"]
        ]
        rows_html.append(
            "<tr>"
            f"<td><b>#{row['helperId']}</b><br><code>{esc(row['targetVaHex'])}</code><br>{esc(row['targetScope'])}</td>"
            f"<td>{'<br>'.join(esc(label) for label in row['skillLabels'][:5])}</td>"
            f"<td>{small_json(row['spawnSchedule'])}</td>"
            f"<td><code>{esc(row['projectionModel']['currentRowsFormula'] if 'currentRowsFormula' in row['projectionModel'] else row['projectionModel']['xFormula'])}</code><br>"
            f"<span class='muted'>{esc(row['projectionModel']['yFormula'])}</span></td>"
            f"<td>{'<br>'.join(esc(item) for item in branches) or '<span class=\"muted\">-</span>'}"
            f"{('<hr>' + '<br>'.join(esc(item) for item in repeats)) if repeats else ''}</td>"
            f"<td>{small_json(row['previewTransform'])}</td>"
            f"<td>{small_json(row['motionWrites'])}</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 Motion Boundary Loop 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; }}
    code {{ color: #ffd58c; }}
    .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-wrap {{ overflow-x: auto; border: 1px solid #2c3444; border-radius: 8px; }}
    table {{ width: 100%; border-collapse: collapse; min-width: 1160px; }}
    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; }}
    hr {{ border: 0; border-top: 1px solid #2c3444; margin: 8px 0; }}
  </style>
</head>
<body>
  <h1>Battle Motion Boundary Loop Review</h1>
  <p class="muted">target renderer plan에서 남은 motion-boundary-loop 7개를 opcode 0x2d 좌표식과 branch 경계 기준으로 재분류한 정적 분석입니다.</p>
  <p>
    <a href="../web/index.html">index</a> ·
    <a href="battle_motion_boundary_loop_review.json">JSON</a> ·
    <a href="battle_child_target_renderer_gap_review.json">previous gap JSON</a> ·
    <a href="battle_child_target_renderer_plan_review.json">target renderer plan JSON</a>
  </p>
  <div class="cards">{cards}</div>
  <section class="card">
    <h2>0x2d Semantics</h2>
    {small_json(report["opcode2dSemantics"])}
  </section>
  <section class="card">
    <h2>Interpretation</h2>
    <ul>{notes}</ul>
  </section>
  <div class="table-wrap">
    <table>
      <thead><tr><th>helper/target</th><th>skills</th><th>spawn schedule</th><th>motion formula</th><th>branches</th><th>preview transform</th><th>motion writes</th></tr></thead>
      <tbody>{''.join(rows_html)}</tbody>
    </table>
  </div>
</body>
</html>
"""


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


if __name__ == "__main__":
    main()
