#!/usr/bin/env python3
"""Group EXE text-sequence table entries by nearby scene/resource records."""
from __future__ import annotations

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

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

from probe_exe_scene_tables import read_sections, va_to_offset
from summarize_story_prompts import extract_script_text_lines


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
TEXT_ENTRY_OPCODE = 0x032F
TEXT_ENTRY_SENTINEL = 0x0084
MAX_PROMPT_SPAN = 0x900
MAX_SCENE_DISTANCE = 0x9000
SEQUENCE_SPLIT_GAP = 0x500
CONTROL_SCAN_LIMIT = 0x80
CONTROL_TARGET_RESOLVE_GAP = 0x80


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


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


def dword_at(data: bytes, offset: int) -> int:
    return struct.unpack_from("<I", data, offset)[0]


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


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


def read_va_bytes(data: bytes, sections: list[dict], va: int, size: int) -> bytes:
    offset = va_to_offset(sections, va)
    if offset is None:
        return b""
    return data[offset : min(len(data), offset + size)]


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


def text_lines(text: str) -> list[str]:
    return [line.strip() for line in str(text or "").splitlines() if line.strip()]


def stream_sample(data: bytes, sections: list[dict], text_va: int) -> tuple[list[dict], str]:
    sample = read_va_bytes(data, sections, text_va, MAX_PROMPT_SPAN)
    lines = extract_script_text_lines(sample, text_va)
    visible = [row["text"] for row in lines if row.get("text")]
    return lines, compact_text("\n".join(visible[:8]), 220)


def looks_like_text_stream(data: bytes, sections: list[dict], text_va: int) -> tuple[bool, list[dict], str]:
    if section_for_va(sections, text_va) is None:
        return False, [], ""
    head = read_va_bytes(data, sections, text_va, 0x18)
    if not head:
        return False, [], ""
    has_text_opcode = b"\x40\x09" in head[:8] or b"\x40\x0b" in head[:8]
    lines, sample = stream_sample(data, sections, text_va)
    has_hangul = any(
        "\u3131" <= char <= "\u318e" or "\uac00" <= char <= "\ud7a3"
        for row in lines
        for char in str(row.get("text") or "")
    )
    return has_text_opcode and has_hangul, lines, sample


def find_text_entries(data: bytes, sections: list[dict]) -> list[dict]:
    entries = []
    seen = set()
    for section in sections:
        if section["name"] not in {".data", ".rdata"}:
            continue
        start = section["raw"]
        end = section["raw"] + section["raw_size"] - 12
        for offset in range(start, max(start, end), 4):
            if dword_at(data, offset) != TEXT_ENTRY_OPCODE:
                continue
            text_va = dword_at(data, offset + 4)
            sentinel = dword_at(data, offset + 8)
            if sentinel != TEXT_ENTRY_SENTINEL:
                continue
            ok, lines, sample = looks_like_text_stream(data, sections, text_va)
            if not ok:
                continue
            entry_va = offset_to_va(sections, offset)
            if entry_va is None or entry_va in seen:
                continue
            seen.add(entry_va)
            entries.append({
                "entryVa": entry_va,
                "entryVaHex": hex32(entry_va),
                "textVa": text_va,
                "textVaHex": hex32(text_va),
                "sentinelHex": hex32(sentinel),
                "sample": sample,
                "lineCountInProbe": len(lines),
            })
    entries.sort(key=lambda row: row["entryVa"])
    return entries


def next_entry_at_or_after(entry_vas: list[int], target_va: int) -> int | None:
    lo = 0
    hi = len(entry_vas)
    while lo < hi:
        mid = (lo + hi) // 2
        if entry_vas[mid] < target_va:
            lo = mid + 1
        else:
            hi = mid
    if lo >= len(entry_vas):
        return None
    return entry_vas[lo]


def annotate_control_pointers(data: bytes, sections: list[dict], entries: list[dict]) -> list[dict]:
    entry_vas = [row["entryVa"] for row in entries]
    entry_set = set(entry_vas)
    for index, entry in enumerate(entries):
        next_entry_va = entry_vas[index + 1] if index + 1 < len(entry_vas) else None
        scan_start_va = entry["entryVa"] + 12
        scan_end_va = entry["entryVa"] + CONTROL_SCAN_LIMIT
        if next_entry_va is not None:
            scan_end_va = min(scan_end_va, next_entry_va)
        scan_start = va_to_offset(sections, scan_start_va)
        scan_end = va_to_offset(sections, scan_end_va)
        pointers = []
        if scan_start is None or scan_end is None or scan_end <= scan_start:
            entry["controlPointers"] = pointers
            continue
        for offset in range(scan_start, scan_end - 3, 4):
            value = dword_at(data, offset)
            if section_for_va(sections, value) is None:
                continue
            resolved = value if value in entry_set else next_entry_at_or_after(entry_vas, value)
            resolved_gap = None if resolved is None else resolved - value
            if resolved is not None and resolved_gap is not None and 0 <= resolved_gap <= CONTROL_TARGET_RESOLVE_GAP:
                pointer_va = offset_to_va(sections, offset)
                opcode_va = None if pointer_va is None else pointer_va - 4
                opcode_offset = None if opcode_va is None else va_to_offset(sections, opcode_va)
                opcode = dword_at(data, opcode_offset) if opcode_offset is not None and opcode_offset >= 0 else None
                pointers.append({
                    "pointerVa": pointer_va,
                    "pointerVaHex": hex32(pointer_va),
                    "opcodeVaHex": hex32(opcode_va),
                    "opcodeHex": hex32(opcode),
                    "targetVa": value,
                    "targetVaHex": hex32(value),
                    "resolvedEntryVa": resolved,
                    "resolvedEntryVaHex": hex32(resolved),
                    "resolvedGap": resolved_gap,
                    "evidenceStatus": "control-pointer-candidate",
                })
        entry["controlPointers"] = pointers
    return entries


def prompt_addr(prompt: dict, key: str) -> int | None:
    value = prompt.get(key)
    if isinstance(value, int):
        return value
    if isinstance(value, str) and value.startswith("0x"):
        return int(value, 16)
    return None


def prompt_index(prompts_summary: dict) -> list[dict]:
    rows = []
    for prompt in prompts_summary.get("prompts") or []:
        addrs = []
        for key in ("scanStartVaHex", "renderVaHex", "startVaHex", "waitVaHex", "endVaHex"):
            value = prompt_addr(prompt, key)
            if value is not None:
                addrs.append(value)
        for value in prompt.get("lineVas") or []:
            if isinstance(value, str) and value.startswith("0x"):
                addrs.append(int(value, 16))
        if not addrs:
            continue
        rows.append({
            "id": prompt.get("id", ""),
            "classification": prompt.get("classification", ""),
            "status": prompt.get("status", ""),
            "displayText": prompt.get("displayText") or prompt.get("text") or "",
            "startVa": min(addrs),
            "endVa": max(addrs),
            "startVaHex": hex32(min(addrs)),
            "endVaHex": hex32(max(addrs)),
        })
    return sorted(rows, key=lambda row: row["startVa"])


def prompts_for_span(prompts: list[dict], start_va: int, end_va: int) -> list[dict]:
    rows = []
    for prompt in prompts:
        if prompt["endVa"] < start_va:
            continue
        if prompt["startVa"] > end_va:
            if prompt["startVa"] - start_va > MAX_PROMPT_SPAN * 2:
                break
            continue
        rows.append(prompt)
    return rows


def selector_contexts(selectors: list[dict]) -> list[dict]:
    rows = []
    seen = set()
    for row in selectors:
        root_hex = row.get("selectedPointerHex")
        if not isinstance(root_hex, str) or not root_hex.startswith("0x"):
            continue
        root = int(root_hex, 16)
        if root in seen:
            continue
        seen.add(root)
        selector = f"{row.get('group')}:{row.get('slot')}"
        rows.append({
            "selector": selector,
            "rootVa": root,
            "rootVaHex": root_hex,
            "fieldMaps": row.get("fieldMaps") or [],
            "linkedCns": row.get("linkedCns") or [],
        })
    rows.sort(key=lambda row: row["rootVa"])
    for index, row in enumerate(rows):
        next_root = rows[index + 1]["rootVa"] if index + 1 < len(rows) else row["rootVa"] + 0x100000
        row["endVa"] = next_root
        row["endVaHex"] = hex32(next_root)
    return rows


def selector_for_entry(entry_va: int, selectors: list[dict]) -> dict | None:
    for row in selectors:
        if row["rootVa"] <= entry_va < row["endVa"]:
            merged = dict(row)
            merged["distance"] = entry_va - row["rootVa"]
            merged["evidenceStatus"] = "selector-root-range-candidate"
            merged["contextKind"] = "selector-root"
            merged["contextLabel"] = f"selector {row['selector']}"
            return merged
    return None


def scene_records(manifest: list[dict], links: dict) -> list[dict]:
    records = {}
    for row in manifest:
        if not isinstance(row.get("recordVa"), int):
            continue
        records[row["recordVa"]] = {
            "map": row.get("map", ""),
            "sceneIdHex": row.get("sceneIdHex", ""),
            "eventKind": "",
            "recordVa": row["recordVa"],
            "recordVaHex": row.get("recordVaHex") or hex32(row["recordVa"]),
            "source": "scene_manifest",
            "fieldMaps": [],
            "tilesets": row.get("tilesets") or [],
            "resources": [item.get("filename") or item.get("name") for item in row.get("resources") or []],
        }
    for map_name, payload in (links or {}).items():
        for row in payload.get("records") or []:
            record_va = row.get("recordVa")
            if not isinstance(record_va, int):
                continue
            existing = records.get(record_va, {})
            records[record_va] = {
                "map": row.get("source") or map_name,
                "sceneIdHex": row.get("sceneIdHex", existing.get("sceneIdHex", "")),
                "eventKind": row.get("eventKind", ""),
                "recordVa": record_va,
                "recordVaHex": hex32(record_va),
                "source": "scene_links",
                "fieldMaps": row.get("fieldMaps") or payload.get("fieldMaps") or [],
                "tilesets": row.get("tilesets") or payload.get("tilesets") or [],
                "resources": row.get("other") or payload.get("other") or [],
            }
    return sorted(records.values(), key=lambda row: row["recordVa"])


def nearest_scene(entry_va: int, scenes: list[dict]) -> dict:
    best = None
    best_distance = 10**12
    for scene in scenes:
        distance = abs(scene["recordVa"] - entry_va)
        if distance < best_distance:
            best = scene
            best_distance = distance
    if best is None or best_distance > MAX_SCENE_DISTANCE:
        return {
            "map": "unbound",
            "sceneIdHex": "",
            "recordVa": None,
            "recordVaHex": "",
            "eventKind": "",
            "distance": None,
            "evidenceStatus": "unbound-text-sequence",
            "fieldMaps": [],
            "tilesets": [],
            "resources": [],
        }
    merged = dict(best)
    merged["distance"] = best_distance
    merged["evidenceStatus"] = "nearest-scene-proximity-candidate"
    merged["contextKind"] = "scene-proximity"
    merged["contextLabel"] = merged.get("map") or "scene-proximity"
    return merged


def context_for_entry(entry_va: int, selectors: list[dict], scenes: list[dict]) -> dict:
    selector = selector_for_entry(entry_va, selectors)
    scene = nearest_scene(entry_va, scenes)
    if selector is not None:
        selector["nearestScene"] = {
            "map": scene.get("map"),
            "sceneIdHex": scene.get("sceneIdHex"),
            "recordVaHex": scene.get("recordVaHex"),
            "distance": scene.get("distance"),
            "evidenceStatus": scene.get("evidenceStatus"),
        }
        return selector
    scene["nearestScene"] = {
        "map": scene.get("map"),
        "sceneIdHex": scene.get("sceneIdHex"),
        "recordVaHex": scene.get("recordVaHex"),
        "distance": scene.get("distance"),
        "evidenceStatus": scene.get("evidenceStatus"),
    }
    return scene


def attach_prompts(entries: list[dict], prompts: list[dict]) -> list[dict]:
    for index, entry in enumerate(entries):
        next_text_va = None
        for other in entries[index + 1 : index + 12]:
            if other["textVa"] > entry["textVa"]:
                next_text_va = other["textVa"]
                break
        span_end = min(
            entry["textVa"] + MAX_PROMPT_SPAN,
            next_text_va - 1 if next_text_va and next_text_va - entry["textVa"] <= MAX_PROMPT_SPAN else entry["textVa"] + MAX_PROMPT_SPAN,
        )
        matched = prompts_for_span(prompts, entry["textVa"], span_end)
        entry["textSpanEndVa"] = span_end
        entry["textSpanEndVaHex"] = hex32(span_end)
        entry["promptCount"] = len(matched)
        entry["prompts"] = [
            {
                "id": prompt["id"],
                "status": prompt["status"],
                "classification": prompt["classification"],
                "startVaHex": prompt["startVaHex"],
                "endVaHex": prompt["endVaHex"],
                "displayText": prompt["displayText"],
                "sample": compact_text(prompt["displayText"], 220),
            }
            for prompt in matched
        ]
        entry["choicePrompts"] = [
            prompt
            for prompt in entry["prompts"]
            if prompt.get("status") == "choice-marker-delimited"
        ]
        if matched:
            entry["sample"] = compact_text("\n".join(prompt["displayText"] for prompt in matched[:3]), 260)
    return entries


def entry_ref(entry: dict | None) -> dict | None:
    if entry is None:
        return None
    return {
        "entryVaHex": entry["entryVaHex"],
        "textVaHex": entry["textVaHex"],
        "promptCount": entry.get("promptCount", 0),
        "sample": entry.get("sample") or "",
    }


def build_choice_reviews(seq_entries: list[dict]) -> list[dict]:
    by_entry_va = {row["entryVa"]: row for row in seq_entries}
    reviews = []
    for index, entry in enumerate(seq_entries):
        choices = entry.get("choicePrompts") or []
        if not choices:
            continue
        fallthrough = seq_entries[index + 1] if index + 1 < len(seq_entries) else None
        candidates = []
        if fallthrough is not None:
            candidates.append({
                "kind": "fallthrough-candidate",
                "label": "next entry",
                "target": entry_ref(fallthrough),
                "control": None,
                "exitPointers": [
                    {
                        **pointer,
                        "resolvedTarget": entry_ref(by_entry_va.get(pointer.get("resolvedEntryVa"))),
                    }
                    for pointer in fallthrough.get("controlPointers") or []
                ],
            })
        seen_targets = {fallthrough["entryVa"]} if fallthrough is not None else set()
        for pointer in entry.get("controlPointers") or []:
            target_entry = by_entry_va.get(pointer.get("resolvedEntryVa"))
            if target_entry is not None and target_entry["entryVa"] in seen_targets:
                continue
            if target_entry is not None:
                seen_targets.add(target_entry["entryVa"])
            candidates.append({
                "kind": "pointer-target-candidate",
                "label": pointer["targetVaHex"],
                "target": entry_ref(target_entry),
                "control": pointer,
                "exitPointers": [
                    {
                        **exit_pointer,
                        "resolvedTarget": entry_ref(by_entry_va.get(exit_pointer.get("resolvedEntryVa"))),
                    }
                    for exit_pointer in (target_entry.get("controlPointers") if target_entry else []) or []
                ],
            })
        reviews.append({
            "entryVaHex": entry["entryVaHex"],
            "textVaHex": entry["textVaHex"],
            "promptIds": [prompt.get("id", "") for prompt in choices],
            "options": [
                {
                    "promptId": prompt.get("id", ""),
                    "startVaHex": prompt.get("startVaHex", ""),
                    "lines": text_lines(prompt.get("displayText") or ""),
                }
                for prompt in choices
            ],
            "sample": entry.get("sample") or "",
            "candidateCount": len(candidates),
            "candidates": candidates,
            "evidenceStatus": "choice-branch-candidate",
            "note": "Option-to-target order is not proven yet; fallthrough and control-pointer targets are shown for manual review.",
        })
    return reviews


def build_sequences(entries: list[dict], selectors: list[dict], scenes: list[dict], prompts: list[dict]) -> dict:
    entries = attach_prompts(entries, prompts)
    for entry in entries:
        entry["context"] = context_for_entry(entry["entryVa"], selectors, scenes)

    grouped = defaultdict(list)
    for entry in entries:
        context = entry["context"]
        key = (
            context.get("contextKind") or "unknown",
            context.get("selector") or "",
            context.get("rootVaHex") or "",
            context.get("map") or "",
            context.get("sceneIdHex") or "",
            context.get("recordVaHex") or "",
        )
        grouped[key].append(entry)

    scene_groups = []
    for group_index, (key, rows) in enumerate(sorted(grouped.items(), key=lambda item: min(row["entryVa"] for row in item[1])), start=1):
        rows.sort(key=lambda row: row["entryVa"])
        context = dict(rows[0]["context"])
        sequences = []
        current = []
        for row in rows:
            if current and row["entryVa"] - current[-1]["entryVa"] > SEQUENCE_SPLIT_GAP:
                sequences.append(current)
                current = []
            current.append(row)
        if current:
            sequences.append(current)

        sequence_rows = []
        for seq_index, seq_entries in enumerate(sequences, start=1):
            seen_prompts = []
            seen_ids = set()
            for entry in seq_entries:
                for prompt in entry.get("prompts") or []:
                    if prompt["id"] in seen_ids:
                        continue
                    seen_ids.add(prompt["id"])
                    seen_prompts.append(prompt)
            nearest_counts = Counter(
                (
                    (entry.get("context") or {}).get("nearestScene") or {}
                ).get("map") or "unbound"
                for entry in seq_entries
            )
            sequence_rows.append({
                "id": f"scene-seq-{group_index:03d}-{seq_index:02d}",
                "entryStartVaHex": hex32(seq_entries[0]["entryVa"]),
                "entryEndVaHex": hex32(seq_entries[-1]["entryVa"]),
                "textStartVaHex": hex32(min(row["textVa"] for row in seq_entries)),
                "textEndVaHex": hex32(max(row["textSpanEndVa"] for row in seq_entries)),
                "entryCount": len(seq_entries),
                "promptCount": len(seen_prompts),
                "sample": compact_text("\n".join(prompt["displayText"] for prompt in seen_prompts[:5]), 420)
                or compact_text("\n".join(row["sample"] for row in seq_entries[:4]), 420),
                "nearestSceneCounts": dict(nearest_counts),
                "entries": seq_entries,
                "prompts": seen_prompts,
                "choiceReviews": build_choice_reviews(seq_entries),
            })

        scene_groups.append({
            "id": f"scene-text-group-{group_index:03d}",
            "contextKind": context.get("contextKind") or "",
            "contextLabel": context.get("contextLabel") or context.get("map") or "unbound",
            "selector": context.get("selector") or "",
            "rootVaHex": context.get("rootVaHex") or "",
            "rootEndVaHex": context.get("endVaHex") or "",
            "map": context.get("map") or "unbound",
            "sceneIdHex": context.get("sceneIdHex") or "",
            "recordVaHex": context.get("recordVaHex") or "",
            "eventKind": context.get("eventKind") or "",
            "distanceMin": min((row["context"].get("distance") or 0) for row in rows),
            "distanceMax": max((row["context"].get("distance") or 0) for row in rows),
            "evidenceStatus": context.get("evidenceStatus"),
            "fieldMaps": context.get("fieldMaps") or [],
            "tilesets": context.get("tilesets") or [],
            "resources": context.get("resources") or context.get("linkedCns") or [],
            "nearestScenes": [
                {"map": name, "count": count}
                for name, count in Counter(
                    ((row.get("context") or {}).get("nearestScene") or {}).get("map") or "unbound"
                    for row in rows
                ).most_common(8)
            ],
            "entryCount": len(rows),
            "promptCount": sum(seq["promptCount"] for seq in sequence_rows),
            "sequenceCount": len(sequence_rows),
            "choiceCount": sum(len(seq["choiceReviews"]) for seq in sequence_rows),
            "sequences": sequence_rows,
        })

    return {
        "scope": "EXE text-sequence entries grouped by context, with choice/control-pointer branch candidates for manual story-order review.",
        "entryPattern": "u32 0x0000032f, u32 text-stream VA, u32 0x00000084",
        "evidenceNote": "Text entries are grouped by selector/root range when available; nearest scene/resource labels and choice/control-pointer branches remain candidates. This is useful for review, not yet proof that the scene dispatch directly calls every target.",
        "textEntryCount": len(entries),
        "selectorContextCount": len(selectors),
        "sceneGroupCount": len(scene_groups),
        "sequenceCount": sum(group["sequenceCount"] for group in scene_groups),
        "choiceCount": sum(group["choiceCount"] for group in scene_groups),
        "promptCount": sum(group["promptCount"] for group in scene_groups),
        "evidenceCounts": dict(Counter(group["evidenceStatus"] for group in scene_groups)),
        "groups": scene_groups,
    }


def chip_list(values: list[str], limit: int = 10) -> str:
    chips = []
    for value in values[:limit]:
        chips.append(f"<span class=\"chip\">{html.escape(str(value))}</span>")
    if len(values) > limit:
        chips.append(f"<span class=\"chip muted\">+{len(values) - limit}</span>")
    return " ".join(chips) or '<span class="muted">-</span>'


def branch_review_html(choice_reviews: list[dict]) -> str:
    if not choice_reviews:
        return ""
    items = []
    for review in choice_reviews:
        option_blocks = []
        for option in review.get("options") or []:
            option_blocks.append(
                "<div class=\"choice-options\">"
                f"<div><code>{html.escape(option.get('promptId') or '')}</code> "
                f"<code>{html.escape(option.get('startVaHex') or '')}</code></div>"
                f"<pre>{html.escape(chr(10).join(option.get('lines') or []))}</pre>"
                "</div>"
            )
        candidate_rows = []
        for candidate in review.get("candidates") or []:
            target = candidate.get("target") or {}
            control = candidate.get("control") or {}
            exits = []
            for pointer in candidate.get("exitPointers") or []:
                resolved = pointer.get("resolvedTarget") or {}
                exits.append(
                    f"{html.escape(pointer.get('opcodeHex') or '')} → "
                    f"{html.escape(pointer.get('targetVaHex') or '')}"
                    + (
                        f" → <code>{html.escape(resolved.get('entryVaHex') or '')}</code>"
                        f" {html.escape(compact_text(resolved.get('sample') or '', 90))}"
                        if resolved
                        else ""
                    )
                )
            candidate_rows.append(
                "<tr>"
                f"<td>{html.escape(candidate.get('kind') or '')}</td>"
                f"<td>{html.escape(control.get('opcodeHex') or 'fallthrough')}</td>"
                f"<td>{html.escape(control.get('targetVaHex') or '-')}</td>"
                f"<td><code>{html.escape(target.get('entryVaHex') or '')}</code><br>"
                f"{html.escape(compact_text(target.get('sample') or '', 180))}</td>"
                f"<td>{'<br>'.join(exits) if exits else '<span class=\"muted\">-</span>'}</td>"
                "</tr>"
            )
        if not candidate_rows:
            candidate_rows.append('<tr><td colspan="5" class="muted">No branch target candidates.</td></tr>')
        items.append(
            "<details class=\"choice-review\">"
            f"<summary><strong>choice at {html.escape(review['entryVaHex'])}</strong> "
            f"{review.get('candidateCount', 0)} branch candidates · {html.escape(review['evidenceStatus'])}</summary>"
            f"<p class=\"muted\">{html.escape(review.get('note') or '')}</p>"
            f"<div class=\"choice-option-wrap\">{''.join(option_blocks)}</div>"
            "<table class=\"branch-table\"><thead><tr>"
            "<th>candidate</th><th>control opcode</th><th>raw target</th><th>resolved text</th><th>branch exit pointers</th>"
            "</tr></thead>"
            f"<tbody>{''.join(candidate_rows)}</tbody></table>"
            "</details>"
        )
    return (
        "<section class=\"branch-review-panel\">"
        "<h3>Choice / Branch Candidates</h3>"
        "<p class=\"muted\">선택지와 직후 control pointer를 함께 보여준다. 선택지 순서와 target 순서는 아직 확정하지 않는다.</p>"
        f"{''.join(items)}"
        "</section>"
    )


def reader_branch_html(review: dict) -> str:
    candidate_blocks = []
    for candidate in review.get("candidates") or []:
        target = candidate.get("target") or {}
        control = candidate.get("control") or {}
        exit_bits = []
        for pointer in candidate.get("exitPointers") or []:
            resolved = pointer.get("resolvedTarget") or {}
            if resolved:
                exit_bits.append(
                    f"<li><code>{html.escape(pointer.get('opcodeHex') or '')}</code> "
                    f"→ <code>{html.escape(pointer.get('targetVaHex') or '')}</code> "
                    f"→ <code>{html.escape(resolved.get('entryVaHex') or '')}</code>"
                    f"<p>{html.escape(compact_text(resolved.get('sample') or '', 160))}</p></li>"
                )
        candidate_blocks.append(
            "<div class=\"reader-branch-card\">"
            f"<div class=\"reader-branch-kind\">{html.escape(candidate.get('kind') or '')}</div>"
            f"<div><code>{html.escape(control.get('opcodeHex') or 'fallthrough')}</code> "
            f"{html.escape('→ ' + control.get('targetVaHex') if control.get('targetVaHex') else '')}</div>"
            f"<pre>{html.escape(target.get('sample') or '')}</pre>"
            + (
                f"<details><summary>branch exit pointers</summary><ul>{''.join(exit_bits)}</ul></details>"
                if exit_bits
                else ""
            )
            + "</div>"
        )
    return (
        "<details class=\"reader-branch\">"
        f"<summary>분기 후보 {len(candidate_blocks)}개 · <code>{html.escape(review.get('entryVaHex') or '')}</code></summary>"
        f"<p class=\"muted\">{html.escape(review.get('note') or '')}</p>"
        f"<div class=\"reader-branch-grid\">{''.join(candidate_blocks) or '<span class=\"muted\">후보 없음</span>'}</div>"
        "</details>"
    )


def script_reader_page(summary: dict) -> str:
    group_blocks = []
    for group_index, group in enumerate(summary["groups"], start=1):
        sequence_blocks = []
        for seq in group["sequences"]:
            choice_by_prompt = {}
            for review in seq.get("choiceReviews") or []:
                for prompt_id in review.get("promptIds") or []:
                    choice_by_prompt[prompt_id] = review
            prompt_cards = []
            for prompt_index, prompt in enumerate(seq.get("prompts") or [], start=1):
                is_choice = prompt.get("id") in choice_by_prompt or prompt.get("status") == "choice-marker-delimited"
                card_class = "reader-prompt choice" if is_choice else "reader-prompt"
                prompt_cards.append(
                    f"<article class=\"{card_class}\" data-choice=\"{'1' if is_choice else '0'}\">"
                    "<div class=\"reader-prompt-meta\">"
                    f"<span>#{prompt_index:03d}</span>"
                    f"<code>{html.escape(prompt.get('id') or '')}</code>"
                    f"<code>{html.escape(prompt.get('startVaHex') or '')}</code>"
                    f"<span>{html.escape(prompt.get('status') or '')}</span>"
                    "</div>"
                    f"<pre>{html.escape(prompt.get('displayText') or '')}</pre>"
                    + (reader_branch_html(choice_by_prompt[prompt.get("id")]) if prompt.get("id") in choice_by_prompt else "")
                    + "</article>"
                )
            searchable = html.escape(
                " ".join(
                    [
                        group.get("id") or "",
                        group.get("contextLabel") or "",
                        group.get("map") or "",
                        seq.get("id") or "",
                        seq.get("sample") or "",
                    ]
                ),
                quote=True,
            )
            sequence_blocks.append(
                "<section class=\"reader-sequence\" "
                f"id=\"{html.escape(seq['id'])}\" data-search=\"{searchable}\">"
                "<header class=\"reader-sequence-head\">"
                f"<h3>{html.escape(seq['id'])}</h3>"
                f"<span>{seq['promptCount']} prompts</span>"
                f"<span>{len(seq.get('choiceReviews') or [])} choices</span>"
                f"<code>{html.escape(seq['entryStartVaHex'])}..{html.escape(seq['entryEndVaHex'])}</code>"
                "</header>"
                f"<p class=\"reader-sample\">{html.escape(seq.get('sample') or '')}</p>"
                f"<div class=\"reader-transcript\">{''.join(prompt_cards) or '<p class=\"muted\">No prompts.</p>'}</div>"
                "</section>"
            )
        group_label = group.get("contextLabel") or group.get("map") or group["id"]
        nearest_text = ", ".join(
            f"{row['map']}:{row['count']}" for row in group.get("nearestScenes") or []
        ) or "-"
        group_blocks.append(
            f"<details class=\"reader-group\" {'open' if group_index == 1 else ''}>"
            f"<summary><strong>{html.escape(group['id'])}</strong> {html.escape(group_label)} "
            f"<span>{group['sequenceCount']} seq</span> <span>{group['promptCount']} prompts</span> "
            f"<span>{group.get('choiceCount', 0)} choices</span> "
            f"<code>{html.escape(group.get('rootVaHex') or group.get('recordVaHex') or '')}</code></summary>"
            "<div class=\"reader-group-meta\">"
            f"<span>evidence {html.escape(group.get('evidenceStatus') or '')}</span>"
            f"<span>field maps {html.escape(', '.join(group.get('fieldMaps') or []) or '-')}</span>"
            f"<span>nearest {html.escape(nearest_text)}</span>"
            "</div>"
            f"{''.join(sequence_blocks)}"
            "</details>"
        )
    return "\n".join([
        "<!doctype html>",
        '<html lang="ko">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>Scene Script Reader</title>",
        "  <style>",
        "    body{margin:0;background:#f4f6f8;color:#17212b;font:15px/1.55 system-ui,sans-serif}",
        "    header.top{position:sticky;top:0;z-index:5;background:#ffffff;border-bottom:1px solid #d9e1e8;padding:14px 18px}",
        "    h1{font-size:22px;margin:0 0 8px} .sub{color:#64727f;margin:0}",
        "    .toolbar{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}",
        "    input[type=search]{font:inherit;padding:8px 10px;border:1px solid #c7d2dc;border-radius:6px;min-width:min(520px,100%)}",
        "    button,label{font:inherit;border:1px solid #c7d2dc;background:#fff;border-radius:6px;padding:8px 10px}",
        "    main{padding:16px;max-width:1180px;margin:0 auto}",
        "    .reader-group{background:#fff;border:1px solid #d9e1e8;border-radius:8px;margin:12px 0;overflow:hidden}",
        "    .reader-group>summary{cursor:pointer;padding:12px 14px;background:#eaf0f5;display:flex;gap:10px;align-items:center;flex-wrap:wrap}",
        "    .reader-group-meta{display:flex;gap:8px;flex-wrap:wrap;padding:10px 14px;color:#60707f;border-bottom:1px solid #edf1f5}",
        "    .reader-sequence{padding:14px;border-top:1px solid #edf1f5}",
        "    .reader-sequence-head{display:flex;gap:8px;align-items:center;flex-wrap:wrap}",
        "    .reader-sequence-head h3{font-size:17px;margin:0 8px 0 0}",
        "    .reader-sequence-head span{background:#eef3f7;border:1px solid #d7e0e8;border-radius:999px;padding:2px 8px;color:#4c5b68}",
        "    code{color:#8a4b00;background:#fff7df;border:1px solid #f0dfaa;border-radius:4px;padding:0 4px}",
        "    .reader-sample{background:#f8fafc;border:1px solid #e2e8ef;border-radius:6px;padding:8px;color:#4c5b68}",
        "    .reader-transcript{display:grid;gap:8px}",
        "    .reader-prompt{border:1px solid #d9e1e8;border-radius:7px;background:#fff;padding:10px}",
        "    .reader-prompt.choice{border-color:#d59b2f;background:#fff8e8}",
        "    .reader-prompt-meta{display:flex;gap:8px;flex-wrap:wrap;color:#687684;font-size:12px;margin-bottom:6px}",
        "    pre{white-space:pre-wrap;margin:0;font:15px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace}",
        "    .reader-branch{margin-top:10px;border-top:1px solid #ead6a8;padding-top:8px}",
        "    .reader-branch summary{cursor:pointer;color:#7a4d05}",
        "    .reader-branch-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:8px;margin-top:8px}",
        "    .reader-branch-card{background:#fff;border:1px solid #ead6a8;border-radius:6px;padding:8px}",
        "    .reader-branch-kind{font-weight:700;color:#7a4d05}",
        "    .reader-branch-card p{margin:4px 0;color:#596775}",
        "    .muted{color:#6b7885}",
        "    .hidden{display:none!important}",
        "  </style>",
        "</head>",
        "<body>",
        "  <header class=\"top\">",
        "    <h1>Scene Script Reader</h1>",
        f"    <p class=\"sub\">{html.escape(summary['evidenceNote'])}</p>",
        "    <div class=\"toolbar\">",
        "      <input id=\"search\" type=\"search\" placeholder=\"검색: group, map, 대사\" />",
        "      <label><input id=\"choiceOnly\" type=\"checkbox\" /> 선택지 있는 sequence만</label>",
        "      <button id=\"openAll\" type=\"button\">모두 펼치기</button>",
        "      <button id=\"closeAll\" type=\"button\">모두 접기</button>",
        "      <a href=\"scene_text_sequence_review.html\">근거표 보기</a>",
        "    </div>",
        "  </header>",
        "  <main>",
        f"    <p class=\"sub\">{summary['sceneGroupCount']} context groups · {summary['sequenceCount']} sequences · {summary['choiceCount']} choice candidates · {summary['promptCount']} prompts</p>",
        "    " + "\n    ".join(group_blocks),
        "  </main>",
        "  <script>",
        "    const search = document.getElementById('search');",
        "    const choiceOnly = document.getElementById('choiceOnly');",
        "    function applyFilter(){",
        "      const q = search.value.trim().toLowerCase();",
        "      const only = choiceOnly.checked;",
        "      document.querySelectorAll('.reader-sequence').forEach(seq => {",
        "        const text = (seq.dataset.search + ' ' + seq.innerText).toLowerCase();",
        "        const hasChoice = seq.querySelector('.reader-prompt.choice');",
        "        seq.classList.toggle('hidden', (q && !text.includes(q)) || (only && !hasChoice));",
        "      });",
        "      document.querySelectorAll('.reader-group').forEach(group => {",
        "        const visible = group.querySelector('.reader-sequence:not(.hidden)');",
        "        group.classList.toggle('hidden', !visible);",
        "        if (visible && (q || only)) group.open = true;",
        "      });",
        "    }",
        "    search.addEventListener('input', applyFilter); choiceOnly.addEventListener('change', applyFilter);",
        "    document.getElementById('openAll').onclick = () => document.querySelectorAll('.reader-group').forEach(d => d.open = true);",
        "    document.getElementById('closeAll').onclick = () => document.querySelectorAll('.reader-group').forEach(d => d.open = false);",
        "  </script>",
        "</body>",
        "</html>",
        "",
    ])


def group_map_refs(group: dict) -> list[dict]:
    priority = {"scene-map": 0, "selector-field-map": 1, "nearest-scene": 2}
    order = []
    refs_by_map = {}

    def add(map_name: str, evidence: str) -> None:
        if not map_name or map_name == "unbound":
            return
        if map_name not in refs_by_map:
            order.append(map_name)
            refs_by_map[map_name] = evidence
            return
        if priority.get(evidence, 9) < priority.get(refs_by_map[map_name], 9):
            refs_by_map[map_name] = evidence

    add(group.get("map") or "", "scene-map")
    for map_name in group.get("fieldMaps") or []:
        add(map_name, "selector-field-map")
    for row in group.get("nearestScenes") or []:
        add(row.get("map") or "", "nearest-scene")
    return [{"map": map_name, "evidence": refs_by_map[map_name]} for map_name in order]


def compact_candidate_for_player(candidate: dict) -> dict:
    target = candidate.get("target") or {}
    control = candidate.get("control") or {}
    return {
        "kind": candidate.get("kind") or "",
        "label": candidate.get("label") or "",
        "controlOpcodeHex": control.get("opcodeHex") or "fallthrough",
        "rawTargetVaHex": control.get("targetVaHex") or "",
        "targetEntryVaHex": target.get("entryVaHex") or "",
        "targetTextVaHex": target.get("textVaHex") or "",
        "targetSample": target.get("sample") or "",
    }


def compact_player_data(summary: dict) -> dict:
    groups = []
    map_index = defaultdict(list)
    for group in summary.get("groups") or []:
        group_maps = group_map_refs(group)
        compact_group = {
            "id": group.get("id") or "",
            "contextKind": group.get("contextKind") or "",
            "contextLabel": group.get("contextLabel") or "",
            "selector": group.get("selector") or "",
            "rootVaHex": group.get("rootVaHex") or "",
            "rootEndVaHex": group.get("rootEndVaHex") or "",
            "map": group.get("map") or "",
            "sceneIdHex": group.get("sceneIdHex") or "",
            "recordVaHex": group.get("recordVaHex") or "",
            "evidenceStatus": group.get("evidenceStatus") or "",
            "fieldMaps": group.get("fieldMaps") or [],
            "nearestScenes": group.get("nearestScenes") or [],
            "mapRefs": group_maps,
            "entryCount": group.get("entryCount") or 0,
            "promptCount": group.get("promptCount") or 0,
            "sequenceCount": group.get("sequenceCount") or 0,
            "choiceCount": group.get("choiceCount") or 0,
            "sequences": [],
        }
        for sequence in group.get("sequences") or []:
            prompt_entry = {}
            prompt_text = {}
            for entry in sequence.get("entries") or []:
                for prompt in entry.get("prompts") or []:
                    prompt_id = prompt.get("id") or ""
                    if not prompt_id or prompt_id in prompt_entry:
                        continue
                    prompt_entry[prompt_id] = entry.get("entryVaHex") or ""
                    prompt_text[prompt_id] = entry.get("textVaHex") or ""
            prompts = []
            seen_prompt_ids = set()
            for prompt in sequence.get("prompts") or []:
                prompt_id = prompt.get("id") or ""
                if not prompt_id or prompt_id in seen_prompt_ids:
                    continue
                seen_prompt_ids.add(prompt_id)
                prompts.append({
                    "id": prompt_id,
                    "status": prompt.get("status") or "",
                    "classification": prompt.get("classification") or "",
                    "startVaHex": prompt.get("startVaHex") or "",
                    "endVaHex": prompt.get("endVaHex") or "",
                    "entryVaHex": prompt_entry.get(prompt_id) or "",
                    "textVaHex": prompt_text.get(prompt_id) or "",
                    "displayText": prompt.get("displayText") or "",
                })
            choices = []
            for review in sequence.get("choiceReviews") or []:
                choices.append({
                    "entryVaHex": review.get("entryVaHex") or "",
                    "textVaHex": review.get("textVaHex") or "",
                    "promptIds": review.get("promptIds") or [],
                    "options": review.get("options") or [],
                    "candidates": [compact_candidate_for_player(candidate) for candidate in review.get("candidates") or []],
                    "evidenceStatus": review.get("evidenceStatus") or "",
                    "note": review.get("note") or "",
                })
            compact_sequence = {
                "id": sequence.get("id") or "",
                "entryStartVaHex": sequence.get("entryStartVaHex") or "",
                "entryEndVaHex": sequence.get("entryEndVaHex") or "",
                "textStartVaHex": sequence.get("textStartVaHex") or "",
                "textEndVaHex": sequence.get("textEndVaHex") or "",
                "entryCount": sequence.get("entryCount") or 0,
                "promptCount": sequence.get("promptCount") or 0,
                "choiceCount": len(choices),
                "sample": sequence.get("sample") or "",
                "nearestSceneCounts": sequence.get("nearestSceneCounts") or {},
                "prompts": prompts,
                "choices": choices,
            }
            compact_group["sequences"].append(compact_sequence)
            for ref in group_maps:
                map_index[ref["map"]].append({
                    "groupId": compact_group["id"],
                    "sequenceId": compact_sequence["id"],
                    "contextLabel": compact_group["contextLabel"],
                    "mapEvidence": ref["evidence"],
                    "sceneIdHex": compact_group["sceneIdHex"],
                    "recordVaHex": compact_group["recordVaHex"],
                    "rootVaHex": compact_group["rootVaHex"],
                    "evidenceStatus": compact_group["evidenceStatus"],
                    "promptCount": compact_sequence["promptCount"],
                    "choiceCount": compact_sequence["choiceCount"],
                    "sample": compact_sequence["sample"],
                })
        groups.append(compact_group)

    for rows in map_index.values():
        rows.sort(key=lambda row: (row["mapEvidence"] != "scene-map", row["groupId"], row["sequenceId"]))

    return {
        "scope": "Compact scene script player data derived from scene_text_sequence_review.json.",
        "evidenceNote": summary.get("evidenceNote") or "",
        "textEntryCount": summary.get("textEntryCount") or 0,
        "sceneGroupCount": summary.get("sceneGroupCount") or 0,
        "sequenceCount": summary.get("sequenceCount") or 0,
        "choiceCount": summary.get("choiceCount") or 0,
        "promptCount": summary.get("promptCount") or 0,
        "groups": groups,
        "mapIndex": dict(sorted(map_index.items(), key=lambda item: item[0])),
    }


def scene_script_player_page() -> str:
    return r"""<!doctype html>
<html lang="ko">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Scene Script Player</title>
    <style>
      :root{color-scheme:light;--bg:#f5f7fa;--fg:#17202a;--muted:#657485;--line:#d8e0e8;--head:#edf2f7;--link:#185abc;--warn:#9a5b00}
      *{box-sizing:border-box}
      body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
      main{max-width:1180px;margin:0 auto;padding:16px}
      header{display:flex;justify-content:space-between;gap:14px;align-items:flex-start;margin-bottom:14px}
      h1{font-size:23px;margin:0 0 6px;letter-spacing:0}
      a{color:var(--link);text-decoration:none}
      a:hover{text-decoration:underline}
      .sub,.muted{color:var(--muted)}
      .panel{background:#fff;border:1px solid var(--line);border-radius:8px;margin:12px 0;overflow:hidden}
      .panel-head{display:flex;justify-content:space-between;gap:10px;align-items:center;padding:11px 13px;background:var(--head);border-bottom:1px solid var(--line)}
      .panel-head h2{font-size:17px;margin:0}
      .body{padding:13px}
      .toolbar{display:grid;grid-template-columns:minmax(180px,260px) minmax(220px,1fr) minmax(220px,1fr);gap:8px}
      select,input,button{font:inherit;border:1px solid #c8d2dc;border-radius:6px;background:#fff;padding:8px 10px}
      button{cursor:pointer}
      button:disabled{cursor:not-allowed;opacity:.5}
      .script-layout{display:grid;grid-template-columns:minmax(0,1fr) 280px;gap:12px}
      .prompt-frame{min-height:230px;border:1px solid #cbd7e2;border-radius:8px;background:#fbfcfe;padding:14px;display:flex;flex-direction:column;gap:10px}
      .prompt-meta{display:flex;flex-wrap:wrap;gap:6px;color:var(--muted);font-size:12px}
      code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:#fff7df;border:1px solid #f0dfaa;border-radius:4px;padding:1px 4px;color:#8a4b00}
      pre{white-space:pre-wrap;margin:0;font:16px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace}
      .controls{display:flex;flex-wrap:wrap;gap:8px;margin-top:auto}
      .choice-buttons{display:grid;gap:8px;margin-top:8px}
      .choice-buttons button{text-align:left;background:#fff9ec;border-color:#e2b45a}
      .choice-note{font-size:12px;color:var(--warn)}
      .side-list{max-height:420px;overflow:auto;border:1px solid var(--line);border-radius:8px;background:#fff}
      .side-list button{display:block;width:100%;border:0;border-bottom:1px solid #edf1f5;border-radius:0;text-align:left;background:#fff;padding:8px}
      .side-list button.active{background:#eaf4ff}
      .side-list button.choice{border-left:4px solid #d28b15}
      .log{display:grid;gap:6px;max-height:220px;overflow:auto}
      .log-item{border:1px solid #edf1f5;border-radius:6px;background:#fbfcfe;padding:7px}
      .branch-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:8px;margin-top:8px}
      .branch-card{border:1px solid #e7d1a1;background:#fffaf0;border-radius:6px;padding:8px}
      .hidden{display:none!important}
      @media(max-width:860px){header{display:block}.toolbar{grid-template-columns:1fr}.script-layout{grid-template-columns:1fr}}
    </style>
  </head>
  <body>
    <main data-page="scene-script-player">
      <header>
        <div>
          <h1>Scene Script Player</h1>
          <p class="sub">scene별 대사 시퀀스를 한 프롬프트씩 재생한다. 선택지 분기는 control pointer 후보이므로 검증용으로만 본다.</p>
        </div>
        <nav><a href="../web/resource_reference_review.html">리소스 참조</a> · <a href="scene_text_sequence_review.html">근거표</a></nav>
      </header>

      <section class="panel">
        <div class="panel-head"><h2>scene 선택</h2><span id="statusText" class="muted">loading</span></div>
        <div class="body">
          <div class="toolbar">
            <select id="mapSelect" aria-label="map"></select>
            <select id="groupSelect" aria-label="scene group"></select>
            <select id="sequenceSelect" aria-label="sequence"></select>
          </div>
          <p id="contextText" class="sub"></p>
        </div>
      </section>

      <section class="panel">
        <div class="panel-head"><h2>프롬프트 재생</h2><span id="progressText" class="muted"></span></div>
        <div class="body script-layout">
          <div class="prompt-frame">
            <div id="promptMeta" class="prompt-meta"></div>
            <pre id="promptText"></pre>
            <div id="choiceArea" class="choice-buttons"></div>
            <div id="branchArea"></div>
            <div class="controls">
              <button id="prevButton" type="button">이전</button>
              <button id="nextButton" type="button">다음</button>
              <button id="resetButton" type="button">처음으로</button>
            </div>
          </div>
          <div class="side-list" id="promptList" aria-label="prompt list"></div>
        </div>
      </section>

      <section class="panel">
        <div class="panel-head"><h2>재생 로그</h2><span class="muted">선택한 분기 후보 기록</span></div>
        <div class="body"><div id="historyLog" class="log"></div></div>
      </section>
    </main>

    <script>
      const DATA_URL = "scene_script_player_data.json";
      const params = new URLSearchParams(location.search);
      const mapSelect = document.getElementById("mapSelect");
      const groupSelect = document.getElementById("groupSelect");
      const sequenceSelect = document.getElementById("sequenceSelect");
      const statusText = document.getElementById("statusText");
      const contextText = document.getElementById("contextText");
      const progressText = document.getElementById("progressText");
      const promptMeta = document.getElementById("promptMeta");
      const promptText = document.getElementById("promptText");
      const choiceArea = document.getElementById("choiceArea");
      const branchArea = document.getElementById("branchArea");
      const promptList = document.getElementById("promptList");
      const historyLog = document.getElementById("historyLog");
      const prevButton = document.getElementById("prevButton");
      const nextButton = document.getElementById("nextButton");
      const resetButton = document.getElementById("resetButton");
      let data = null;
      let currentGroup = null;
      let currentSequence = null;
      let promptIndex = 0;
      let history = [];

      function escapeHtml(value) {
        return String(value ?? "").replace(/[&<>"']/g, (char) => ({
          "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
        })[char]);
      }

      function compact(value, limit = 90) {
        const text = String(value || "").replace(/\s+/g, " ").trim();
        return text.length > limit ? `${text.slice(0, limit - 1)}...` : text;
      }

      function optionLines(review) {
        return (review?.options || []).flatMap((option) => option.lines || []).filter(Boolean);
      }

      function choiceByPromptId(sequence) {
        const map = new Map();
        for (const choice of sequence?.choices || []) {
          for (const promptId of choice.promptIds || []) map.set(promptId, choice);
        }
        return map;
      }

      function resolvePromptIndexByEntry(entryVaHex) {
        if (!entryVaHex || !currentSequence) return -1;
        return (currentSequence.prompts || []).findIndex((prompt) => prompt.entryVaHex === entryVaHex);
      }

      function groupById(id) {
        return (data?.groups || []).find((group) => group.id === id) || null;
      }

      function sequenceById(group, id) {
        return (group?.sequences || []).find((sequence) => sequence.id === id) || null;
      }

      function mapEntries(mapName) {
        return data?.mapIndex?.[mapName] || [];
      }

      function setOptions(select, rows, valueFn, labelFn, selected) {
        select.innerHTML = rows.map((row) => {
          const value = valueFn(row);
          const label = labelFn(row);
          return `<option value="${escapeHtml(value)}" ${value === selected ? "selected" : ""}>${escapeHtml(label)}</option>`;
        }).join("");
      }

      function refreshSelectors() {
        const maps = Object.keys(data?.mapIndex || {});
        const selectedMap = params.get("map") && maps.includes(params.get("map")) ? params.get("map") : (mapSelect.value || maps[0] || "");
        setOptions(mapSelect, maps, (map) => map, (map) => `${map} (${mapEntries(map).length})`, selectedMap);

        const entries = mapEntries(mapSelect.value);
        const groupIds = [...new Set(entries.map((entry) => entry.groupId))];
        const selectedGroup = params.get("group") && groupIds.includes(params.get("group")) ? params.get("group") : (groupSelect.value || groupIds[0] || "");
        setOptions(groupSelect, groupIds, (id) => id, (id) => {
          const group = groupById(id);
          return `${id} · ${group?.contextLabel || ""} · ${group?.promptCount || 0} prompts`;
        }, selectedGroup);

        currentGroup = groupById(groupSelect.value);
        const sequences = currentGroup?.sequences || [];
        const sequenceIds = sequences.map((sequence) => sequence.id);
        const selectedSeq = params.get("seq") && sequenceIds.includes(params.get("seq")) ? params.get("seq") : (sequenceSelect.value || sequenceIds[0] || "");
        setOptions(sequenceSelect, sequences, (sequence) => sequence.id, (sequence) => `${sequence.id} · ${sequence.promptCount} prompts · ${sequence.choiceCount} choices`, selectedSeq);
        currentSequence = sequenceById(currentGroup, sequenceSelect.value);
      }

      function renderPromptList() {
        const choiceMap = choiceByPromptId(currentSequence);
        promptList.innerHTML = (currentSequence?.prompts || []).map((prompt, index) => `
          <button type="button" class="${index === promptIndex ? "active " : ""}${choiceMap.has(prompt.id) ? "choice" : ""}" data-prompt-index="${index}">
            #${String(index + 1).padStart(3, "0")} ${escapeHtml(compact(prompt.displayText, 80))}
          </button>
        `).join("");
      }

      function renderHistory() {
        historyLog.innerHTML = history.length
          ? history.map((item) => `<div class="log-item"><code>${escapeHtml(item.from)}</code> ${escapeHtml(item.label)} → <code>${escapeHtml(item.to || "next")}</code></div>`).join("")
          : '<span class="muted">아직 선택한 분기가 없다.</span>';
      }

      function renderBranchCards(review) {
        if (!review) {
          branchArea.innerHTML = "";
          return;
        }
        branchArea.innerHTML = `
          <details>
            <summary class="choice-note">분기 후보 ${review.candidates.length}개 · 선택지와 target 순서는 미확정</summary>
            <div class="branch-grid">
              ${review.candidates.map((candidate, index) => `
                <div class="branch-card">
                  <strong>#${index + 1} ${escapeHtml(candidate.kind)}</strong><br>
                  <code>${escapeHtml(candidate.controlOpcodeHex)}</code> ${candidate.rawTargetVaHex ? `→ <code>${escapeHtml(candidate.rawTargetVaHex)}</code>` : ""}<br>
                  target <code>${escapeHtml(candidate.targetEntryVaHex || "-")}</code>
                  <p class="muted">${escapeHtml(compact(candidate.targetSample, 160))}</p>
                </div>
              `).join("")}
            </div>
          </details>
        `;
      }

      function renderPrompt() {
        const prompts = currentSequence?.prompts || [];
        const prompt = prompts[promptIndex];
        const choiceMap = choiceByPromptId(currentSequence);
        const choice = prompt ? choiceMap.get(prompt.id) : null;
        contextText.textContent = currentGroup
          ? `${currentGroup.id} · ${currentGroup.contextLabel || ""} · evidence ${currentGroup.evidenceStatus || ""} · field maps ${(currentGroup.fieldMaps || []).join(", ") || "-"}`
          : "";
        progressText.textContent = prompts.length ? `${promptIndex + 1}/${prompts.length}` : "0/0";
        if (!prompt) {
          promptMeta.innerHTML = "";
          promptText.textContent = "표시할 프롬프트가 없다.";
          choiceArea.innerHTML = "";
          renderBranchCards(null);
          renderPromptList();
          renderHistory();
          return;
        }
        promptMeta.innerHTML = [
          `#${String(promptIndex + 1).padStart(3, "0")}`,
          `<code>${escapeHtml(prompt.id)}</code>`,
          `<code>${escapeHtml(prompt.entryVaHex || "-")}</code>`,
          `<code>${escapeHtml(prompt.startVaHex || "-")}</code>`,
          escapeHtml(prompt.status || "")
        ].join(" ");
        promptText.textContent = prompt.displayText || "";
        if (choice) {
          const lines = optionLines(choice);
          choiceArea.innerHTML = `
            <div class="choice-note">선택지 프롬프트다. 버튼은 후보 target 순서에 맞춰 임시 연결한다.</div>
            ${lines.map((line, index) => `<button type="button" data-choice-index="${index}">${escapeHtml(line)}</button>`).join("")}
          `;
          nextButton.disabled = true;
        } else {
          choiceArea.innerHTML = "";
          nextButton.disabled = promptIndex >= prompts.length - 1;
        }
        prevButton.disabled = promptIndex <= 0;
        renderBranchCards(choice);
        renderPromptList();
        renderHistory();
      }

      function jumpTo(index) {
        const prompts = currentSequence?.prompts || [];
        promptIndex = Math.max(0, Math.min(index, Math.max(0, prompts.length - 1)));
        renderPrompt();
      }

      function chooseBranch(choiceIndex) {
        const prompt = currentSequence?.prompts?.[promptIndex];
        const choice = prompt ? choiceByPromptId(currentSequence).get(prompt.id) : null;
        if (!choice) return;
        const candidate = choice.candidates[choiceIndex] || choice.candidates[0] || null;
        const targetIndex = resolvePromptIndexByEntry(candidate?.targetEntryVaHex);
        const nextIndex = targetIndex >= 0 && targetIndex !== promptIndex ? targetIndex : Math.min(promptIndex + 1, (currentSequence.prompts || []).length - 1);
        history.push({
          from: prompt.id,
          label: optionLines(choice)[choiceIndex] || `choice ${choiceIndex + 1}`,
          to: candidate?.targetEntryVaHex || "next",
        });
        jumpTo(nextIndex);
      }

      function selectCurrent() {
        currentGroup = groupById(groupSelect.value);
        currentSequence = sequenceById(currentGroup, sequenceSelect.value);
        promptIndex = 0;
        history = [];
        renderPrompt();
      }

      mapSelect.addEventListener("change", () => {
        params.delete("group"); params.delete("seq");
        refreshSelectors();
        selectCurrent();
      });
      groupSelect.addEventListener("change", () => {
        params.delete("seq");
        refreshSelectors();
        selectCurrent();
      });
      sequenceSelect.addEventListener("change", selectCurrent);
      prevButton.addEventListener("click", () => jumpTo(promptIndex - 1));
      nextButton.addEventListener("click", () => jumpTo(promptIndex + 1));
      resetButton.addEventListener("click", () => { history = []; jumpTo(0); });
      choiceArea.addEventListener("click", (event) => {
        const button = event.target.closest("[data-choice-index]");
        if (!button) return;
        chooseBranch(Number(button.dataset.choiceIndex || 0));
      });
      promptList.addEventListener("click", (event) => {
        const button = event.target.closest("[data-prompt-index]");
        if (!button) return;
        jumpTo(Number(button.dataset.promptIndex || 0));
      });

      async function boot() {
        const response = await fetch(DATA_URL);
        data = await response.json();
        statusText.textContent = `${data.sceneGroupCount} groups · ${data.sequenceCount} seq · ${data.choiceCount} choices`;
        refreshSelectors();
        selectCurrent();
        window.HWANSE_SCENE_SCRIPT_PLAYER_READY = true;
        window.HWANSE_LAST_SCENE_SCRIPT_PLAYER = {
          sceneScriptPlayerImplemented: true,
          sceneScriptPlayerDataUrl: DATA_URL,
          mapIndexCount: Object.keys(data.mapIndex || {}).length,
          sequenceCount: data.sequenceCount,
          choiceCount: data.choiceCount,
        };
      }

      boot().catch((error) => {
        console.error(error);
        statusText.textContent = "load failed";
        promptText.textContent = String(error);
      });
    </script>
  </body>
</html>
"""


def html_page(summary: dict) -> str:
    cards = []
    for group in summary["groups"]:
        sequence_html = []
        for seq in group["sequences"]:
            prompt_rows = []
            for prompt in seq["prompts"][:30]:
                prompt_rows.append(
                    "<tr>"
                    f"<td><code>{html.escape(prompt['id'])}</code></td>"
                    f"<td><code>{html.escape(prompt['startVaHex'])}</code></td>"
                    f"<td>{html.escape(prompt.get('status') or '')}</td>"
                    f"<td><pre>{html.escape(prompt.get('displayText') or '')}</pre></td>"
                    "</tr>"
                )
            if not prompt_rows:
                prompt_rows.append('<tr><td colspan="4" class="muted">No matched story prompt rows.</td></tr>')

            entry_rows = []
            for entry in seq["entries"][:40]:
                entry_rows.append(
                    "<tr>"
                    f"<td><code>{html.escape(entry['entryVaHex'])}</code></td>"
                    f"<td><code>{html.escape(entry['textVaHex'])}</code></td>"
                    f"<td>{entry['promptCount']}</td>"
                    f"<td>{html.escape(entry.get('sample') or '')}</td>"
                    "</tr>"
                )
            sequence_html.append(
                "<details class=\"sequence\" open>"
                f"<summary><strong>{html.escape(seq['id'])}</strong> "
                f"<code>{html.escape(seq['entryStartVaHex'])}..{html.escape(seq['entryEndVaHex'])}</code> "
                f"{seq['entryCount']} entries · {seq['promptCount']} prompts · {len(seq.get('choiceReviews') or [])} choices</summary>"
                f"<p class=\"muted\">nearest scenes: {html.escape(', '.join(f'{name}:{count}' for name, count in (seq.get('nearestSceneCounts') or {}).items()) or '-')}</p>"
                f"<p class=\"sample\">{html.escape(seq.get('sample') or '')}</p>"
                f"{branch_review_html(seq.get('choiceReviews') or [])}"
                "<table><thead><tr><th>entry VA</th><th>text stream</th><th>prompts</th><th>sample</th></tr></thead>"
                f"<tbody>{''.join(entry_rows)}</tbody></table>"
                "<table><thead><tr><th>prompt</th><th>start</th><th>status</th><th>text</th></tr></thead>"
                f"<tbody>{''.join(prompt_rows)}</tbody></table>"
                "</details>"
            )
        cards.append(
            "<details class=\"group\" open>"
            f"<summary><strong>{html.escape(group.get('contextLabel') or group['map'])}</strong> "
            f"{html.escape(group.get('sceneIdHex') or '')} "
            f"<code>{html.escape(group.get('rootVaHex') or group.get('recordVaHex') or '')}</code> "
            f"{group['entryCount']} entries · {group['promptCount']} prompts · {html.escape(group['evidenceStatus'] or '')}</summary>"
            "<div class=\"meta\">"
            f"<span>kind {html.escape(group.get('contextKind') or '-')}</span>"
            f"<span>range {html.escape(group.get('rootVaHex') or '-')}..{html.escape(group.get('rootEndVaHex') or '-')}</span>"
            f"<span>distance {group['distanceMin']}..{group['distanceMax']}</span>"
            f"<span>event kind {html.escape(str(group['eventKind'] or '-'))}</span>"
            f"<span>field maps {chip_list(group['fieldMaps'])}</span>"
            f"<span>tilesets {chip_list(group['tilesets'])}</span>"
            f"<span>resources {chip_list(group['resources'])}</span>"
            f"<span>nearest scenes {chip_list([f'{row['map']}:{row['count']}' for row in group.get('nearestScenes') or []])}</span>"
            "</div>"
            f"{''.join(sequence_html)}"
            "</details>"
        )
    return "\n".join([
        "<!doctype html>",
        '<html lang="ko">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>Scene Text Sequence Review</title>",
        "  <style>",
        "    body{margin:24px;background:#101316;color:#e9eef2;font:14px/1.5 system-ui,sans-serif}",
        "    a{color:#8cc7ff} code{color:#ffd27a} h1{font-size:24px;margin:0 0 8px}",
        "    .summary{color:#a9b4bd;margin:0 0 18px}",
        "    .metrics{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0 20px}",
        "    .metric{border:1px solid #2c3944;background:#151b21;padding:8px 10px;border-radius:6px}",
        "    details.group{border:1px solid #2c3944;background:#141a20;margin:10px 0;border-radius:8px}",
        "    details.group>summary{cursor:pointer;padding:10px 12px;background:#1b232b;border-radius:8px}",
        "    .sequence{border-top:1px solid #2c3944;padding:8px 12px}",
        "    .sequence summary{cursor:pointer;margin:4px 0 8px}",
        "    .meta{display:flex;flex-wrap:wrap;gap:8px;padding:10px 12px;color:#a9b4bd}",
        "    .meta span{display:inline-flex;gap:4px;align-items:center}",
        "    .chip{display:inline-block;border:1px solid #33414d;background:#202a33;color:#d7e1e8;border-radius:999px;padding:1px 6px;margin:1px}",
        "    .muted{color:#7e8a94}",
        "    table{border-collapse:collapse;width:100%;margin:8px 0 14px}",
        "    th,td{border:1px solid #2c3944;padding:6px 8px;vertical-align:top;text-align:left}",
        "    th{background:#1b232b;color:#c7d0d8}",
        "    pre{white-space:pre-wrap;margin:0;font:13px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace}",
        "    .sample{white-space:pre-wrap;background:#0f1419;border:1px solid #26313b;padding:8px;border-radius:6px}",
        "    .branch-review-panel{border:1px solid #354654;background:#101820;margin:10px 0 16px;padding:10px;border-radius:6px}",
        "    .branch-review-panel h3{font-size:15px;margin:0 0 6px;color:#dce7ef}",
        "    .choice-review{border-top:1px solid #26313b;padding:8px 0}",
        "    .choice-option-wrap{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px;margin:8px 0}",
        "    .choice-options{background:#0d1318;border:1px solid #26313b;padding:8px;border-radius:6px}",
        "    .branch-table td:nth-child(4){min-width:260px}",
        "  </style>",
        "</head>",
        "<body>",
        "  <h1>Scene Text Sequence Review</h1>",
        f"  <p class=\"summary\">{html.escape(summary['evidenceNote'])}</p>",
        "  <div class=\"metrics\">",
        f"    <div class=\"metric\">text entries <strong>{summary['textEntryCount']}</strong></div>",
        f"    <div class=\"metric\">context groups <strong>{summary['sceneGroupCount']}</strong></div>",
        f"    <div class=\"metric\">sequences <strong>{summary['sequenceCount']}</strong></div>",
        f"    <div class=\"metric\">choice candidates <strong>{summary['choiceCount']}</strong></div>",
        f"    <div class=\"metric\">matched prompts <strong>{summary['promptCount']}</strong></div>",
        "  </div>",
        "  <p class=\"summary\">패턴: <code>0x0000032f, text-stream VA, 0x00000084</code>. selector/root 묶음은 정적 범위 후보이고, nearest scene은 보조 후보일 뿐 직접 호출 증명은 아니다.</p>",
        "  " + "\n  ".join(cards),
        "</body>",
        "</html>",
        "",
    ])


def write_outputs(summary: dict, out_dir: Path, reader_html_out: Path | None = None) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    player_data = compact_player_data(summary)
    (out_dir / "scene_text_sequence_review.json").write_text(
        json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    (out_dir / "scene_script_player_data.json").write_text(
        json.dumps(player_data, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    (out_dir / "scene_text_sequence_review.html").write_text(html_page(summary), encoding="utf-8")
    (out_dir / "scene_script_player.html").write_text(scene_script_player_page(), encoding="utf-8")
    if reader_html_out is not None:
        reader_html_out.parent.mkdir(parents=True, exist_ok=True)
        reader_html_out.write_text(script_reader_page(summary), encoding="utf-8")


def build_summary(exe_path: Path, out_dir: Path) -> dict:
    data = exe_path.read_bytes()
    sections = read_sections(data)
    prompts = prompt_index(load_json(out_dir / "story_prompts.json", {"prompts": []}))
    manifest = load_json(out_dir / "scene_manifest.json", [])
    links = load_json(out_dir / "scene_links.json", {})
    selectors = selector_contexts(load_json(out_dir / "save_scene_selectors.json", []))
    scenes = scene_records(manifest, links)
    entries = annotate_control_pointers(data, sections, find_text_entries(data, sections))
    return build_sequences(entries, selectors, scenes, prompts)


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument(
        "--reader-html-out",
        type=Path,
        help="Optional full static script reader HTML. Omitted by default because the player and JSON are the active surface.",
    )
    args = parser.parse_args()
    summary = build_summary(args.exe, args.out_dir)
    write_outputs(summary, args.out_dir, reader_html_out=args.reader_html_out)
    print(
        "wrote scene text sequence review: "
        f"{summary['textEntryCount']} entries, {summary['sceneGroupCount']} context groups, "
        f"{summary['sequenceCount']} sequences"
    )


if __name__ == "__main__":
    main()
