#!/usr/bin/env python3
"""Build a focused review for status-window short-comment selector binding.

``build_status_menu_ui_expression_review`` already proves that the status
window short comments are real text tables.  This report keeps the next
question separate: which scene/resource data region owns each ``40 13 01``
comment group, and where should future consumer tracing start?
"""
from __future__ import annotations

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

sys.path.insert(0, str(Path(__file__).resolve().parent))

from build_map_animation_tile_review import load_scene_records, load_scene_resource_groups, parse_hex_va  # noqa: E402
from build_status_menu_ui_expression_review import (  # noqa: E402
    EXE,
    OUT,
    WEB,
    byte_hex,
    file_offset_to_va,
    hx,
    parse_status_comment_groups,
    pointer_refs,
)
from probe_exe_scene_tables import read_sections, va_to_offset  # noqa: E402


JSON_OUT = OUT / "status_comment_selector_binding_review.json"
HTML_OUT = WEB / "status_comment_selector_binding_review.html"
WEB_HTML_OUT = WEB / "status_comment_selector_binding_review.html"

STATUS_COMMENT_SELECTOR_ARRAY_VA = 0x004E8610
STATUS_COMMENT_SELECTOR_ARRAY_HEADER_VA = 0x004E85FC
STATUS_COMMENT_SELECTOR_RECORD_VA = 0x004E8608
STATUS_COMMENT_SELECTOR_ARRAY_COUNT = 9


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


def code(value: Any) -> str:
    return f"<code>{h(value)}</code>"


def dword_at_va(exe: bytes, sections: list[dict[str, Any]], va: int) -> int | None:
    off = va_to_offset(sections, va)
    if off is None or off + 4 > len(exe):
        return None
    return struct.unpack_from("<I", exe, off)[0]


def read_context(exe: bytes, sections: list[dict[str, Any]], va: int, before: int = 16, after: int = 24) -> dict[str, Any]:
    off = va_to_offset(sections, va)
    if off is None:
        return {"vaHex": hx(va), "rawHex": "", "ascii": ""}
    start = max(0, off - before)
    end = min(len(exe), off + after)
    raw = exe[start:end]
    ascii_text = "".join(chr(byte) if 0x20 <= byte <= 0x7E else "." for byte in raw)
    return {
        "vaHex": hx(va),
        "fileOffsetHex": hx(off),
        "rawHex": byte_hex(raw),
        "ascii": ascii_text,
    }


def pointer_refs_to_table(exe: bytes, sections: list[dict[str, Any]], table_va: int) -> list[dict[str, Any]]:
    refs = pointer_refs(exe, sections, table_va)
    rows: list[dict[str, Any]] = []
    for ref in refs:
        ref_va = parse_hex_va(ref.get("vaHex"))
        if ref_va is None:
            continue
        rows.append(
            {
                **ref,
                "distanceToTable": table_va - ref_va,
                "dwordAtRefHex": hx(dword_at_va(exe, sections, ref_va)),
                "context": read_context(exe, sections, ref_va),
            }
        )
    return rows


def read_u32_at_offset(exe: bytes, offset: int) -> int | None:
    if offset < 0 or offset + 4 > len(exe):
        return None
    return struct.unpack_from("<I", exe, offset)[0]


def slot_kind_at_va(exe: bytes, sections: list[dict[str, Any]], va: int, table_id: int) -> dict[str, Any]:
    off = va_to_offset(sections, va)
    if off is None:
        return {"kind": "invalid-pointer", "rawHex": ""}
    raw = exe[off : off + 16]
    if raw.startswith(bytes([0x40, 0x13, 0x01, table_id])):
        count = read_u32_at_offset(exe, off + 4)
        end_va = read_u32_at_offset(exe, off + 8) if count and count >= 1 else None
        return {
            "kind": "comment-table-marker",
            "count": count,
            "endVaHex": hx(end_va),
            "rawHex": byte_hex(raw),
        }
    if raw.startswith(b"\x40\x0a\x00\x00"):
        return {"kind": "empty-or-terminator-slot", "rawHex": byte_hex(raw)}
    return {"kind": "nonmarker-pointer-target", "rawHex": byte_hex(raw)}


def scan_status_comment_blocks(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    pattern = b"\x40\x1b\x00\x00\x40\x1f\x00\x00"
    rows: list[dict[str, Any]] = []
    offset = exe.find(pattern)
    while offset != -1:
        va = file_offset_to_va(sections, offset)
        if va is None:
            offset = exe.find(pattern, offset + 1)
            continue
        pointers = [read_u32_at_offset(exe, offset + 8 + index * 4) for index in range(3)]
        marker_offset = offset + 20
        marker = exe[marker_offset : marker_offset + 8]
        if len(marker) < 8 or not marker.startswith(b"\x40\x13\x01"):
            offset = exe.find(pattern, offset + 1)
            continue
        table_id = marker[3]
        marker_count = struct.unpack_from("<I", marker, 4)[0]
        slots = []
        for index, ptr in enumerate(pointers):
            if ptr is None:
                continue
            kind = slot_kind_at_va(exe, sections, ptr, table_id)
            slots.append(
                {
                    "slotIndex": index,
                    "slotLabel": f"slot-{index}",
                    "targetVaHex": hx(ptr),
                    **kind,
                }
            )
        rows.append(
            {
                "blockVaHex": hx(va),
                "blockFileOffsetHex": hx(offset),
                "tableId": table_id,
                "tableIdHex": hx(table_id, 2),
                "markerCount": marker_count,
                "markerRawHex": byte_hex(marker),
                "rawHex": byte_hex(exe[offset : offset + 32]),
                "slots": slots,
                "commentTableMarkerSlotCount": sum(1 for slot in slots if slot.get("kind") == "comment-table-marker"),
                "emptyOrTerminatorSlotCount": sum(1 for slot in slots if slot.get("kind") == "empty-or-terminator-slot"),
            }
        )
        offset = exe.find(pattern, offset + 1)
    return rows


def scan_status_comment_selector_array(
    exe: bytes,
    sections: list[dict[str, Any]],
    blocks_by_table_id: dict[str, dict[str, Any]],
) -> dict[str, Any]:
    """Decode the 9-entry status-comment block selector array.

    The table is not referenced by a normal x86 pointer.  It is embedded inside
    the status/menu VM payload immediately after ``40 1b 01 00`` and a
    ``40 14 03 00 09 00 00 00`` count record.  The exact flag/index producer
    is still unknown, but this array grounds the block ordering used by the
    status short-comment subsystem.
    """
    header_context = read_context(exe, sections, STATUS_COMMENT_SELECTOR_ARRAY_HEADER_VA, before=0, after=0x48)
    off = va_to_offset(sections, STATUS_COMMENT_SELECTOR_ARRAY_VA)
    if off is None:
        return {
            "status": "selector-array-missing",
            "arrayVaHex": hx(STATUS_COMMENT_SELECTOR_ARRAY_VA),
            "entries": [],
            "headerContext": header_context,
        }
    entries: list[dict[str, Any]] = []
    block_by_va = {
        parse_hex_va(block.get("blockVaHex")): block
        for block in blocks_by_table_id.values()
        if parse_hex_va(block.get("blockVaHex")) is not None
    }
    for index in range(STATUS_COMMENT_SELECTOR_ARRAY_COUNT):
        ptr = read_u32_at_offset(exe, off + index * 4)
        block = block_by_va.get(ptr)
        entries.append(
            {
                "selectorIndex": index,
                "blockVaHex": hx(ptr),
                "tableIdHex": block.get("tableIdHex") if block else "",
                "markerSlotCount": block.get("commentTableMarkerSlotCount") if block else None,
                "emptySlotCount": block.get("emptyOrTerminatorSlotCount") if block else None,
                "blockMatched": bool(block),
            }
        )
    expected_ids = [f"0x{value:02x}" for value in range(0x77, 0x80)]
    found_ids = [entry.get("tableIdHex") for entry in entries]
    header_raw = header_context.get("rawHex", "")
    return {
        "status": "selector-array-grounded-entry-consumer-pending",
        "arrayVaHex": hx(STATUS_COMMENT_SELECTOR_ARRAY_VA),
        "headerVaHex": hx(STATUS_COMMENT_SELECTOR_ARRAY_HEADER_VA),
        "count": STATUS_COMMENT_SELECTOR_ARRAY_COUNT,
        "headerRawHex": header_raw,
        "headerInterpretation": "40 1b 01 00 opens the status/menu VM list; 40 14 03 00 09 00 00 00 is the inline 9-entry count before the block pointer array.",
        "entries": entries,
        "allBlocksMatched": all(entry["blockMatched"] for entry in entries),
        "expectedTableIdOrder": expected_ids,
        "foundTableIdOrder": found_ids,
        "normalXrefEvidence": "No direct x86 pointer reference to 0x004e8610 was found; the array is consumed as inline VM payload data.",
    }


def scan_selector_producer_probe(
    exe: bytes,
    sections: list[dict[str, Any]],
    selector_array: dict[str, Any],
) -> dict[str, Any]:
    """Record what kind of consumer proof exists for the selector array.

    This is intentionally narrow.  The selector array is inline VM payload, so
    a normal pointer xref is not expected.  The useful proof is that each status
    comment block is referenced by exactly one slot inside the 9-entry array.
    """
    anchor_refs = []
    for label, va in (
        ("payload header", STATUS_COMMENT_SELECTOR_ARRAY_HEADER_VA),
        ("40 14 record", STATUS_COMMENT_SELECTOR_RECORD_VA),
        ("entry array", STATUS_COMMENT_SELECTOR_ARRAY_VA),
    ):
        refs = pointer_refs(exe, sections, va)
        anchor_refs.append(
            {
                "label": label,
                "targetVaHex": hx(va),
                "pointerRefCount": len(refs),
                "sampleRefsHex": [row.get("vaHex") for row in refs[:8]],
            }
        )

    entry_refs = []
    entry_ref_outside_selector = 0
    selector_slots = {
        STATUS_COMMENT_SELECTOR_ARRAY_VA + index * 4
        for index in range(STATUS_COMMENT_SELECTOR_ARRAY_COUNT)
    }
    for entry in selector_array.get("entries", []):
        block_va = parse_hex_va(entry.get("blockVaHex"))
        if block_va is None:
            continue
        refs = pointer_refs(exe, sections, block_va)
        ref_vas = [parse_hex_va(row.get("vaHex")) for row in refs]
        outside = [
            ref
            for ref in ref_vas
            if ref is not None and ref not in selector_slots
        ]
        entry_ref_outside_selector += len(outside)
        entry_refs.append(
            {
                "selectorIndex": entry.get("selectorIndex"),
                "tableIdHex": entry.get("tableIdHex"),
                "blockVaHex": entry.get("blockVaHex"),
                "pointerRefCount": len(refs),
                "selectorSlotRefHex": hx(STATUS_COMMENT_SELECTOR_ARRAY_VA + int(entry.get("selectorIndex", 0)) * 4),
                "sampleRefsHex": [row.get("vaHex") for row in refs[:8]],
                "outsideSelectorRefCount": len(outside),
            }
        )

    return {
        "status": "inline-vm-array-consumer-grounded-producer-pending",
        "anchorRefs": anchor_refs,
        "entryRefs": entry_refs,
        "entryRefOutsideSelectorCount": entry_ref_outside_selector,
        "interpretation": (
            "The selector payload/header/array has no normal x86 pointer refs. "
            "Each grounded status-comment block is referenced through the inline "
            "0x004e8610 + index*4 array slot.  The missing producer is therefore "
            "the VM/status-window code that computes the selector index before "
            "reading this inline array, not another ordinary pointer table."
        ),
    }


def containing_scene_groups(va: int, scene_groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows = []
    for group in scene_groups:
        start = group.get("startVa")
        end = group.get("endVa")
        if start is None or end is None or not (start <= va < end):
            continue
        rows.append(
            {
                "id": group.get("id"),
                "contextKind": group.get("contextKind"),
                "selector": group.get("selector") or "",
                "rootVaHex": group.get("rootVaHex") or "",
                "rootEndVaHex": group.get("rootEndVaHex") or "",
                "maps": group.get("maps", [])[:8],
                "resources": group.get("resources", [])[:10],
                "linkClass": group.get("linkClass") or "",
                "evidenceStatus": group.get("evidenceStatus") or "",
            }
        )
    return rows


def nearest_scene_groups(va: int, scene_groups: list[dict[str, Any]], limit: int = 3) -> list[dict[str, Any]]:
    candidates = []
    for group in scene_groups:
        start = group.get("startVa")
        end = group.get("endVa")
        if start is None or end is None:
            continue
        if start <= va < end:
            continue
        distance = min(abs(va - start), abs(va - end))
        candidates.append((distance, group))
    rows = []
    for distance, group in sorted(candidates, key=lambda item: item[0])[:limit]:
        rows.append(
            {
                "id": group.get("id"),
                "distanceBytes": distance,
                "contextKind": group.get("contextKind"),
                "selector": group.get("selector") or "",
                "rootVaHex": group.get("rootVaHex") or "",
                "rootEndVaHex": group.get("rootEndVaHex") or "",
                "maps": group.get("maps", [])[:8],
                "resources": group.get("resources", [])[:10],
                "linkClass": group.get("linkClass") or "",
                "evidenceStatus": group.get("evidenceStatus") or "",
            }
        )
    return rows


def nearest_scene_records(va: int, records: list[dict[str, Any]], limit: int = 3) -> list[dict[str, Any]]:
    rows = []
    for record in sorted(records, key=lambda row: abs(va - int(row["recordVa"])))[:limit]:
        rows.append(
            {
                "map": record.get("map"),
                "recordVaHex": record.get("recordVaHex"),
                "sceneIdHex": record.get("sceneIdHex"),
                "distanceBytes": abs(va - int(record["recordVa"])),
                "tilesets": record.get("tilesets", [])[:6],
                "resources": record.get("resources", [])[:8],
            }
        )
    return rows


def refs_form_local_pointer_block(refs_by_table: dict[str, list[dict[str, Any]]]) -> bool:
    first_refs: list[int] = []
    for refs in refs_by_table.values():
        if not refs:
            return False
        ref_va = parse_hex_va(refs[0].get("vaHex"))
        if ref_va is None:
            return False
        first_refs.append(ref_va)
    if not first_refs:
        return False
    first_refs = sorted(first_refs)
    return all((b - a) == 4 for a, b in zip(first_refs, first_refs[1:]))


def classify_actor_table(
    table: dict[str, Any],
    containing: list[dict[str, Any]],
    refs: list[dict[str, Any]],
    *,
    group_has_pointer_block: bool,
) -> str:
    near_local_refs = [row for row in refs if 0 < int(row.get("distanceToTable", 0)) <= 0x20]
    if containing:
        return "scene-root-contained-comment-table"
    if group_has_pointer_block and refs:
        return "local-pointer-block-owned-comment-table"
    if near_local_refs:
        return "local-pointer-owned-comment-table"
    return "comment-table-consumer-unbound"


def build_payload() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    scene_groups = load_scene_resource_groups()
    scene_records = load_scene_records()
    comment_groups = parse_status_comment_groups(exe, sections)
    comment_blocks = scan_status_comment_blocks(exe, sections)
    blocks_by_table_id = {row["tableIdHex"]: row for row in comment_blocks}
    selector_array = scan_status_comment_selector_array(exe, sections, blocks_by_table_id)
    producer_probe = scan_selector_producer_probe(exe, sections, selector_array)

    rows: list[dict[str, Any]] = []
    status_counts: dict[str, int] = {}
    for group in comment_groups:
        actor_tables = []
        group_statuses = set()
        group_containing_ids = set()
        comment_block = blocks_by_table_id.get(str(group.get("tableIdHex")), {})
        refs_by_table: dict[str, list[dict[str, Any]]] = {}
        for table in group.get("actorTables", []):
            table_va = parse_hex_va(table.get("tableVaHex"))
            if table_va is None:
                continue
            refs_by_table[str(table.get("tableVaHex"))] = pointer_refs_to_table(exe, sections, table_va)
        group_has_pointer_block = bool(comment_block) or refs_form_local_pointer_block(refs_by_table)
        for table in group.get("actorTables", []):
            table_va = parse_hex_va(table.get("tableVaHex"))
            if table_va is None:
                continue
            refs = refs_by_table.get(str(table.get("tableVaHex")), [])
            containing = containing_scene_groups(table_va, scene_groups)
            nearest_groups = nearest_scene_groups(table_va, scene_groups)
            nearest_records = nearest_scene_records(table_va, scene_records)
            classification = classify_actor_table(table, containing, refs, group_has_pointer_block=group_has_pointer_block)
            group_statuses.add(classification)
            for contained in containing:
                if contained.get("id"):
                    group_containing_ids.add(str(contained["id"]))
            actor_tables.append(
                {
                    "actorRoleCandidate": table.get("actorRoleCandidate"),
                    "tableVaHex": table.get("tableVaHex"),
                    "tableIdHex": table.get("tableIdHex"),
                    "entryCount": table.get("entryCount"),
                    "textEntryCount": table.get("textEntryCount"),
                    "sample": table.get("sample"),
                    "classification": classification,
                    "pointerRefCount": len(refs),
                    "pointerRefs": refs[:8],
                    "containingSceneGroups": containing,
                    "nearestSceneGroups": nearest_groups,
                    "nearestSceneRecords": nearest_records,
                }
            )
        if not actor_tables:
            continue
        if "scene-root-contained-comment-table" in group_statuses:
            group_status = "partly-scene-root-contained"
        elif "local-pointer-block-owned-comment-table" in group_statuses:
            group_status = "local-pointer-block-owned"
        elif "local-pointer-owned-comment-table" in group_statuses:
            group_status = "local-pointer-owned"
        else:
            group_status = "consumer-unbound"
        status_counts[group_status] = status_counts.get(group_status, 0) + 1
        rows.append(
            {
                "tableIdHex": group.get("tableIdHex"),
                "actorTableCount": group.get("actorTableCount"),
                "maxEntryCount": group.get("maxEntryCount"),
                "samples": group.get("samples", []),
                "status": group_status,
                "localPointerBlockDetected": group_has_pointer_block,
                "commentBlock": comment_block,
                "containingSceneGroupIds": sorted(group_containing_ids),
                "actorTables": actor_tables,
            }
        )

    return {
        "version": 1,
        "kind": "status-comment-selector-binding-review",
        "promotionStatus": "selector-array-grounded-entry-consumer-pending",
        "summary": {
            "commentGroupCount": len(rows),
            "actorTableCount": sum(len(row["actorTables"]) for row in rows),
            "commentBlockCount": len(comment_blocks),
            "commentBlockSlotCount": sum(len(row["slots"]) for row in comment_blocks),
            "commentBlockMarkerSlotCount": sum(row["commentTableMarkerSlotCount"] for row in comment_blocks),
            "commentBlockEmptySlotCount": sum(row["emptyOrTerminatorSlotCount"] for row in comment_blocks),
            "selectorArrayStatus": selector_array.get("status"),
            "selectorArrayCount": selector_array.get("count", 0),
            "selectorArrayAllBlocksMatched": selector_array.get("allBlocksMatched", False),
            "selectorProducerProbeStatus": producer_probe.get("status"),
            "selectorAnchorPointerRefs": sum(row["pointerRefCount"] for row in producer_probe["anchorRefs"]),
            "selectorEntryOutsideRefs": producer_probe["entryRefOutsideSelectorCount"],
            "statusCounts": status_counts,
            "sceneResourceGroupCount": len(scene_groups),
            "sceneRecordCount": len(scene_records),
        },
        "conclusions": [
            "40 13 01 상태창 짧은 문구 테이블은 EXE에서 9개 그룹으로 재현된다.",
            "40 1b 00 00 40 1f 00 00 블록도 정확히 9개이며, 각 블록은 table id별로 3개 포인터 슬롯을 가진다.",
            "상태창 payload 0x004e85fc 안에서 0x004e8610의 9-entry 블록 포인터 배열이 확인되며, 배열 순서는 table id 0x77..0x7f와 일치한다.",
            "일부 슬롯은 독립 40 13 01 table marker가 아니라 40 0a empty/terminator 슬롯이다. 따라서 actor-table 이름은 consumer 확정 전까지 후보로 둔다.",
            "대부분 테이블은 인접 로컬 포인터가 소유하는 데이터 블록으로 보이며, 일부는 selector-root scene/resource 범위 안에 들어간다.",
            "아직 시나리오 플래그/상태값이 selector array index와 엔트리 index를 고르는 producer는 확정되지 않았다.",
            "selector/header/array 자체에는 일반 x86 포인터 참조가 없고, 각 comment block은 0x004e8610 + index*4 inline array slot에서만 참조된다.",
        ],
        "groups": rows,
        "commentBlocks": comment_blocks,
        "selectorArray": selector_array,
        "selectorProducerProbe": producer_probe,
    }


def table(headers: list[str], rows: list[list[Any]]) -> str:
    head = "".join(f"<th>{h(item)}</th>" for item in headers)
    body = "\n".join("<tr>" + "".join(f"<td>{item}</td>" for item in row) + "</tr>" for row in rows)
    return f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"


def build_html(payload: dict[str, Any]) -> str:
    summary = payload["summary"]
    group_rows = []
    selector_rows = []
    detail_sections = []
    selector_array = payload.get("selectorArray") or {}
    for row in selector_array.get("entries", []):
        selector_rows.append(
            [
                h(row.get("selectorIndex")),
                code(row.get("tableIdHex") or ""),
                code(row.get("blockVaHex") or ""),
                h("yes" if row.get("blockMatched") else "no"),
                h(row.get("markerSlotCount") if row.get("markerSlotCount") is not None else ""),
                h(row.get("emptySlotCount") if row.get("emptySlotCount") is not None else ""),
            ]
        )
    for group in payload["groups"]:
        group_rows.append(
            [
                code(group["tableIdHex"]),
                h(group["status"]),
                h(group["actorTableCount"]),
                h(group["maxEntryCount"]),
                h((group.get("commentBlock") or {}).get("blockVaHex") or "-"),
                h((group.get("commentBlock") or {}).get("commentTableMarkerSlotCount") or 0),
                h((group.get("commentBlock") or {}).get("emptyOrTerminatorSlotCount") or 0),
                h(", ".join(group.get("containingSceneGroupIds") or []) or "-"),
                h(" / ".join((group.get("samples") or [])[:3])),
            ]
        )
        block = group.get("commentBlock") or {}
        slot_rows = [
            [
                h(slot.get("slotLabel")),
                code(slot.get("targetVaHex")),
                h(slot.get("kind")),
                h(slot.get("count") or ""),
                code(slot.get("endVaHex") or ""),
                h(slot.get("rawHex")),
            ]
            for slot in block.get("slots", [])
        ]
        actor_rows = []
        for table_row in group.get("actorTables", []):
            contained = "; ".join(
                f"{row.get('id')} {row.get('selector')} maps={','.join(row.get('maps') or [])}"
                for row in table_row.get("containingSceneGroups", [])
            )
            nearest = "; ".join(
                f"{row.get('id')} d={row.get('distanceBytes')} {row.get('selector')}"
                for row in table_row.get("nearestSceneGroups", [])[:2]
            )
            records = "; ".join(
                f"{row.get('map')} d={row.get('distanceBytes')} {row.get('sceneIdHex')}"
                for row in table_row.get("nearestSceneRecords", [])[:2]
            )
            refs = "; ".join(
                f"{row.get('vaHex')} d={row.get('distanceToTable')}"
                for row in table_row.get("pointerRefs", [])[:4]
            )
            actor_rows.append(
                [
                    h(table_row.get("actorRoleCandidate")),
                    code(table_row.get("tableVaHex")),
                    h(table_row.get("classification")),
                    h(table_row.get("entryCount")),
                    h(table_row.get("pointerRefCount")),
                    h(refs or "-"),
                    h(contained or "-"),
                    h(nearest or "-"),
                    h(records or "-"),
                    h(table_row.get("sample")),
                ]
            )
        detail_sections.append(
            f"""
            <section>
              <h2>{h(group['tableIdHex'])}</h2>
              <h3>3-slot local block</h3>
              {table(['slot','target','kind','count','end','raw'], slot_rows)}
              <h3>parsed comment tables</h3>
              {table(['actor 후보','table','classification','entries','refs','local refs','containing scene groups','nearest scene groups','nearest records','sample'], actor_rows)}
            </section>
            """
        )

    conclusions = "".join(f"<li>{h(item)}</li>" for item in payload.get("conclusions", []))
    status_counts = ", ".join(f"{key}: {value}" for key, value in summary.get("statusCounts", {}).items())
    producer_probe = payload.get("selectorProducerProbe") or {}
    producer_anchor_rows = table(
        ["anchor", "target", "pointer refs", "sample refs"],
        [
            [
                h(row.get("label")),
                code(row.get("targetVaHex")),
                h(row.get("pointerRefCount")),
                " ".join(code(ref) for ref in row.get("sampleRefsHex", []) if ref) or "-",
            ]
            for row in producer_probe.get("anchorRefs", [])
        ],
    )
    producer_entry_rows = table(
        ["index", "table id", "block", "refs", "selector slot", "outside refs", "sample refs"],
        [
            [
                h(row.get("selectorIndex")),
                code(row.get("tableIdHex")),
                code(row.get("blockVaHex")),
                h(row.get("pointerRefCount")),
                code(row.get("selectorSlotRefHex")),
                h(row.get("outsideSelectorRefCount")),
                " ".join(code(ref) for ref in row.get("sampleRefsHex", []) if ref) or "-",
            ]
            for row in producer_probe.get("entryRefs", [])
        ],
    )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>상태창 문구 selector binding 리뷰</title>
  <style>
    body {{ margin: 0; font-family: system-ui, sans-serif; background: #f5f1e8; color: #1f2933; }}
    main {{ max-width: 1180px; margin: 0 auto; padding: 24px; }}
    section {{ background: #fffaf0; border: 1px solid #d7c9a8; border-radius: 8px; padding: 16px; margin: 16px 0; }}
    table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
    th, td {{ border-bottom: 1px solid #e2d6ba; padding: 7px 8px; vertical-align: top; text-align: left; }}
    th {{ background: #efe3c7; position: sticky; top: 0; }}
    code {{ background: #fff; border: 1px solid #e2d6ba; border-radius: 4px; padding: 1px 4px; }}
    .cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }}
    .card {{ background: #fff; border: 1px solid #e2d6ba; border-radius: 8px; padding: 12px; }}
    .label {{ color: #6b7280; font-size: 12px; }}
    .value {{ font-size: 20px; font-weight: 700; margin-top: 4px; }}
    .scroll {{ overflow-x: auto; }}
  </style>
</head>
<body>
<main>
  <h1>상태창 문구 selector binding 리뷰</h1>
  <p>상태창 짧은 문구 <code>40 13 01</code> 테이블을 scene/resource 후보 범위와 대조한 정적 분석 자료입니다.</p>
  <section>
    <div class="cards">
      <div class="card"><div class="label">promotion</div><div class="value">{h(payload['promotionStatus'])}</div></div>
      <div class="card"><div class="label">groups</div><div class="value">{summary['commentGroupCount']}</div></div>
      <div class="card"><div class="label">actor tables</div><div class="value">{summary['actorTableCount']}</div></div>
      <div class="card"><div class="label">3-slot blocks</div><div class="value">{summary['commentBlockCount']}</div></div>
      <div class="card"><div class="label">selector array</div><div class="value">{h(summary.get('selectorArrayStatus'))}</div></div>
      <div class="card"><div class="label">status counts</div><div class="value" style="font-size:14px">{h(status_counts)}</div></div>
    </div>
    <ul>{conclusions}</ul>
  </section>
  <section>
    <h2>Selector array</h2>
    <p><code>{h(selector_array.get('headerVaHex'))}</code> payload 안의 <code>{h(selector_array.get('arrayVaHex'))}</code> 9-entry 배열입니다. {h(selector_array.get('normalXrefEvidence'))}</p>
    <p>{h(selector_array.get('headerInterpretation'))}</p>
    <div class="scroll">{table(['index','table id','block','matched','marker slots','empty slots'], selector_rows)}</div>
  </section>
  <section>
    <h2>Producer probe</h2>
    <p>{h(producer_probe.get('interpretation'))}</p>
    <h3>selector anchors</h3>
    <div class="scroll">{producer_anchor_rows}</div>
    <h3>comment block refs</h3>
    <div class="scroll">{producer_entry_rows}</div>
  </section>
  <section>
    <h2>그룹 요약</h2>
    <div class="scroll">{table(['table id','status','parsed tables','max entries','block','marker slots','empty slots','containing group ids','samples'], group_rows)}</div>
  </section>
  {''.join(detail_sections)}
</main>
</body>
</html>
"""


def write_json(path: Path, payload: dict[str, Any]) -> None:
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")


def main() -> None:
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    payload = build_payload()
    write_json(JSON_OUT, payload)
    html_text = build_html(payload)
    HTML_OUT.write_text(html_text, encoding="utf-8")
    WEB_HTML_OUT.write_text(html_text, encoding="utf-8")
    print(f"wrote {JSON_OUT}")
    print(f"wrote {WEB_HTML_OUT}")


if __name__ == "__main__":
    main()
