#!/usr/bin/env python3
"""Build a focused proof report for the mode-4 sparkle effect.

The visible sparkle on 맹호스페셜 신기's final pierce is not encoded as a
normal 0xbd helper id. It is reached through the 0xbc movement opcode with
byte1/mode 4.  The same mode-4 branch is reused by a small number of other
player actions, so this report records the shared branch and the concrete
맹호스페셜 call site separately.
"""

from __future__ import annotations

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

from build_battle_display_vm_static_decode import EXE, decode_display_init_records, read_sections, walk_script
from build_battle_hishokaku_effect_review import effect_rect_frames
from decode_cns import decompress_cns, parse_image
from probe_exe_scene_tables import va_to_offset


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

MODE4_BRANCH_VA = 0x004B5B4C
MODE4_CHILD_VA = 0x004B5138
MODE4_CHILD_CONTEXT_VA = 0x004B517C
BTL_EFC_CNS = ROOT / "extract_fld" / "btl_efc.cns"


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


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


def compact(values: list[Any], limit: int = 12) -> str:
    items = [str(value) for value in values]
    if len(items) > limit:
        items = items[:limit] + [f"... +{len(values) - limit}"]
    return ", ".join(items) if items else "-"


def call_rows() -> list[dict[str, Any]]:
    pattern = load_json("battle_skill_complete_pattern_review.json")
    rows: list[dict[str, Any]] = []
    for row in pattern.get("rows") or []:
        events = row.get("timelineEvents") or []
        mode4_events = [
            event for event in events
            if event.get("kind") == "movement" and int(event.get("movementMode") or 0) == 4
        ]
        if not mode4_events:
            continue
        for movement in mode4_events:
            tick = int(movement.get("tick") or 0)
            payload_hit_count = int(row.get("hitCount") or 0)
            result_sound_count = len(row.get("resultWlkNos") or [])
            timeline_payload_mismatch = (
                payload_hit_count > 0
                and result_sound_count > payload_hit_count
                and row.get("skillName") == "열화폭염권"
            )
            visible_sparkle = (
                (
                    row.get("ownerKey") == "ataho"
                    and row.get("skillName") == "맹호스페셜"
                    and int(row.get("levelOrFixed") or 0) == 4
                )
                or (
                    row.get("ownerKey") == "rinshan"
                    and row.get("skillName") == "선렬각"
                    and int(row.get("levelOrFixed") or 0) == 4
                )
            ) and not timeline_payload_mismatch
            sound = next(
                (
                    event for event in events
                    if event.get("kind") == "result-sound" and int(event.get("tick") or -1) == tick
                ),
                None,
            )
            actor_frame = next(
                (
                    event for event in events
                    if event.get("kind") == "actor-frame" and int(event.get("tick") or -1) == tick
                ),
                None,
            )
            rows.append(
                {
                    "ownerKey": row.get("ownerKey"),
                    "ownerName": row.get("ownerName") or row.get("ownerLabel"),
                    "skillName": row.get("skillName"),
                    "skillIdHex": row.get("skillIdHex"),
                    "level": row.get("levelOrFixed"),
                    "key": f"{row.get('ownerKey')}:{str(row.get('skillIdHex')).lower()}:{row.get('levelOrFixed')}",
                    "tick": tick,
                    "movementOpcode": movement,
                    "resultSoundAtSameTick": sound,
                    "actorFrameAtSameTick": actor_frame,
                    "payloadHitCount": payload_hit_count,
                    "resultSoundCount": result_sound_count,
                    "timelinePayloadMismatch": timeline_payload_mismatch,
                    "visibleSparklePromoted": visible_sparkle,
                    "visibilityClass": (
                        "visible-mode4-sparkle"
                        if visible_sparkle
                        else "review-only-display-timeline-mapping-collision"
                        if timeline_payload_mismatch
                        else "review-only-mode4-nonpromoted"
                    ),
                    "isMoukoSpecialFinalSparkle": (
                        row.get("ownerKey") == "ataho"
                        and row.get("skillName") == "맹호스페셜"
                        and int(row.get("levelOrFixed") or 0) == 4
                    ),
                }
            )
    return rows


def script_walk(start_va: int, max_steps: int = 180) -> dict[str, Any]:
    data = EXE.read_bytes()
    sections = read_sections(data)
    return walk_script(data, sections, start_va, 0, max_steps=max_steps)


def frame_stream() -> list[dict[str, Any]]:
    walked = script_walk(MODE4_CHILD_CONTEXT_VA, max_steps=40)
    rects = {frame["frame"]: frame for frame in effect_rect_frames([30, 31, 32, 33, 34, 35, 36, 37])}
    out: list[dict[str, Any]] = []
    tick = 0
    for frame in walked.get("frames") or []:
        frame_no = int(frame.get("frame") or 0)
        gate = int(frame.get("gate") or 1)
        out.append(
            {
                "asset": "btl_efc",
                "cns": "btl_efc.cns",
                "spriteHex": frame.get("spriteHex"),
                "frame": frame_no,
                "gate": gate,
                "relativeTick": tick,
                "vaHex": frame.get("vaHex"),
                "rect": (rects.get(frame_no) or {}).get("rect"),
            }
        )
        tick += max(1, gate)
    return out


def read_exe_slice(va: int, size: int) -> bytes:
    data = EXE.read_bytes()
    sections = read_sections(data)
    offset = va_to_offset(sections, va)
    if offset is None:
        return b""
    return data[offset:offset + size]


def signed_fixed16(value: int | None) -> float | None:
    if value is None:
        return None
    if value & 0x80000000:
        value -= 0x100000000
    return value / 65536


def child_init_record_review() -> list[dict[str, Any]]:
    raw = read_exe_slice(MODE4_CHILD_VA, 80)
    records = decode_display_init_records(raw)
    out: list[dict[str, Any]] = []
    for row in records:
        field = row.get("fieldOffset")
        value = row.get("value")
        meaning = ""
        if field == 0x00:
            meaning = "display object flag field; common 0x10000000 effect object bit"
        elif field == 0x14:
            meaning = "draw/lifecycle flag word; 0x0110 appears on depth-offset child effects"
        elif field == 0x2C:
            meaning = "effect object enable/layer flag; common value 1"
        elif field == 0x28:
            meaning = "sprite/frame selector; here one btl_efc sprite frame only"
        elif field == 0x24:
            meaning = "fixed16 z/depth offset candidate; 0xfc180000 = -1000px"
        out.append(
            {
                **row,
                "signedFixed16": signed_fixed16(value) if isinstance(value, int) and field in {0x24} else None,
                "fieldMeaning": meaning,
            }
        )
    return out


def cns_index_matrix() -> tuple[int, int, list[list[int]]]:
    decoded = decompress_cns(BTL_EFC_CNS.read_bytes())
    width, height, _palette, pixels, bpp = parse_image(decoded)
    stride = ((width * bpp + 31) // 32) * 4
    matrix: list[list[int]] = []
    for y in range(height):
        row = pixels[(height - 1 - y) * stride:(height - y) * stride]
        if bpp == 8:
            indices = list(row[:width])
        elif bpp == 4:
            indices = []
            for byte in row[: (width + 1) // 2]:
                indices.append(byte >> 4)
                if len(indices) < width:
                    indices.append(byte & 0x0F)
        else:
            raise ValueError(f"unsupported bpp {bpp}")
        matrix.append(indices)
    return width, height, matrix


def connected_components(mask: list[list[bool]]) -> list[dict[str, Any]]:
    h = len(mask)
    w = len(mask[0]) if h else 0
    seen = [[False] * w for _ in range(h)]
    comps: list[dict[str, Any]] = []
    for y in range(h):
        for x in range(w):
            if seen[y][x] or not mask[y][x]:
                continue
            stack = [(x, y)]
            seen[y][x] = True
            xs: list[int] = []
            ys: list[int] = []
            while stack:
                cx, cy = stack.pop()
                xs.append(cx)
                ys.append(cy)
                for nx, ny in ((cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)):
                    if 0 <= nx < w and 0 <= ny < h and not seen[ny][nx] and mask[ny][nx]:
                        seen[ny][nx] = True
                        stack.append((nx, ny))
            comps.append(
                {
                    "pixelCount": len(xs),
                    "bounds": {
                        "x": min(xs),
                        "y": min(ys),
                        "w": max(xs) - min(xs) + 1,
                        "h": max(ys) - min(ys) + 1,
                    },
                }
            )
    comps.sort(key=lambda item: (-int(item["pixelCount"]), item["bounds"]["y"], item["bounds"]["x"]))
    return comps


def frame_fragment_review(stream: list[dict[str, Any]]) -> list[dict[str, Any]]:
    _width, _height, matrix = cns_index_matrix()
    out: list[dict[str, Any]] = []
    for frame in stream:
        rect = frame.get("rect") or {}
        x = int(rect.get("x") or 0)
        y = int(rect.get("y") or 0)
        w = int(rect.get("w") or 0)
        h = int(rect.get("h") or 0)
        sub = [row[x:x + w] for row in matrix[y:y + h]]
        mask = [[idx != 0 for idx in row] for row in sub]
        nonzero = sum(1 for row in mask for value in row if value)
        comps = connected_components(mask)
        out.append(
            {
                "frame": frame.get("frame"),
                "rect": rect,
                "nonTransparentPixelCount": nonzero,
                "componentCount": len(comps),
                "components": comps,
                "visualFragmentNote": (
                    "single EXE child sprite frame; apparent multiple spark fragments are pixels/components inside this frame"
                    if len(comps) > 1
                    else "single EXE child sprite frame"
                ),
            }
        )
    return out


def expanded_mode4_frame_stream(
    stream: list[dict[str, Any]],
    base_tick: int,
    target_va_hex: str | None,
    burst_count: int,
    repeat_count: int,
) -> list[dict[str, Any]]:
    expanded: list[dict[str, Any]] = []
    for burst_index in range(max(1, burst_count)):
        for frame in stream:
            relative_tick = int(frame.get("relativeTick") or 0)
            expanded.append(
                {
                    **frame,
                    "actorTick": base_tick + relative_tick,
                    "spawnRelativeTick": 0,
                    "source": "mouko-special-sparkle-proof/0xbc-mode4-child-context",
                    "targetVaHex": target_va_hex,
                    "visualBehaviorClass": "mode4-sparkle-multi-child-framescript",
                    "animationClass": "mode4-sparkle-multi-child-framescript",
                    "effectAnimationClass": "mode4-sparkle-multi-child-framescript",
                    "visibleCandidate": True,
                    "visibilityBasis": (
                        "0xbc mode 4 branch spawns 0x004b5138 child; "
                        "child context 0x004b517c emits btl_efc#30..37 gate 2; "
                        "0x004b5cac repeat count expands the same child body"
                    ),
                    "burstIndex": burst_index,
                    "burstOrdinal": burst_index + 1,
                    "effectBurstCount": burst_count,
                    "repeatLoopCount": repeat_count,
                    # 0x004b5c8c/90 adds +0x68/+0x6c before the child display
                    # coordinates are copied, so each loop advances one target-side
                    # step from the preceding burst.
                    "pathStepMultiplier": burst_index + 1,
                    "pathProgress": (burst_index + 1) / max(1, burst_count),
                    "transformScope": {
                        "status": "EXE branch formula, runtime RNG seed not fixed",
                        "base": "x=actor.+0x1c+(e6-2)*4, y=actor.+0x20+(e7-2)*4",
                        "pathStep": "+0x68=+0x74/4, +0x6c=+0x78/4, added once before each child coordinate copy",
                        "scatter": "0x2b(0x3000)-0x1800 angle bias + 0x2b(0x0060)+0x30 amplitude, applied through 0x2d trig and /16",
                    },
                }
            )
    return expanded


def mode4_evidence() -> dict[str, Any]:
    branch = script_walk(MODE4_BRANCH_VA, max_steps=120)
    child = script_walk(MODE4_CHILD_VA, max_steps=40)
    context = script_walk(MODE4_CHILD_CONTEXT_VA, max_steps=40)
    stream = frame_stream()
    return {
        "mode4BranchStartVaHex": f"0x{MODE4_BRANCH_VA:08x}",
        "childStartVaHex": f"0x{MODE4_CHILD_VA:08x}",
        "childContextVaHex": f"0x{MODE4_CHILD_CONTEXT_VA:08x}",
        "spawnTargetsVaHex": sorted(
            {
                row.get("targetVaHex")
                for row in branch.get("rows") or []
                if row.get("category") == "spawn-child-vm" and row.get("targetVaHex")
            }
        ),
        "repeatLoops": branch.get("repeatLoops") or [],
        "branchRowsOfInterest": [
            {
                "vaHex": row.get("vaHex"),
                "opcode": row.get("opcode"),
                "category": row.get("category"),
                "summary": row.get("summary"),
            }
            for row in branch.get("rows") or []
            if row.get("category") in {
                "spawn-child-vm",
                "repeat-loop",
                "trig-motion",
                "effect-resource-handle",
                "movement-update",
            }
        ],
        "childInitRows": [
            {
                "vaHex": row.get("vaHex"),
                "opcode": row.get("opcode"),
                "category": row.get("category"),
                "summary": row.get("summary"),
                "initSpriteFrames": row.get("initSpriteFrames"),
            }
            for row in child.get("rows") or []
        ],
        "contextFrameRows": [
            {
                "vaHex": row.get("vaHex"),
                "opcode": row.get("opcode"),
                "category": row.get("category"),
                "summary": row.get("summary"),
                "spriteHex": row.get("spriteHex"),
                "frame": row.get("frame"),
                "gate": row.get("gate"),
            }
            for row in context.get("rows") or []
            if row.get("category") == "frame"
        ],
        "frameStream": stream,
        "childInitRecordReview": child_init_record_review(),
        "frameFragmentReview": frame_fragment_review(stream),
    }


def build() -> dict[str, Any]:
    rows = call_rows()
    evidence = mode4_evidence()
    stream = evidence.get("frameStream") or []
    repeat_count = 0
    for loop in evidence.get("repeatLoops") or []:
        repeat_count = max(repeat_count, int(loop.get("repeatCount") or loop.get("count") or 0))
    # The battle action timeline expander has already grounded opcode 0x06 as
    # total body executions, not "one original plus count repeats".  Keep this
    # report consistent with that EXE interpretation: count=4 means four child
    # bodies in total.
    burst_count = max(1, repeat_count)
    for row in rows:
        if row.get("visibleSparklePromoted"):
            row["effectFrameStream"] = expanded_mode4_frame_stream(
                stream,
                int(row.get("tick") or 0),
                (row.get("movementOpcode") or {}).get("vaHex"),
                burst_count,
                repeat_count,
            )
            row["repeatLoopCount"] = repeat_count
            row["effectBurstCount"] = burst_count
            row["effectFrameLabels"] = [
                f"b{frame.get('burstOrdinal')}:{frame.get('frame')}@{frame.get('gate')}"
                for frame in row["effectFrameStream"]
            ]
            row["mode4TransformFormula"] = {
                "base": "x=actor.+0x1c+(e6-2)*4, y=actor.+0x20+(e7-2)*4",
                "pathStep": "+0x68=+0x74/4, +0x6c=+0x78/4, added before every spawned child",
                "scatter": "angle=(0x2b(0x3000)-0x1800)+child.+0x90; amplitude=0x2b(0x0060)+0x30; 0x2d writes x/y then /16",
                "spawnTotal": burst_count,
                "repeatLoopCount": repeat_count,
                "repeatLoopSemantics": "0x06 count is total body executions; count=4 => four child spawns total",
            }
    return {
        "version": 1,
        "kind": "battle-mouko-special-sparkle-review",
        "status": "static-exe-proof-for-0xbc-mode4-sparkle-child-effect",
        "source": [
            "out/battle_skill_complete_pattern_review.json",
            "Hwanse2.exe display VM script 0x004b5b4c/0x004b5138/0x004b517c",
            "out/cns_frame_rect_exe_scan.json",
        ],
        "summary": {
            "mode4CallCount": len(rows),
            "moukoSpecialCall": next((row for row in rows if row["isMoukoSpecialFinalSparkle"]), None),
            "visibleSparkleCalls": [
                row for row in rows
                if row.get("visibleSparklePromoted")
            ],
            "reviewOnlyCalls": [
                row for row in rows
                if not row.get("visibleSparklePromoted")
            ],
            "sharedFrameSequence": [frame["frame"] for frame in evidence["frameStream"]],
            "sharedGateSequence": [frame["gate"] for frame in evidence["frameStream"]],
            "childSpawnRepeatCount": max(
                [int(loop.get("repeatCount") or 0) for loop in evidence.get("repeatLoops") or []] or [0]
            ),
            "childSpawnTotalCount": burst_count,
            "frameFragmentCounts": [
                {
                    "frame": row.get("frame"),
                    "components": row.get("componentCount"),
                    "pixels": row.get("nonTransparentPixelCount"),
                }
                for row in evidence.get("frameFragmentReview") or []
            ],
        },
        "interpretationNotes": [
            "맹호스페셜 신기 마지막 관통 타격은 0x004d69cc의 0xbc movementMode=4 selector=2 divisor=4를 호출한다.",
            "mode 4 분기는 0x004b5b4c에서 시작하고 0x004b5138 child display VM을 생성한다.",
            "child는 context 0x004b517c에서 btl_efc sprite 0x1a frame 30..37을 gate 2로 순차 재생한다.",
            "0x004b5cac repeat count=4는 전투 타임라인의 기존 0x06 해석과 동일하게 전체 body 실행 수다. 따라서 반짝이 child는 총 4개 spawn되는 계열이다.",
            "0x004b5b4c..0x004b5ca4는 actor 기준 좌표, target-side path step, 0x2b 난수, 0x2d 삼각 이동을 조합해 각 child 위치를 흩뿌린다.",
            "btl_efc #30..37은 16x16 child sprite frame sequence이며, EXE child 수는 총 4개다. 각 프레임 자체도 여러 비투명 조각을 포함할 수 있어 화면상으로는 child 수보다 많은 작은 반짝이 조각처럼 보인다.",
            "현재 EXE 근거는 16개 child를 만들지 않는다. 관찰된 '4개 단위 x 4 묶음'은 4 child spawn + btl_efc 프레임 내부 fragment의 합성으로 해석해야 한다.",
            "선렬각 신기도 같은 mode 4 child를 보이는 효과로 사용한다.",
            "열화폭염권 1단으로 라벨된 mode 4 행은 payload 1타와 display timeline 6타가 맞지 않으므로 visible sparkle로 승격하지 않는다. 이는 기존 display-script 매칭 충돌 후보로 분리한다.",
            "정확한 runtime RNG seed는 아직 고정하지 못했지만, 다중 child 수와 path/scatter 산식은 EXE branch row로 근거화했다.",
        ],
        "rows": rows,
        "mode4Evidence": evidence,
    }


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# 맹호스페셜 신기 반짝이 효과 분석",
        "",
        f"- status: `{report['status']}`",
        f"- mode4 calls: `{report['summary']['mode4CallCount']}`",
        f"- child spawn repeat: `{report['summary']['childSpawnRepeatCount']}`",
        f"- child spawn total: `{report['summary']['childSpawnTotalCount']}`",
        f"- frame stream: `{compact(report['summary']['sharedFrameSequence'])}`",
        f"- gate stream: `{compact(report['summary']['sharedGateSequence'])}`",
        "",
        "## 해석",
        "",
    ]
    lines += [f"- {note}" for note in report["interpretationNotes"]]
    lines += ["", "## mode4 사용 스킬", "", "| owner | skill | id | lv | tick | visibility | payload/result | opcode | sound | actor frame |", "|---|---|---:|---:|---:|---|---|---|---|---|"]
    for row in report["rows"]:
        sound = row.get("resultSoundAtSameTick") or {}
        actor = row.get("actorFrameAtSameTick") or {}
        movement = row.get("movementOpcode") or {}
        lines.append(
            "| "
            + " | ".join(
                [
                    str(row.get("ownerName")),
                    str(row.get("skillName")),
                    f"`{row.get('skillIdHex')}`",
                    str(row.get("level")),
                    str(row.get("tick")),
                    str(row.get("visibilityClass")),
                    f"{row.get('payloadHitCount')}/{row.get('resultSoundCount')}",
                    f"`{movement.get('vaHex')}` m{movement.get('movementMode')} s{movement.get('selector')} d{movement.get('divisor')}",
                    f"WLK {sound.get('wlkNo')} / alt {sound.get('altWlkNo')}" if sound else "-",
                    f"#{actor.get('frame')}@{actor.get('gate')}" if actor else "-",
                ]
            )
            + " |"
        )
    lines += ["", "## child frame stream", "", "| va | asset | frame | gate | rect |", "|---|---|---:|---:|---|"]
    for frame in report["mode4Evidence"]["frameStream"]:
        rect = frame.get("rect") or {}
        lines.append(
            f"| `{frame.get('vaHex')}` | `{frame.get('asset')}` | {frame.get('frame')} | {frame.get('gate')} | "
            f"{rect.get('x')},{rect.get('y')} {rect.get('w')}x{rect.get('h')} |"
        )
    lines += ["", "## child init records", "", "| record | field | value | fixed16 | meaning |", "|---:|---|---|---:|---|"]
    for row in report["mode4Evidence"].get("childInitRecordReview") or []:
        if row.get("kind") == "terminator":
            continue
        lines.append(
            f"| {row.get('index')} | `{row.get('fieldOffsetHex')}` | `{row.get('valueHex')}` | "
            f"{row.get('signedFixed16') if row.get('signedFixed16') is not None else '-'} | {row.get('fieldMeaning') or row.get('summary')} |"
        )
    lines += ["", "## frame fragment check", "", "| frame | non-transparent px | components | largest components |", "|---:|---:|---:|---|"]
    for row in report["mode4Evidence"].get("frameFragmentReview") or []:
        comps = row.get("components") or []
        compact_comps = ", ".join(
            f"{comp.get('pixelCount')}px@{comp.get('bounds', {}).get('x')},{comp.get('bounds', {}).get('y')}"
            for comp in comps[:6]
        )
        lines.append(
            f"| {row.get('frame')} | {row.get('nonTransparentPixelCount')} | {row.get('componentCount')} | {compact_comps or '-'} |"
        )
    return "\n".join(lines) + "\n"


def html_page(report: dict[str, Any]) -> str:
    call_rows = []
    for row in report["rows"]:
        movement = row.get("movementOpcode") or {}
        sound = row.get("resultSoundAtSameTick") or {}
        actor = row.get("actorFrameAtSameTick") or {}
        call_rows.append(
            "<tr>"
            f"<td>{esc(row.get('ownerName'))}</td>"
            f"<td>{esc(row.get('skillName'))}</td>"
            f"<td><code>{esc(row.get('skillIdHex'))}</code></td>"
            f"<td>{esc(row.get('level'))}</td>"
            f"<td>{esc(row.get('tick'))}</td>"
            f"<td>{esc(row.get('visibilityClass'))}</td>"
            f"<td>{esc(row.get('payloadHitCount'))}/{esc(row.get('resultSoundCount'))}</td>"
            f"<td><code>{esc(movement.get('vaHex'))}</code> m{esc(movement.get('movementMode'))}/s{esc(movement.get('selector'))}/d{esc(movement.get('divisor'))}</td>"
            f"<td>{'WLK ' + esc(sound.get('wlkNo')) + ' / alt ' + esc(sound.get('altWlkNo')) if sound else '-'}</td>"
            f"<td>{('#' + esc(actor.get('frame')) + '@' + esc(actor.get('gate'))) if actor else '-'}</td>"
            "</tr>"
        )
    frame_rows = []
    for frame in report["mode4Evidence"]["frameStream"]:
        rect = frame.get("rect") or {}
        frame_rows.append(
            "<tr>"
            f"<td><code>{esc(frame.get('vaHex'))}</code></td>"
            f"<td><code>{esc(frame.get('asset'))}</code></td>"
            f"<td>{esc(frame.get('frame'))}</td>"
            f"<td>{esc(frame.get('gate'))}</td>"
            f"<td>{esc(rect.get('x'))},{esc(rect.get('y'))} {esc(rect.get('w'))}x{esc(rect.get('h'))}</td>"
            "</tr>"
        )
    init_rows = []
    for row in report["mode4Evidence"].get("childInitRecordReview") or []:
        if row.get("kind") == "terminator":
            continue
        init_rows.append(
            "<tr>"
            f"<td>{esc(row.get('index'))}</td>"
            f"<td><code>{esc(row.get('fieldOffsetHex'))}</code></td>"
            f"<td><code>{esc(row.get('valueHex'))}</code></td>"
            f"<td>{esc(row.get('signedFixed16'))}</td>"
            f"<td>{esc(row.get('fieldMeaning') or row.get('summary'))}</td>"
            "</tr>"
        )
    fragment_rows = []
    for row in report["mode4Evidence"].get("frameFragmentReview") or []:
        comps = row.get("components") or []
        compact_comps = ", ".join(
            f"{comp.get('pixelCount')}px@{(comp.get('bounds') or {}).get('x')},{(comp.get('bounds') or {}).get('y')}"
            for comp in comps[:6]
        )
        fragment_rows.append(
            "<tr>"
            f"<td>{esc(row.get('frame'))}</td>"
            f"<td>{esc(row.get('nonTransparentPixelCount'))}</td>"
            f"<td>{esc(row.get('componentCount'))}</td>"
            f"<td>{esc(compact_comps)}</td>"
            "</tr>"
        )
    evidence_rows = [
        f"<li><code>{esc(row.get('vaHex'))}</code> {esc(row.get('category'))}: {esc(row.get('summary'))}</li>"
        for row in report["mode4Evidence"].get("branchRowsOfInterest") or []
    ]
    notes = "".join(f"<li>{esc(note)}</li>" for note in report["interpretationNotes"])
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>맹호스페셜 신기 반짝이 효과</title>
  <style>
    body {{ margin: 0; background: #f5f7fb; color: #20242b; font-family: system-ui, sans-serif; }}
    main {{ width: min(1180px, calc(100vw - 24px)); margin: 0 auto; padding: 18px 0 32px; }}
    h1 {{ margin: 0 0 8px; font-size: 25px; }}
    .panel {{ background: #fff; border: 1px solid #d8dee8; border-radius: 8px; padding: 14px; margin: 12px 0; }}
    table {{ width: 100%; border-collapse: collapse; background: #fff; }}
    th, td {{ border: 1px solid #d8dee8; padding: 8px; vertical-align: top; text-align: left; }}
    th {{ background: #eef2f7; }}
    code {{ background: #f8fafc; border: 1px solid #e0e4ec; border-radius: 4px; padding: 1px 4px; }}
    li {{ margin: 5px 0; }}
  </style>
</head>
<body>
<main>
  <h1>맹호스페셜 신기 반짝이 효과</h1>
  <section class="panel">
    <p><strong>status:</strong> <code>{esc(report['status'])}</code></p>
    <ul>{notes}</ul>
  </section>
  <section class="panel">
    <h2>mode4 사용 스킬</h2>
    <table>
      <thead><tr><th>owner</th><th>skill</th><th>id</th><th>lv</th><th>tick</th><th>visibility</th><th>payload/result</th><th>0xbc</th><th>sound</th><th>actor</th></tr></thead>
      <tbody>{"".join(call_rows)}</tbody>
    </table>
  </section>
  <section class="panel">
    <h2>child frame stream</h2>
    <table>
      <thead><tr><th>va</th><th>asset</th><th>frame</th><th>gate</th><th>rect</th></tr></thead>
      <tbody>{"".join(frame_rows)}</tbody>
    </table>
  </section>
  <section class="panel">
    <h2>child init records</h2>
    <table>
      <thead><tr><th>#</th><th>field</th><th>value</th><th>fixed16</th><th>meaning</th></tr></thead>
      <tbody>{"".join(init_rows)}</tbody>
    </table>
  </section>
  <section class="panel">
    <h2>frame fragment check</h2>
    <table>
      <thead><tr><th>frame</th><th>non-transparent px</th><th>components</th><th>largest components</th></tr></thead>
      <tbody>{"".join(fragment_rows)}</tbody>
    </table>
  </section>
  <section class="panel">
    <h2>branch evidence</h2>
    <ul>{"".join(evidence_rows)}</ul>
  </section>
</main>
</body>
</html>
"""


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


if __name__ == "__main__":
    main()
