#!/usr/bin/env python3
"""Decompose selector-root payload ranges into coarse static spans.

This is the first step after linking scene sequences to resource records.  It
does not try to execute the scene VM.  Instead it scans each selected root range
and separates the storage surfaces that are already recognizable:

* CNS/resource string references,
* `0x032f, text VA, 0x84` text-entry tables,
* control-pointer candidates attached to text entries,
* local pointer clusters that look like intra-root dispatch/control tables.

The output is a structural review page.  It is intentionally conservative:
root internals are "decomposed candidates", not confirmed command opcodes.
"""
from __future__ import annotations

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

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

from probe_exe_scene_tables import (  # noqa: E402
    classify_cns,
    find_cns_strings,
    read_sections,
    va_to_offset,
)
from summarize_scene_text_sequences import (  # noqa: E402
    annotate_control_pointers,
    find_text_entries,
)


ROOT = Path(__file__).resolve().parents[1]
EXE = ROOT / "Hwanse2.exe"
OUT = ROOT / "out"
WEB = ROOT / "web"
PROMOTION_STATUS = "selector-root-structure-decomposed-candidate"
MAX_ROOT_SCAN = 0x20000
CLUSTER_GAP = 0x40


def hx(value: int | None) -> str:
    if value is None:
        return ""
    return f"0x{value:08x}"


def load_json(path: Path, default: Any) -> Any:
    if not path.exists():
        return default
    return json.loads(path.read_text(encoding="utf-8"))


def unique(values: list[str]) -> list[str]:
    seen: set[str] = set()
    rows: list[str] = []
    for value in values:
        if not value or value in seen:
            continue
        seen.add(value)
        rows.append(value)
    return rows


def compact_text(value: str, limit: int = 220) -> str:
    text = " / ".join(str(value or "").splitlines()).strip()
    if len(text) <= limit:
        return text
    return text[: limit - 1] + "..."


def section_for_va(sections: list[dict[str, Any]], va: int) -> dict[str, Any] | None:
    for section in sections:
        start = section["va"]
        end = start + section["raw_size"]
        if start <= va < end:
            return section
    return None


def section_end_for_va(sections: list[dict[str, Any]], va: int) -> int:
    section = section_for_va(sections, va)
    if not section:
        return va
    return section["va"] + section["raw_size"]


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


def int_hex(value: str | int | None) -> int | None:
    if isinstance(value, int):
        return value
    if isinstance(value, str) and value.startswith("0x"):
        return int(value, 16)
    return None


def selector_key(row: dict[str, Any]) -> str:
    return f"{row.get('group')}:{row.get('slot')}"


def selector_key_parts(key: str) -> tuple[int | None, int | None]:
    try:
        group, slot = key.split(":", 1)
        return int(group), int(slot)
    except (AttributeError, ValueError):
        return None, None


def selector_alias_summary(
    selectors: list[dict[str, Any]],
    runtime_selector_writes: dict[str, Any],
) -> dict[str, Any]:
    rows_by_key = {selector_key(row): row for row in selectors}
    groups: dict[int, list[dict[str, Any]]] = defaultdict(list)
    for row in selectors:
        group = row.get("group")
        if isinstance(group, int):
            groups[group].append(row)

    mode1_rows = [
        row
        for row in (runtime_selector_writes.get("opcode4fDataScan") or {}).get("rows") or []
        if row.get("mode") == 1 and row.get("targetSelector")
    ]
    cross_rows = []
    self_rows = []
    for row in mode1_rows:
        root_selector = (row.get("rootContext") or {}).get("selector")
        target_selector = row.get("targetSelector")
        if not root_selector or not target_selector:
            continue
        source_group, source_slot = selector_key_parts(root_selector)
        target_group, target_slot = selector_key_parts(target_selector)
        source_selector = rows_by_key.get(root_selector) or {}
        target_selector_row = rows_by_key.get(target_selector) or {}
        fields_same = tuple(source_selector.get("fieldMaps") or []) == tuple(target_selector_row.get("fieldMaps") or [])
        cns_same = tuple(source_selector.get("linkedCns") or []) == tuple(target_selector_row.get("linkedCns") or [])
        normalized = {
            "rowVaHex": row.get("vaHex") or "",
            "sourceSelector": root_selector,
            "targetSelector": target_selector,
            "sourceGroup": source_group,
            "sourceSlot": source_slot,
            "targetGroup": target_group,
            "targetSlot": target_slot,
            "sameGroup": source_group is not None and source_group == target_group,
            "slot1ToSlot0": source_slot == 1 and target_slot == 0,
            "fieldMapsSame": fields_same,
            "linkedCnsSame": cns_same,
            "classification": "slot-alias-normalization-candidate",
            "promotesProducer": False,
        }
        if root_selector == target_selector:
            self_rows.append(normalized)
        else:
            cross_rows.append(normalized)

    cross_by_source = {row["sourceSelector"]: row for row in cross_rows}
    multi_slot_groups = []
    for group, rows in sorted(groups.items()):
        slots = sorted({row.get("slot") for row in rows if isinstance(row.get("slot"), int)})
        if len(slots) <= 1:
            continue
        sorted_rows = sorted(rows, key=lambda row: row.get("slot") if isinstance(row.get("slot"), int) else -1)
        field_map_sets = {tuple(row.get("fieldMaps") or []) for row in sorted_rows}
        linked_cns_sets = {tuple(row.get("linkedCns") or []) for row in sorted_rows}
        alias_rows = [
            cross_by_source[key]
            for key in [selector_key(row) for row in sorted_rows]
            if key in cross_by_source
        ]
        multi_slot_groups.append(
            {
                "group": group,
                "slots": slots,
                "selectorKeys": [selector_key(row) for row in sorted_rows],
                "rootHexes": [row.get("selectedPointerHex") or "" for row in sorted_rows],
                "fieldMapsSame": len(field_map_sets) == 1,
                "linkedCnsSame": len(linked_cns_sets) == 1,
                "hasFieldMaps": any(row.get("fieldMaps") for row in sorted_rows),
                "fieldMapSamples": [list(values) for values in sorted(field_map_sets) if values][:3],
                "linkedCnsSamples": [list(values) for values in sorted(linked_cns_sets) if values][:3],
                "opcode4fCrossAliasRows": alias_rows,
                "hasOpcode4fCrossAlias": bool(alias_rows),
            }
        )

    return {
        "selectorRowCount": len(selectors),
        "selectorGroupCount": len(groups),
        "multiSlotGroupCount": len(multi_slot_groups),
        "multiSlotGroupsWithFieldMapsCount": sum(1 for row in multi_slot_groups if row["hasFieldMaps"]),
        "multiSlotGroupsSameFieldMapsCount": sum(1 for row in multi_slot_groups if row["fieldMapsSame"]),
        "opcode4fMode1SelectorWriterCount": len(mode1_rows),
        "opcode4fMode1SelfWriterCount": len(self_rows),
        "opcode4fMode1CrossAliasNormalizationCount": len(cross_rows),
        "opcode4fCrossAliasRowsAllSameGroup": all(row["sameGroup"] for row in cross_rows),
        "opcode4fCrossAliasRowsAllSlot1ToSlot0": all(row["slot1ToSlot0"] for row in cross_rows),
        "opcode4fCrossAliasRowsPromoteProducer": any(row["promotesProducer"] for row in cross_rows),
        "opcode4fCrossAliasRows": cross_rows,
        "multiSlotGroups": multi_slot_groups,
    }


def sequence_groups_by_root(sequence_review: dict[str, Any]) -> dict[int, list[dict[str, Any]]]:
    rows: dict[int, list[dict[str, Any]]] = defaultdict(list)
    for group in sequence_review.get("groups") or []:
        root = int_hex(group.get("rootVaHex"))
        if root is None:
            continue
        rows[root].append(group)
    return rows


def entries_by_va(entries: list[dict[str, Any]]) -> dict[int, dict[str, Any]]:
    return {int(row["entryVa"]): row for row in entries if isinstance(row.get("entryVa"), int)}


def text_entry_ranges_for_group(group: dict[str, Any]) -> list[dict[str, Any]]:
    rows = []
    for seq in group.get("sequences") or []:
        start = int_hex(seq.get("entryStartVaHex"))
        end = int_hex(seq.get("entryEndVaHex"))
        if start is None or end is None:
            continue
        rows.append(
            {
                "kind": "text-entry-table-span",
                "sequenceId": seq.get("id", ""),
                "startVa": start,
                "endVa": end + 12,
                "startVaHex": hx(start),
                "endVaHex": hx(end + 12),
                "entryCount": seq.get("entryCount", 0),
                "promptCount": seq.get("promptCount", 0),
                "choiceCount": len(seq.get("choiceReviews") or []),
                "sample": compact_text(seq.get("sample") or "", 360),
                "evidence": "sequence-review-entry-range",
            }
        )
    return rows


def root_ranges(
    selectors: list[dict[str, Any]],
    groups_by_root: dict[int, list[dict[str, Any]]],
    sections: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    by_root: dict[int, dict[str, Any]] = {}
    for row in selectors:
        root = int_hex(row.get("selectedPointerHex"))
        if root is None:
            continue
        bucket = by_root.setdefault(
            root,
            {
                "rootVa": root,
                "selectorKeys": [],
                "fieldMaps": [],
                "linkedCns": [],
                "rowPointerHexes": [],
            },
        )
        bucket["selectorKeys"].append(selector_key(row))
        bucket["fieldMaps"].extend(row.get("fieldMaps") or [])
        bucket["linkedCns"].extend(row.get("linkedCns") or [])
        if row.get("rowPointerHex"):
            bucket["rowPointerHexes"].append(row["rowPointerHex"])
    roots = sorted(by_root)
    ranges = []
    for index, root in enumerate(roots):
        next_root = roots[index + 1] if index + 1 < len(roots) else section_end_for_va(sections, root)
        section_end = section_end_for_va(sections, root)
        scan_end = min(next_root, section_end, root + MAX_ROOT_SCAN)
        for group in groups_by_root.get(root, []):
            for span in text_entry_ranges_for_group(group):
                scan_end = max(scan_end, min(next_root, section_end, span["endVa"] + 0x200))
        row = dict(by_root[root])
        row["rootVaHex"] = hx(root)
        row["rangeEndVa"] = scan_end
        row["rangeEndVaHex"] = hx(scan_end)
        row["nextRootVaHex"] = hx(next_root)
        row["scanSize"] = max(0, scan_end - root)
        row["selectorKeys"] = unique(row["selectorKeys"])
        row["fieldMaps"] = unique(row["fieldMaps"])
        row["linkedCns"] = unique(row["linkedCns"])
        row["rowPointerHexes"] = unique(row["rowPointerHexes"])
        ranges.append(row)
    return ranges


def classify_dword(
    va: int,
    value: int,
    root_start: int,
    root_end: int,
    cns_strings: dict[int, str],
    text_entry_starts: set[int],
    text_entry_value_vas: set[int],
    text_entry_sentinel_vas: set[int],
) -> tuple[str, dict[str, Any]]:
    extra: dict[str, Any] = {}
    if va in text_entry_starts:
        return "text-entry-opcode", extra
    if va in text_entry_value_vas:
        return "text-entry-text-pointer", extra
    if va in text_entry_sentinel_vas:
        return "text-entry-sentinel", extra
    if value in cns_strings:
        name = cns_strings[value]
        extra["string"] = name
        extra["resourceKind"] = classify_cns(name)
        if classify_cns(name) == "map":
            return "field-map-string-ref", extra
        if classify_cns(name) == "tileset":
            return "tileset-string-ref", extra
        if classify_cns(name) == "sprite":
            return "sprite-string-ref", extra
        return "cns-string-ref", extra
    if root_start <= value < root_end:
        return "local-pointer", extra
    if 0x00400000 <= value < 0x00600000:
        return "external-pointer", extra
    if value in (0, 1, 3, 4, 5, 0x3F, 0x5A, 0x6D, 0x84, 0x16D, 0x307, 0x34B, 0x352):
        return "small-scalar", extra
    if value & 0xFFFF0000 and value & 0x0000FFFF:
        return "packed-control-scalar", extra
    return "scalar", extra


def cluster_rows(rows: list[dict[str, Any]], kind: str, max_gap: int = CLUSTER_GAP) -> list[dict[str, Any]]:
    if not rows:
        return []
    rows = sorted(rows, key=lambda row: row["va"])
    clusters: list[list[dict[str, Any]]] = []
    current: list[dict[str, Any]] = []
    for row in rows:
        if current and row["va"] - current[-1]["va"] > max_gap:
            clusters.append(current)
            current = []
        current.append(row)
    if current:
        clusters.append(current)
    spans = []
    for index, cluster in enumerate(clusters, start=1):
        names = unique([row.get("string", "") for row in cluster if row.get("string")])
        spans.append(
            {
                "kind": kind,
                "id": f"{kind}-{index:02d}",
                "startVa": cluster[0]["va"],
                "endVa": cluster[-1]["va"] + 4,
                "startVaHex": hx(cluster[0]["va"]),
                "endVaHex": hx(cluster[-1]["va"] + 4),
                "rowCount": len(cluster),
                "names": names,
                "sample": ", ".join(names[:12]) + (f", +{len(names) - 12}" if len(names) > 12 else ""),
                "rows": cluster[:32],
            }
        )
    return spans


def control_pointer_spans_for_group(group: dict[str, Any]) -> list[dict[str, Any]]:
    pointers = []
    for seq in group.get("sequences") or []:
        for entry in seq.get("entries") or []:
            for pointer in entry.get("controlPointers") or []:
                pointer_va = int_hex(pointer.get("pointerVa"))
                if pointer_va is None:
                    continue
                pointers.append(
                    {
                        "va": pointer_va,
                        "vaHex": hx(pointer_va),
                        "opcodeHex": pointer.get("opcodeHex") or "",
                        "targetVaHex": pointer.get("targetVaHex") or "",
                        "resolvedEntryVaHex": pointer.get("resolvedEntryVaHex") or "",
                        "sequenceId": seq.get("id", ""),
                    }
                )
    return cluster_rows(pointers, "control-pointer-candidate-span", max_gap=0x80)


def scan_root(
    data: bytes,
    sections: list[dict[str, Any]],
    root: dict[str, Any],
    cns_strings: dict[int, str],
    entries_by_start: dict[int, dict[str, Any]],
) -> dict[str, Any]:
    root_start = root["rootVa"]
    root_end = root["rangeEndVa"]
    text_entry_starts = {
        va for va in entries_by_start if root_start <= va < root_end
    }
    text_entry_value_vas = {va + 4 for va in text_entry_starts}
    text_entry_sentinel_vas = {va + 8 for va in text_entry_starts}
    rows = []
    kind_counter: Counter[str] = Counter()
    for va in range(root_start, root_end, 4):
        value = dword_at(data, sections, va)
        if value is None:
            continue
        kind, extra = classify_dword(
            va,
            value,
            root_start,
            root_end,
            cns_strings,
            text_entry_starts,
            text_entry_value_vas,
            text_entry_sentinel_vas,
        )
        kind_counter[kind] += 1
        row = {
            "va": va,
            "vaHex": hx(va),
            "value": value,
            "valueHex": hx(value),
            "kind": kind,
        }
        row.update(extra)
        rows.append(row)
    resource_rows = [
        row
        for row in rows
        if row["kind"] in {"field-map-string-ref", "tileset-string-ref", "sprite-string-ref", "cns-string-ref"}
    ]
    local_pointer_rows = [row for row in rows if row["kind"] == "local-pointer"]
    text_entry_raw_spans = cluster_rows(
        [row for row in rows if row["kind"].startswith("text-entry")],
        "raw-text-entry-storage-cluster",
        max_gap=0x20,
    )
    resource_spans = cluster_rows(resource_rows, "resource-string-ref-cluster", max_gap=0x50)
    local_pointer_spans = cluster_rows(local_pointer_rows, "local-pointer-cluster", max_gap=0x40)
    return {
        "kindCounts": dict(kind_counter),
        "resourceRefCount": len(resource_rows),
        "textEntryStorageRowCount": sum(1 for row in rows if row["kind"].startswith("text-entry")),
        "localPointerCount": len(local_pointer_rows),
        "resourceSpans": resource_spans,
        "rawTextEntrySpans": text_entry_raw_spans,
        "localPointerSpans": local_pointer_spans[:24],
        "rowsSample": rows[:80],
    }


def analyze(
    data: bytes,
    sections: list[dict[str, Any]],
    selectors: list[dict[str, Any]],
    sequence_review: dict[str, Any],
    runtime_selector_writes: dict[str, Any],
) -> dict[str, Any]:
    cns_strings = find_cns_strings(data, sections)
    text_entries = annotate_control_pointers(data, sections, find_text_entries(data, sections))
    entry_by_va = entries_by_va(text_entries)
    groups_by_root = sequence_groups_by_root(sequence_review)
    ranges = root_ranges(selectors, groups_by_root, sections)
    roots = []
    span_counter: Counter[str] = Counter()
    root_class_counter: Counter[str] = Counter()
    total_resource_refs = 0
    total_text_entries = 0
    total_control_spans = 0
    roots_with_seq = 0
    for root in ranges:
        linked_groups = groups_by_root.get(root["rootVa"], [])
        if linked_groups:
            roots_with_seq += 1
        scan = scan_root(data, sections, root, cns_strings, entry_by_va)
        text_spans = []
        control_spans = []
        for group in linked_groups:
            text_spans.extend(text_entry_ranges_for_group(group))
            control_spans.extend(control_pointer_spans_for_group(group))
        spans = (
            scan["resourceSpans"]
            + text_spans
            + control_spans
            + scan["localPointerSpans"][:8]
        )
        spans = sorted(spans, key=lambda row: row.get("startVa", 0))
        for span in spans:
            span_counter[span["kind"]] += 1
        if linked_groups and scan["resourceRefCount"]:
            root_class = "sequence-root-with-resource-structure"
        elif linked_groups:
            root_class = "sequence-root-text-only-or-external-resource"
        elif scan["resourceRefCount"]:
            root_class = "resource-root-without-sequence"
        else:
            root_class = "unclassified-selector-root"
        root_class_counter[root_class] += 1
        total_resource_refs += scan["resourceRefCount"]
        total_text_entries += len([va for va in entry_by_va if root["rootVa"] <= va < root["rangeEndVa"]])
        total_control_spans += len(control_spans)
        roots.append(
            {
                **root,
                "rootClass": root_class,
                "sequenceGroupIds": [group.get("id", "") for group in linked_groups],
                "sequenceCount": sum(group.get("sequenceCount", 0) for group in linked_groups),
                "promptCount": sum(group.get("promptCount", 0) for group in linked_groups),
                "textEntryCount": len([va for va in entry_by_va if root["rootVa"] <= va < root["rangeEndVa"]]),
                "resourceRefCount": scan["resourceRefCount"],
                "controlPointerSpanCount": len(control_spans),
                "kindCounts": scan["kindCounts"],
                "spans": spans,
                "rowsSample": scan["rowsSample"],
                "evidenceStatus": PROMOTION_STATUS,
            }
        )
    summary = {
        "selectorRootCount": len(roots),
        "selectorRows": len(selectors),
        "rootsWithSceneSeqCount": roots_with_seq,
        "rootsWithResourceRefCount": sum(1 for row in roots if row["resourceRefCount"] > 0),
        "sequenceRootWithResourceStructureCount": root_class_counter.get("sequence-root-with-resource-structure", 0),
        "sequenceRootTextOnlyOrExternalResourceCount": root_class_counter.get("sequence-root-text-only-or-external-resource", 0),
        "resourceRootWithoutSequenceCount": root_class_counter.get("resource-root-without-sequence", 0),
        "unclassifiedSelectorRootCount": root_class_counter.get("unclassified-selector-root", 0),
        "totalResourceRefCount": total_resource_refs,
        "totalTextEntryCountInRootRanges": total_text_entries,
        "totalControlPointerSpanCount": total_control_spans,
        "spanKindCounts": dict(span_counter),
        "rootClassCounts": dict(root_class_counter),
        "directExecutionProofFound": False,
        "commandOpcodeProofFound": False,
        "promotionStatus": PROMOTION_STATUS,
    }
    alias_summary = selector_alias_summary(selectors, runtime_selector_writes)
    summary.update({key: value for key, value in alias_summary.items() if key != "multiSlotGroups"})
    return {
        "kind": "hwanse-selector-root-structure-review",
        "promotionStatus": PROMOTION_STATUS,
        "summary": summary,
        "roots": roots,
        "selectorAliasGroups": alias_summary["multiSlotGroups"],
        "notes": [
            "CNS refs, text-entry rows, control pointers, and local pointers are separated as storage spans.",
            "Packed scalars and local pointers are not promoted to command opcodes without a consumer.",
            "This narrows selector-root payload structure for later resource consumer tracing.",
            "Opcode 0x4f mode1 cross-writers are classified as same-group slot-alias normalization candidates, not route producers.",
        ],
    }


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


def tag(value: str, cls: str = "") -> str:
    return f'<span class="tag {cls}">{esc(value)}</span>'


def code_list(values: list[str], limit: int = 8) -> str:
    if not values:
        return '<span class="muted">-</span>'
    bits = [f"<code>{esc(value)}</code>" for value in values[:limit]]
    if len(values) > limit:
        bits.append(f'<span class="muted">+{len(values) - limit}</span>')
    return " ".join(bits)


def span_table(spans: list[dict[str, Any]]) -> str:
    rows = []
    for span in spans:
        detail = span.get("sample") or ", ".join(span.get("names") or [])
        if span.get("sequenceId"):
            detail = f"{span['sequenceId']} · {detail}"
        rows.append(
            "<tr>"
            f"<td>{tag(span['kind'], 'muted')}</td>"
            f"<td><code>{esc(span['startVaHex'])}</code>..<code>{esc(span['endVaHex'])}</code></td>"
            f"<td>{esc(span.get('rowCount') or span.get('entryCount') or '')}</td>"
            f"<td>{esc(detail)}</td>"
            "</tr>"
        )
    if not rows:
        rows.append('<tr><td colspan="4" class="muted">No promoted spans in this root.</td></tr>')
    return (
        '<table class="span-table"><thead><tr><th>span</th><th>range</th><th>rows</th><th>detail</th></tr></thead>'
        f"<tbody>{''.join(rows)}</tbody></table>"
    )


def alias_group_table(groups: list[dict[str, Any]]) -> str:
    rows = []
    for group in groups:
        alias_rows = group.get("opcode4fCrossAliasRows") or []
        alias_text = "<br>".join(
            f"<code>{esc(row['sourceSelector'])}</code> -> <code>{esc(row['targetSelector'])}</code> "
            f"<code>{esc(row['rowVaHex'])}</code> {tag(row['classification'], 'warn')}"
            for row in alias_rows
        ) or '<span class="muted">-</span>'
        map_cls = "good" if group["fieldMapsSame"] else "warn"
        cns_cls = "good" if group["linkedCnsSame"] else "warn"
        rows.append(
            "<tr>"
            f"<td><code>{esc(group['group'])}</code></td>"
            f"<td>{' '.join(f'<code>{esc(slot)}</code>' for slot in group['slots'])}</td>"
            f"<td>{code_list(group['selectorKeys'], 10)}</td>"
            f"<td>{code_list(group['rootHexes'], 10)}</td>"
            f"<td>{tag(str(group['fieldMapsSame']).lower(), map_cls)}</td>"
            f"<td>{tag(str(group['linkedCnsSame']).lower(), cns_cls)}</td>"
            f"<td>{alias_text}</td>"
            "</tr>"
        )
    if not rows:
        rows.append('<tr><td colspan="7" class="muted">No multi-slot selector groups.</td></tr>')
    return (
        "<table><thead><tr>"
        "<th>group</th><th>slots</th><th>selectors</th><th>roots</th>"
        "<th>same maps</th><th>same CNS</th><th>opcode4f cross alias</th>"
        "</tr></thead>"
        f"<tbody>{''.join(rows)}</tbody></table>"
    )


def render_html(payload: dict[str, Any]) -> str:
    summary = payload["summary"]
    root_blocks = []
    for root in payload["roots"]:
        cls = "good" if root["rootClass"] == "sequence-root-with-resource-structure" else "warn"
        if root["rootClass"] == "unclassified-selector-root":
            cls = "muted"
        root_blocks.append(
            "<details class=\"root-card\" "
            + ("open" if root["sequenceGroupIds"] else "")
            + ">"
            "<summary>"
            f"<strong><code>{esc(root['rootVaHex'])}</code></strong> "
            f"{tag(root['rootClass'], cls)} "
            f"<span>{esc(root['scanSize'])} bytes</span> "
            f"<span>{esc(root['resourceRefCount'])} resource refs</span> "
            f"<span>{esc(root['textEntryCount'])} text entries</span> "
            f"<span>{esc(root['controlPointerSpanCount'])} control spans</span>"
            "</summary>"
            "<div class=\"root-meta\">"
            f"<div><strong>selectors</strong>{code_list(root['selectorKeys'], 10)}</div>"
            f"<div><strong>seq groups</strong>{code_list(root['sequenceGroupIds'], 8)}</div>"
            f"<div><strong>field maps</strong>{code_list(root['fieldMaps'], 12)}</div>"
            f"<div><strong>linked CNS</strong>{code_list(root['linkedCns'], 12)}</div>"
            "</div>"
            f"{span_table(root['spans'])}"
            "</details>"
        )
    summary_json = json.dumps(
        {
            "promotionStatus": payload["promotionStatus"],
            "selectorRootCount": summary["selectorRootCount"],
            "selectorRows": summary["selectorRows"],
            "selectorGroupCount": summary["selectorGroupCount"],
            "multiSlotGroupCount": summary["multiSlotGroupCount"],
            "multiSlotGroupsWithFieldMapsCount": summary["multiSlotGroupsWithFieldMapsCount"],
            "multiSlotGroupsSameFieldMapsCount": summary["multiSlotGroupsSameFieldMapsCount"],
            "opcode4fMode1SelectorWriterCount": summary["opcode4fMode1SelectorWriterCount"],
            "opcode4fMode1SelfWriterCount": summary["opcode4fMode1SelfWriterCount"],
            "opcode4fMode1CrossAliasNormalizationCount": summary["opcode4fMode1CrossAliasNormalizationCount"],
            "opcode4fCrossAliasRowsAllSameGroup": summary["opcode4fCrossAliasRowsAllSameGroup"],
            "opcode4fCrossAliasRowsAllSlot1ToSlot0": summary["opcode4fCrossAliasRowsAllSlot1ToSlot0"],
            "opcode4fCrossAliasRowsPromoteProducer": summary["opcode4fCrossAliasRowsPromoteProducer"],
            "rootsWithSceneSeqCount": summary["rootsWithSceneSeqCount"],
            "sequenceRootWithResourceStructureCount": summary["sequenceRootWithResourceStructureCount"],
            "directExecutionProofFound": False,
            "commandOpcodeProofFound": False,
        },
        ensure_ascii=False,
        indent=2,
    )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link rel="icon" href="../favicon.ico" />
  <title>selector-root 구조 분해</title>
  <style>
    :root {{ --bg:#f6f7f9; --panel:#fff; --head:#eef2f6; --border:#d8dee6; --ink:#17202a; --muted:#647384; }}
    * {{ box-sizing:border-box; }}
    body {{ margin:0; background:var(--bg); color:var(--ink); font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; line-height:1.45; }}
    main {{ max-width:1500px; margin:0 auto; padding:18px; }}
    header {{ display:flex; justify-content:space-between; align-items:flex-start; gap:16px; margin-bottom:14px; }}
    h1 {{ margin:0; font-size:24px; }}
    h2 {{ margin:0; font-size:17px; }}
    a {{ color:#185abc; font-weight:700; text-decoration:none; }}
    a:hover {{ text-decoration:underline; }}
    nav {{ display:flex; flex-wrap:wrap; gap:10px; justify-content:flex-end; }}
    section,.root-card {{ background:var(--panel); border:1px solid var(--border); border-radius:8px; margin:14px 0; overflow:hidden; }}
    .head {{ display:flex; justify-content:space-between; gap:12px; padding:12px 14px; background:var(--head); border-bottom:1px solid var(--border); }}
    .body {{ padding:14px; }}
    .metrics {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:10px; }}
    .metric {{ border:1px solid var(--border); border-radius:6px; background:#f8fafc; padding:10px; }}
    .metric strong {{ display:block; font-size:22px; }}
    .root-card>summary {{ cursor:pointer; padding:12px 14px; background:#f8fafc; display:flex; gap:8px; flex-wrap:wrap; align-items:center; }}
    .root-meta {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:8px; padding:12px 14px; border-top:1px solid var(--border); }}
    .root-meta strong {{ display:block; margin-bottom:4px; }}
    table {{ width:100%; border-collapse:collapse; }}
    th,td {{ padding:8px 10px; border-top:1px solid var(--border); vertical-align:top; text-align:left; font-size:13px; }}
    th {{ background:#f8fafc; color:#344050; }}
    code {{ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }}
    .tag {{ display:inline-block; padding:2px 7px; border-radius:999px; background:#edf2f7; color:#334155; font-size:12px; white-space:nowrap; margin:1px; }}
    .tag.good {{ color:#0f6a38; background:#e7f6ec; }}
    .tag.warn {{ color:#a15c00; background:#fff4df; }}
    .tag.bad {{ color:#b42318; background:#fdebea; }}
    .tag.muted {{ color:#607080; background:#edf2f7; }}
    .muted {{ color:var(--muted); }}
    @media (max-width:760px) {{ header {{ display:block; }} nav {{ justify-content:flex-start; margin-top:10px; }} }}
  </style>
</head>
<body>
<main data-page="selector-root-structure-review">
  <header>
    <div>
      <h1>selector-root 구조 분해</h1>
      <p class="muted">selected root 내부의 resource refs, text-entry table, control pointer 후보, local pointer cluster를 분리한다. opcode 실행 증거는 아직 승격하지 않는다.</p>
    </div>
    <nav aria-label="review links">
      <a href="index.html">관리 홈</a>
      <a href="scene_seq_resource_record_link_review.html">seq/resource 연결</a>
      <a href="scene_event_vm_review.html">Scene/Event VM</a>
      <a href="scene_event_text_consumer_trace_review.html">text consumer</a>
      <a href="resource_loader_consumer_trace_review.html">resource loader</a>
      <a href="../out/selector_root_structure_review.json">JSON</a>
    </nav>
  </header>

  <section>
    <div class="head"><h2>요약</h2><span class="muted">{esc(payload['promotionStatus'])}</span></div>
    <div class="body metrics">
      <div class="metric"><strong>{summary['selectorRootCount']}</strong><span>selector roots</span></div>
      <div class="metric"><strong>{summary['selectorRows']}</strong><span>selector rows</span></div>
      <div class="metric"><strong>{summary['multiSlotGroupCount']}</strong><span>multi-slot groups</span></div>
      <div class="metric"><strong>{summary['opcode4fMode1CrossAliasNormalizationCount']}</strong><span>0x4f cross alias rows</span></div>
      <div class="metric"><strong>{summary['rootsWithSceneSeqCount']}</strong><span>roots with scene-seq</span></div>
      <div class="metric"><strong>{summary['sequenceRootWithResourceStructureCount']}</strong><span>seq roots with resources</span></div>
      <div class="metric"><strong>{summary['rootsWithResourceRefCount']}</strong><span>roots with CNS refs</span></div>
      <div class="metric"><strong>{summary['totalTextEntryCountInRootRanges']}</strong><span>text entries in ranges</span></div>
      <div class="metric"><strong>false</strong><span>direct execution proof</span></div>
    </div>
  </section>

  <section>
    <div class="head"><h2>Selector Slot Alias Groups</h2><span>0x4f mode1 cross-writer는 route producer가 아닌 same-group slot normalization 후보</span></div>
    <div class="body">
      <p class="muted">동일 selector group 안에서 여러 slot/root가 존재하는 경우만 표시한다. cross-writer 4개는 모두 같은 group의 <code>slot 1 -> slot 0</code> 형태이며, pre-entry 맵 이동 producer로 승격하지 않는다.</p>
      {alias_group_table(payload.get('selectorAliasGroups') or [])}
    </div>
  </section>

  <section>
    <div class="head"><h2>Root cards</h2><span>대사 그룹이 있는 root는 기본 펼침</span></div>
    <div class="body">
      {''.join(root_blocks)}
    </div>
  </section>
</main>
<script>
window.HWANSE_SELECTOR_ROOT_STRUCTURE_REVIEW_READY = {{
  selectorRootStructureReviewImplemented: true,
  promotionStatus: "{PROMOTION_STATUS}",
  selectorRootCount: {summary['selectorRootCount']},
  selectorRows: {summary['selectorRows']},
  selectorGroupCount: {summary['selectorGroupCount']},
  multiSlotGroupCount: {summary['multiSlotGroupCount']},
  multiSlotGroupsWithFieldMapsCount: {summary['multiSlotGroupsWithFieldMapsCount']},
  multiSlotGroupsSameFieldMapsCount: {summary['multiSlotGroupsSameFieldMapsCount']},
  opcode4fMode1SelectorWriterCount: {summary['opcode4fMode1SelectorWriterCount']},
  opcode4fMode1SelfWriterCount: {summary['opcode4fMode1SelfWriterCount']},
  opcode4fMode1CrossAliasNormalizationCount: {summary['opcode4fMode1CrossAliasNormalizationCount']},
  opcode4fCrossAliasRowsAllSameGroup: {str(summary['opcode4fCrossAliasRowsAllSameGroup']).lower()},
  opcode4fCrossAliasRowsAllSlot1ToSlot0: {str(summary['opcode4fCrossAliasRowsAllSlot1ToSlot0']).lower()},
  opcode4fCrossAliasRowsPromoteProducer: {str(summary['opcode4fCrossAliasRowsPromoteProducer']).lower()},
  rootsWithSceneSeqCount: {summary['rootsWithSceneSeqCount']},
  sequenceRootWithResourceStructureCount: {summary['sequenceRootWithResourceStructureCount']},
  rootsWithResourceRefCount: {summary['rootsWithResourceRefCount']},
  totalTextEntryCountInRootRanges: {summary['totalTextEntryCountInRootRanges']},
  directExecutionProofFound: false,
  commandOpcodeProofFound: false,
  summary: {summary_json}
}};
</script>
</body>
</html>
"""


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--selectors", type=Path, default=OUT / "save_scene_selectors.json")
    parser.add_argument("--sequence-review", type=Path, default=OUT / "scene_text_sequence_review.json")
    parser.add_argument("--runtime-selector-writes", type=Path, default=OUT / "save_selector_runtime_selector_byte_writes.json")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--web-dir", type=Path, default=WEB)
    args = parser.parse_args()

    data = args.exe.read_bytes()
    sections = read_sections(data)
    selectors = load_json(args.selectors, [])
    sequence_review = load_json(args.sequence_review, {})
    runtime_selector_writes = load_json(args.runtime_selector_writes, {})
    payload = analyze(data, sections, selectors, sequence_review, runtime_selector_writes)

    args.out_dir.mkdir(parents=True, exist_ok=True)
    args.web_dir.mkdir(parents=True, exist_ok=True)
    json_path = args.out_dir / "selector_root_structure_review.json"
    web_path = args.web_dir / "selector_root_structure_review.html"
    html_text = render_html(payload)
    json_path.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
    web_path.write_text(html_text, encoding="utf-8")
    print(f"wrote {json_path}")
    print(f"wrote {web_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
