#!/usr/bin/env python3
"""Build a reverse-flow review index for wait-delimited story prompts."""
from __future__ import annotations

import argparse
import bisect
import html
import json
import struct
from collections import Counter
from pathlib import Path
from typing import Any

from probe_exe_scene_tables import read_sections, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
IMAGE_MIN = 0x00400000
IMAGE_MAX = 0x00600000
TRACE_BEFORE = 0x04
TRACE_AFTER = 0x04
TRACE_RETAIN_PROMPT_IDS = {f"story-prompt-{index}" for index in range(2373, 2406)}
CHAPTER_SEEDS = [
    {"key": "prologue", "title": "프롤로그"},
    {"key": "chapter-1", "title": "1장 불청객"},
    {"key": "chapter-2", "title": "2장 의외의 재회"},
    {"key": "chapter-3", "title": "3장 자객 침입"},
    {"key": "chapter-4", "title": "4장 고양이 귀 권법녀"},
    {"key": "chapter-5", "title": "5장 호랑이 동굴"},
    {"key": "chapter-6", "title": "6장 대격돌 무술대회"},
    {"key": "chapter-7", "title": "7장 최강 맹호전설"},
    {"key": "chapter-8", "title": "8장 의문의 던전"},
]

OPCODE_LABELS = {
    0x00: "end/clear",
    0x02: "text separator/line advance",
    0x03: "set display/font row",
    0x04: "set display timer/state",
    0x06: "wait for input release",
    0x07: "set object fixed-point position",
    0x08: "set display style",
    0x09: "call event stream branch",
    0x0A: "return from event stream branch",
    0x0B: "render current context text id",
    0x0C: "spawn child object with text/source id",
    0x0D: "set current context text id",
    0x0E: "set cursor relative to text origin",
    0x15: "move text origin/cursor relative",
    0x18: "choice/control selector",
    0x1B: "select script data bank",
    0x35: "literal text/control payload",
    0x37: "set global timer/state",
}

FIXED_LENGTHS = {
    0x00: 4,
    0x02: 4,
    0x03: 4,
    0x04: 4,
    0x06: 4,
    0x07: 8,
    0x09: 8,
    0x0B: 4,
    0x0C: 0x14,
    0x0D: 8,
    0x0E: 8,
    0x15: 8,
    0x18: 4,
    0x1B: 4,
    0x37: 4,
}


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


def hex8(value: int) -> str:
    return f"0x{value:02x}"


def int_hex(value: str | None, fallback: int = 0) -> int:
    if not value or value == "-":
        return fallback
    return int(value, 16)


def load_json(path: Path, default: Any) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return default


def write_json(path: Path, payload: Any) -> None:
    path.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")


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


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


def offset_to_va(sections: list[dict], offset: int) -> int | None:
    section = section_for_offset(sections, offset)
    if section is None:
        return None
    return section["va"] + offset - section["raw"]


def interval_offsets(sections: list[dict], start_va: int, end_va: int) -> tuple[int, int, int] | None:
    section = section_for_va(sections, start_va) or section_for_va(sections, max(start_va, end_va - 1))
    if section is None:
        return None
    start_va = max(start_va, section["va"])
    end_va = min(end_va, section["va"] + section["raw_size"])
    if end_va <= start_va:
        return None
    start_off = va_to_offset(sections, start_va)
    end_off = va_to_offset(sections, end_va - 1)
    if start_off is None or end_off is None:
        return None
    return start_off, end_off + 1, start_va


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


def word_at(data: bytes, offset: int) -> int | None:
    if offset < 0 or offset + 2 > len(data):
        return None
    return struct.unpack_from("<H", data, offset)[0]


def command_length(data: bytes, index: int, opcode: int) -> int | None:
    if opcode == 0x08:
        mode = data[index + 2] if index + 2 < len(data) else 0
        return 8 if mode == 1 else 4
    return FIXED_LENGTHS.get(opcode)


def raw_bytes(data: bytes, index: int, length: int | None) -> str:
    if length is None:
        length = 8
    chunk = data[index : min(len(data), index + length)]
    return " ".join(f"{byte:02x}" for byte in chunk)


def decode_command(data: bytes, base_va: int, index: int) -> dict:
    opcode = data[index + 1]
    va = base_va + index
    length = command_length(data, index, opcode)
    row = {
        "va": va,
        "vaHex": hex32(va),
        "opcode": opcode,
        "opcodeHex": hex8(opcode),
        "label": OPCODE_LABELS.get(opcode, "unknown/control"),
        "decodedLength": length,
        "rawBytes": raw_bytes(data, index, length),
    }
    if opcode == 0x09:
        target = dword_at(data, index + 4)
        row["branchTargetVaHex"] = hex32(target)
    elif opcode == 0x0D:
        value = dword_at(data, index + 4)
        row["textSourceValueHex"] = hex32(value)
        row["textSourceLow16Hex"] = f"0x{value & 0xffff:04x}" if value is not None else "-"
    elif opcode == 0x0C:
        value = dword_at(data, index + 0x0C)
        row["childTextSourceValueHex"] = hex32(value)
        row["childTextSourceLow16Hex"] = f"0x{value & 0xffff:04x}" if value is not None else "-"
    elif opcode in {0x07, 0x0E, 0x15}:
        row["arg0"] = word_at(data, index + 2)
        row["arg1"] = word_at(data, index + 4)
    elif opcode == 0x18:
        row["choiceMarker"] = True
        row["arg0Hex"] = f"0x{word_at(data, index + 2) or 0:04x}"
    return row


def trace_window_for_prompt(prompt: dict) -> tuple[int, int]:
    render_va = int_hex(prompt.get("renderVaHex"))
    wait_va = int_hex(prompt.get("waitVaHex"))
    start = max(IMAGE_MIN, render_va - TRACE_BEFORE)
    end = max(wait_va + TRACE_AFTER, render_va + 0x20)
    return start, end


def decode_trace(exe: bytes, sections: list[dict], prompt: dict) -> list[dict]:
    start_va, end_va = trace_window_for_prompt(prompt)
    offsets = interval_offsets(sections, start_va, end_va)
    if offsets is None:
        return []
    start_off, end_off, base_va = offsets
    data = exe[start_off:end_off]
    render_va = int_hex(prompt.get("renderVaHex"))
    wait_va = int_hex(prompt.get("waitVaHex"))
    start_text_va = int_hex(prompt.get("startVaHex"))
    end_prompt_va = int_hex(prompt.get("endVaHex"))
    rows = []
    index = 0
    while index < max(0, len(data) - 1):
        if data[index] != 0x40:
            index += 1
            continue
        opcode = data[index + 1]
        row = decode_command(data, base_va, index)
        roles = []
        if row["va"] == render_va:
            roles.append("prompt-render")
        if row["va"] == wait_va:
            roles.append("prompt-wait")
        if start_text_va <= row["va"] <= end_prompt_va:
            roles.append("inside-prompt")
        if opcode == 0x18:
            roles.append("choice-marker")
        if opcode == 0x09:
            roles.append("branch-call")
        row["roles"] = roles
        rows.append(row)
        length = row.get("decodedLength")
        index += length if isinstance(length, int) and length > 1 else 1
    return rows


def build_pointer_ref_index(exe: bytes, sections: list[dict]) -> dict[int, list[dict]]:
    refs: dict[int, list[dict]] = {}
    for section in sections:
        if section["name"] not in {".text", ".rdata", ".data"}:
            continue
        start = section["raw"]
        end = section["raw"] + section["raw_size"] - 4
        for offset in range(start - (start % 4), end + 1, 4):
            value = dword_at(exe, offset)
            if value is None or not (IMAGE_MIN <= value < IMAGE_MAX):
                continue
            if section_for_va(sections, value) is None:
                continue
            ref_va = offset_to_va(sections, offset)
            if ref_va is None:
                continue
            refs.setdefault(value, []).append({
                "refVa": ref_va,
                "refVaHex": hex32(ref_va),
                "refSection": section["name"],
                "fileOffsetHex": f"0x{offset:06x}",
            })
    return refs


def build_prompt_locator(prompts: list[dict]):
    intervals: list[tuple[int, int, str]] = []
    exact: dict[int, str] = {}
    for prompt in prompts:
        start = int_hex(prompt.get("startVaHex"))
        end = int_hex(prompt.get("endVaHex"), start)
        intervals.append((start, max(end, start), prompt["id"]))
        render = int_hex(prompt.get("renderVaHex"))
        if render:
            exact.setdefault(render, prompt["id"])
        wait = int_hex(prompt.get("waitVaHex"))
        if wait:
            exact.setdefault(wait, prompt["id"])
        for line_va_hex in prompt.get("lineVas") or []:
            exact.setdefault(int_hex(line_va_hex), prompt["id"])
    intervals.sort()
    starts = [row[0] for row in intervals]

    def locate(va: int | None) -> str:
        if va is None:
            return ""
        if va in exact:
            return exact[va]
        pos = bisect.bisect_right(starts, va) - 1
        if pos >= 0:
            start, end, prompt_id = intervals[pos]
            if start <= va <= end:
                return prompt_id
        return ""

    return locate


def ref_context_dwords(
    exe: bytes,
    sections: list[dict],
    offset_hex: str,
    target: int,
    locate_prompt,
    radius: int = 1,
) -> list[dict]:
    offset = int(offset_hex, 16)
    base = max(0, offset - radius * 4)
    base -= base % 4
    rows = []
    for off in range(base, min(len(exe), offset + (radius + 1) * 4), 4):
        value = dword_at(exe, off)
        if value is None:
            continue
        va = offset_to_va(sections, off)
        pointer_section = section_for_va(sections, value)
        row = {
            "refVaHex": hex32(va),
            "valueHex": hex32(value),
            "isTarget": value == target,
        }
        if pointer_section is not None:
            row["targetSection"] = pointer_section["name"]
            prompt_id = locate_prompt(value)
            if prompt_id:
                row["targetPromptId"] = prompt_id
        rows.append(row)
    return rows


def collect_reverse_refs(
    exe: bytes,
    sections: list[dict],
    ref_index: dict[int, list[dict]],
    target: int,
    locate_prompt,
    limit: int = 2,
) -> dict:
    refs = ref_index.get(target, [])
    samples = []
    for ref in refs[:limit]:
        sample = dict(ref)
        sample["contextDwords"] = ref_context_dwords(
            exe,
            sections,
            ref["fileOffsetHex"],
            target,
            locate_prompt,
        )
        samples.append(sample)
    return {
        "targetVaHex": hex32(target),
        "refCount": len(refs),
        "sampleRefs": samples,
    }


def prompt_reverse_targets(prompt: dict, trace: list[dict]) -> list[dict]:
    targets: dict[tuple[str, int], dict] = {}

    def add(kind: str, va: int | None, label: str) -> None:
        if va is None or va <= 0:
            return
        targets[(kind, va)] = {"kind": kind, "targetVa": va, "targetVaHex": hex32(va), "label": label}

    add("render-command", int_hex(prompt.get("renderVaHex")), "prompt render opcode")
    add("first-text", int_hex(prompt.get("startVaHex")), "first displayed text")
    add("wait-command", int_hex(prompt.get("waitVaHex")), "prompt input wait")
    for command in trace:
        opcode = command.get("opcode")
        va = command.get("va")
        if opcode == 0x09:
            add("branch-command", va, "local branch-call opcode")
            add("branch-target", int_hex(command.get("branchTargetVaHex")), "branch-call target")
        elif opcode == 0x18:
            add("choice-command", va, "choice/control selector opcode")
        elif opcode == 0x0D:
            add("source-command", va, "context text-source setter")
    return list(targets.values())


def evidence_class(reverse_targets: list[dict], trace: list[dict]) -> str:
    has_refs = any((target.get("reverseRefs") or {}).get("refCount", 0) for target in reverse_targets)
    has_branch = any(row.get("opcode") == 0x09 for row in trace)
    has_choice = any(row.get("opcode") == 0x18 for row in trace)
    if has_refs and (has_branch or has_choice):
        return "control-referenced"
    if has_branch or has_choice:
        return "control-local"
    if has_refs:
        return "referenced"
    return "local-render"


def build_prompt_flow(
    prompt: dict,
    exe: bytes,
    sections: list[dict],
    ref_index: dict[int, list[dict]],
    locate_prompt,
) -> dict:
    trace = decode_trace(exe, sections, prompt)
    opcode_counts = Counter(row.get("opcodeHex") for row in trace)
    reverse_targets = prompt_reverse_targets(prompt, trace)
    for target in reverse_targets:
        refs = collect_reverse_refs(exe, sections, ref_index, target["targetVa"], locate_prompt)
        target["reverseRefs"] = refs
        for sample in refs["sampleRefs"]:
            sample["sourcePromptId"] = locate_prompt(int_hex(sample.get("refVaHex")))
    reverse_targets = [
        target
        for target in reverse_targets
        if (target.get("reverseRefs") or {}).get("refCount", 0)
        or target.get("kind") in {"branch-command", "branch-target", "choice-command", "source-command"}
    ]
    branch_targets = []
    for row in trace:
        if row.get("opcode") != 0x09:
            continue
        target_va = int_hex(row.get("branchTargetVaHex"))
        branch_targets.append({
            "commandVaHex": row["vaHex"],
            "targetVaHex": row.get("branchTargetVaHex"),
            "targetPromptId": locate_prompt(target_va),
        })
    line_kind_counts = Counter(row.get("kind") or "text" for row in prompt.get("lineRows") or [])
    ref_total = sum((target.get("reverseRefs") or {}).get("refCount", 0) for target in reverse_targets)
    flow_class = evidence_class(reverse_targets, trace)
    return {
        "id": prompt["id"],
        "globalIndex": prompt.get("globalIndex"),
        "blockId": prompt.get("blockId"),
        "classification": prompt.get("classification"),
        "status": prompt.get("status"),
        "flowEvidenceClass": flow_class,
        "renderVaHex": prompt.get("renderVaHex"),
        "startVaHex": prompt.get("startVaHex"),
        "waitVaHex": prompt.get("waitVaHex"),
        "endVaHex": prompt.get("endVaHex"),
        "scanStartVaHex": prompt.get("scanStartVaHex"),
        "scanEndVaHex": prompt.get("scanEndVaHex"),
        "lineCount": prompt.get("lineCount"),
        "lineKindCounts": dict(line_kind_counts),
        "displayText": prompt.get("displayText") or prompt.get("text"),
        "lines": prompt.get("lines") or [],
        "resourceNames": prompt.get("resourceNames") or [],
        "fieldMaps": prompt.get("fieldMaps") or [],
        "tilesets": prompt.get("tilesets") or [],
        "routeContexts": prompt.get("routeContexts") or [],
        "opcodeCounts": dict(opcode_counts),
        "trace": trace,
        "branchTargets": branch_targets,
        "reverseTargets": reverse_targets,
        "reverseRefTotal": ref_total,
        "choiceOpcodeCount": opcode_counts.get("0x18", 0),
        "branchOpcodeCount": opcode_counts.get("0x09", 0),
        "originalStoryOrderBound": False,
    }


def compact_prompt_sample(rows_by_id: dict[str, dict], prompt_ids: list[str], limit: int = 6) -> list[dict]:
    samples = []
    for prompt_id in prompt_ids[:limit]:
        row = rows_by_id[prompt_id]
        samples.append({
            "id": prompt_id,
            "text": str(row.get("displayText") or "").replace("\n", " / ")[:180],
            "renderVaHex": row.get("renderVaHex"),
            "waitVaHex": row.get("waitVaHex"),
        })
    return samples


def retain_trace_for_output(row: dict) -> bool:
    """Keep only traces that current review/tool consumers need directly.

    Full trace/reverse-target payloads made story_flow_review.json large enough
    to slow down normal cleanup and browser usage.  The current downstream tools
    need choice-marker traces and the representative map1_01a opening prompt
    trace.  Other prompt rows retain counts and addresses, but not full command
    context.
    """
    return row.get("status") == "choice-marker-delimited" or row.get("id") in TRACE_RETAIN_PROMPT_IDS


def trim_row_for_output(row: dict) -> dict:
    trimmed = dict(row)
    trace = trimmed.get("trace") or []
    reverse_targets = trimmed.get("reverseTargets") or []
    trimmed["traceCommandCount"] = len(trace)
    trimmed["reverseTargetCount"] = len(reverse_targets)
    if not retain_trace_for_output(trimmed):
        trimmed.pop("trace", None)
        trimmed["traceTrimmed"] = bool(trace)
    else:
        trimmed["traceRetained"] = True
    trimmed.pop("reverseTargets", None)
    trimmed["reverseTargetsTrimmed"] = bool(reverse_targets)
    return trimmed


def contiguous_runs(prompt_ids: list[str], rows_by_id: dict[str, dict], max_gap: int = 8) -> list[list[str]]:
    unique_ids = []
    seen = set()
    for prompt_id in sorted(prompt_ids, key=lambda item: rows_by_id[item].get("globalIndex") or 0):
        if prompt_id in seen:
            continue
        seen.add(prompt_id)
        unique_ids.append(prompt_id)
    runs: list[list[str]] = []
    current: list[str] = []
    previous_index: int | None = None
    for prompt_id in unique_ids:
        index = int(rows_by_id[prompt_id].get("globalIndex") or 0)
        if previous_index is None or index - previous_index <= max_gap:
            current.append(prompt_id)
        else:
            if current:
                runs.append(current)
            current = [prompt_id]
        previous_index = index
    if current:
        runs.append(current)
    return runs


def group_record(
    group_id: str,
    kind: str,
    key: str,
    prompt_ids: list[str],
    rows_by_id: dict[str, dict],
    evidence: dict | None = None,
) -> dict:
    prompt_ids = sorted(dict.fromkeys(prompt_ids), key=lambda item: rows_by_id[item].get("globalIndex") or 0)
    indices = [int(rows_by_id[prompt_id].get("globalIndex") or 0) for prompt_id in prompt_ids]
    choice_count = sum(1 for prompt_id in prompt_ids if rows_by_id[prompt_id].get("choiceOpcodeCount"))
    branch_count = sum(1 for prompt_id in prompt_ids if rows_by_id[prompt_id].get("branchOpcodeCount"))
    ref_count = sum(int(rows_by_id[prompt_id].get("reverseRefTotal") or 0) for prompt_id in prompt_ids)
    classifications = Counter(rows_by_id[prompt_id].get("classification") or "" for prompt_id in prompt_ids)
    stability = group_stability(kind, key, len(prompt_ids), choice_count, branch_count, ref_count)
    return {
        "id": group_id,
        "kind": kind,
        "key": key,
        "stabilityClass": stability["stabilityClass"],
        "confidenceStatus": stability["confidenceStatus"],
        "confidenceReason": stability["confidenceReason"],
        "remainingRisk": stability["remainingRisk"],
        "promptCount": len(prompt_ids),
        "promptIds": prompt_ids,
        "firstPromptId": prompt_ids[0],
        "lastPromptId": prompt_ids[-1],
        "globalIndexStart": min(indices),
        "globalIndexEnd": max(indices),
        "choicePromptCount": choice_count,
        "branchPromptCount": branch_count,
        "reverseRefTotal": ref_count,
        "classificationCounts": dict(classifications),
        "samplePrompts": compact_prompt_sample(rows_by_id, prompt_ids),
        "evidence": evidence or {},
        "storyOrderStatus": "candidate-group; memory adjacency and shared control-flow evidence are not canonical story order",
    }


def group_stability(
    kind: str,
    key: str,
    prompt_count: int,
    choice_count: int,
    branch_count: int,
    ref_count: int,
) -> dict:
    if kind == "stream":
        if key.startswith("event-dialogue-block-"):
            return {
                "stabilityClass": "grounded-local",
                "confidenceStatus": "same extracted event-dialogue block",
                "confidenceReason": "wait-delimited prompts share the same extracted dialogue block and local byte stream.",
                "remainingRisk": "This proves local storage adjacency, not route execution order.",
            }
        return {
            "stabilityClass": "local-candidate",
            "confidenceStatus": "same render stream",
            "confidenceReason": "prompts share a render stream address family, but no route dispatch binding is attached.",
            "remainingRisk": "May include repeated local render fragments rather than one story scene.",
        }
    if kind == "branch-target-run":
        if prompt_count >= 40:
            return {
                "stabilityClass": "broad-candidate",
                "confidenceStatus": "shared broad branch target",
                "confidenceReason": "nearby prompts share a branch target, but the target is reused by many prompts.",
                "remainingRisk": "Likely includes a common routine or broad event family; split further before treating as one scene.",
            }
        return {
            "stabilityClass": "control-candidate",
            "confidenceStatus": "shared branch target run",
            "confidenceReason": "nearby prompts share a VM branch target and preserve prompt-index adjacency.",
            "remainingRisk": "Choice outcome mapping and runtime path execution are still unproven.",
        }
    if kind == "ref-table-cluster":
        if prompt_count <= 12 and branch_count and ref_count:
            return {
                "stabilityClass": "focused-candidate",
                "confidenceStatus": "nearby reverse-reference table",
                "confidenceReason": "branch-command reverse references cluster tightly in a table and point to a small prompt set.",
                "remainingRisk": "The table role is inferred until the original event dispatcher path is tied to it.",
            }
        return {
            "stabilityClass": "reference-candidate",
            "confidenceStatus": "reverse-reference table cluster",
            "confidenceReason": "branch-command reverse references are near each other in a pointer-like table.",
            "remainingRisk": "Cluster may overlap neighboring scenes or alternate cases.",
        }
    return {
        "stabilityClass": "unknown",
        "confidenceStatus": "unclassified",
        "confidenceReason": "no stability rule matched this group kind.",
        "remainingRisk": "manual review required.",
    }


def build_flow_groups(rows: list[dict]) -> list[dict]:
    rows_by_id = {row["id"]: row for row in rows}
    groups: list[dict] = []
    counters: Counter[str] = Counter()

    def add(kind: str, key: str, prompt_ids: list[str], evidence: dict | None = None) -> None:
        unique_count = len(set(prompt_ids))
        if unique_count < 2:
            return
        counters[kind] += 1
        group_id = f"{kind}-{counters[kind]:04d}"
        groups.append(group_record(group_id, kind, key, prompt_ids, rows_by_id, evidence))

    by_block: dict[str, list[str]] = {}
    for row in rows:
        by_block.setdefault(row.get("blockId") or "-", []).append(row["id"])
    for block_id, prompt_ids in sorted(by_block.items(), key=lambda item: min(rows_by_id[p].get("globalIndex") or 0 for p in item[1])):
        if len(set(prompt_ids)) < 3:
            continue
        add("stream", block_id, prompt_ids, {"blockId": block_id})

    by_branch_target: dict[str, list[str]] = {}
    for row in rows:
        for branch in row.get("branchTargets") or []:
            target = branch.get("targetVaHex")
            if target and target != "-":
                by_branch_target.setdefault(target, []).append(row["id"])
    for target, prompt_ids in sorted(by_branch_target.items()):
        runs = contiguous_runs(prompt_ids, rows_by_id, max_gap=8)
        for run_index, run in enumerate(runs, start=1):
            if len(run) < 2:
                continue
            add(
                "branch-target-run",
                f"{target}#{run_index}",
                run,
                {"branchTargetVaHex": target, "runIndex": run_index, "sourcePromptCount": len(set(prompt_ids))},
            )

    ref_items: list[tuple[int, str, str]] = []
    for row in rows:
        for target in row.get("reverseTargets") or []:
            if target.get("kind") != "branch-command":
                continue
            for sample in (target.get("reverseRefs") or {}).get("sampleRefs") or []:
                ref_va = int_hex(sample.get("refVaHex"))
                if ref_va:
                    ref_items.append((ref_va, row["id"], target.get("targetVaHex") or "-"))
    ref_items.sort()
    clusters: list[list[tuple[int, str, str]]] = []
    current: list[tuple[int, str, str]] = []
    previous_va: int | None = None
    for item in ref_items:
        ref_va = item[0]
        if previous_va is None or ref_va - previous_va <= 0x40:
            current.append(item)
        else:
            if current:
                clusters.append(current)
            current = [item]
        previous_va = ref_va
    if current:
        clusters.append(current)
    for cluster in clusters:
        prompt_ids = [item[1] for item in cluster]
        if len(set(prompt_ids)) < 3:
            continue
        ref_start = cluster[0][0]
        ref_end = cluster[-1][0]
        add(
            "ref-table-cluster",
            f"{hex32(ref_start)}-{hex32(ref_end)}",
            prompt_ids,
            {
                "refStartVaHex": hex32(ref_start),
                "refEndVaHex": hex32(ref_end),
                "branchCommandTargets": sorted(set(item[2] for item in cluster)),
            },
        )

    groups.sort(key=lambda group: (
        group["globalIndexStart"],
        {"stream": 0, "branch-target-run": 1, "ref-table-cluster": 2}.get(group["kind"], 9),
        group["id"],
    ))
    for row in rows:
        row["flowGroupIds"] = []
    for group in groups:
        for prompt_id in group["promptIds"]:
            rows_by_id[prompt_id].setdefault("flowGroupIds", []).append(group["id"])
    return groups


def build_summary(
    prompts_summary: dict,
    event_text_source_flow: dict,
    exe: bytes,
) -> dict:
    sections = read_sections(exe)
    prompts = prompts_summary.get("prompts") or []
    ref_index = build_pointer_ref_index(exe, sections)
    locate_prompt = build_prompt_locator(prompts)
    rows = [
        build_prompt_flow(prompt, exe, sections, ref_index, locate_prompt)
        for prompt in prompts
    ]
    flow_groups = build_flow_groups(rows)
    output_rows = [trim_row_for_output(row) for row in rows]
    flow_counts = Counter(row["flowEvidenceClass"] for row in rows)
    group_counts = Counter(group["kind"] for group in flow_groups)
    group_stability_counts = Counter(group["stabilityClass"] for group in flow_groups)
    opcode_counts = Counter()
    for row in rows:
        opcode_counts.update(row["opcodeCounts"])
    chapter_seeds = [
        {
            **seed,
            "source": "manual-user-hint",
            "bindingStatus": "manual-anchor-unbound",
            "bindingNote": "Chapter title rendering may be image/effect-driven; do not require text-table hits.",
        }
        for seed in CHAPTER_SEEDS
    ]
    return {
        "scope": "reverse-flow evidence for wait-delimited story prompts",
        "source": "out/story_prompts.json + out/event_text_source_flow.json + Hwanse2.exe",
        "promptCount": len(rows),
        "traceCommandCount": sum(len(row["trace"]) for row in rows),
        "retainedTraceCommandCount": sum(len(row.get("trace") or []) for row in output_rows),
        "choicePromptCount": sum(1 for row in rows if row["choiceOpcodeCount"]),
        "branchPromptCount": sum(1 for row in rows if row["branchOpcodeCount"]),
        "reverseReferencedPromptCount": sum(1 for row in rows if row["reverseRefTotal"]),
        "flowEvidenceCounts": dict(flow_counts),
        "flowGroupCount": len(flow_groups),
        "flowGroupCounts": dict(group_counts),
        "flowGroupStabilityCounts": dict(group_stability_counts),
        "traceOpcodeCounts": dict(opcode_counts),
        "textSourceCommandCount": event_text_source_flow.get("commandCount", 0),
        "textSourceCommandCountsByOpcode": event_text_source_flow.get("commandCountsByOpcode", {}),
        "chapterSeeds": chapter_seeds,
        "chapterSeedCount": len(chapter_seeds),
        "chapterBindingStatus": "manual anchors only; not text-hit based",
        "storyOrderStatus": "not-promoted; this page exposes local control-flow and reverse pointer evidence, not canonical story order",
        "flowGroups": flow_groups,
        "rowTrimPolicy": {
            "reverseTargets": "trimmed-from-output; reverseRefTotal/reverseTargetCount retained",
            "trace": "retained for choice-marker rows and representative map1_01a opening prompt ids",
            "traceRetainedPromptCount": sum(1 for row in output_rows if row.get("traceRetained")),
        },
        "rows": output_rows,
    }


def html_page(summary: dict) -> str:
    payload_name = "story_flow_review.json"
    return "\n".join([
        "<!doctype html>",
        '<html lang="ko">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>환세취호전 대사 흐름 역추적</title>",
        "  <style>",
        "    :root{color-scheme:dark;--bg:#0b0d0f;--panel:#15191d;--panel2:#101316;--line:#313940;--text:#eff3f5;--muted:#aeb8bf;--blue:#6cb8ff;--green:#72d19b;--yellow:#f3ce62;--red:#f1797d;font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}",
        "    *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text)}a{color:var(--blue);text-decoration:none}",
        "    header{position:sticky;top:0;z-index:5;display:grid;gap:10px;padding:12px 16px;border-bottom:1px solid var(--line);background:rgba(11,13,15,.96)}",
        "    .topbar,.toolbar,.chips{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.topbar h1{font-size:18px;margin:0;letter-spacing:0}.topbar nav{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}",
        "    nav a,.chip{display:inline-flex;align-items:center;min-height:28px;padding:0 9px;border:1px solid var(--line);border-radius:4px;background:var(--panel);color:var(--text);font-size:12px;white-space:nowrap}",
        "    input,select,label.toggle{height:34px;border:1px solid var(--line);border-radius:4px;background:var(--panel2);color:var(--text);font:13px system-ui,sans-serif}input{min-width:260px;flex:1;padding:0 10px}select{padding:0 8px}label.toggle{display:inline-flex;align-items:center;gap:6px;padding:0 10px}",
        "    main{display:grid;gap:14px;padding:16px}.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px}.metric,.panel{border:1px solid var(--line);border-radius:6px;background:var(--panel)}.metric{padding:10px}.metric b{display:block;font:700 22px ui-monospace,Menlo,Consolas,monospace}.metric span,.muted,small{color:var(--muted)}",
        "    .layout{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(360px,.75fr);gap:14px}.panel{min-width:0;padding:12px}.panel-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-bottom:10px}h2{font-size:15px;margin:0;letter-spacing:0}",
        "    .table-wrap{overflow:auto;border:1px solid #252b31;border-radius:4px;background:var(--panel2);max-height:68vh}table{width:100%;min-width:940px;border-collapse:collapse;font-size:12px}th,td{padding:7px 8px;border-bottom:1px solid #252b31;text-align:left;vertical-align:top}th{position:sticky;top:0;background:#171d21;color:var(--muted);z-index:1}tr.selected td{background:rgba(108,184,255,.10)}tr.hidden{display:none}",
        "    code{font:12px ui-monospace,Menlo,Consolas,monospace;color:var(--green)}pre{margin:0;white-space:pre-wrap;word-break:keep-all;font:13px/1.55 system-ui,sans-serif}.bubble{border:1px solid #3a3a3a;background:#111;color:#f3f0df;border-radius:4px;padding:10px}",
        "    .chip.good{border-color:rgba(114,209,155,.45);color:var(--green)}.chip.warn{border-color:rgba(243,206,98,.55);color:var(--yellow)}.chip.bad{border-color:rgba(241,121,125,.55);color:var(--red)}.chip.blue{border-color:rgba(108,184,255,.55);color:var(--blue)}",
        "    .detail-grid{display:grid;gap:10px}.trace,.refs{display:grid;gap:8px}.trace-row,.ref-row{border:1px solid #252b31;border-radius:4px;background:var(--panel2);padding:8px}.kv{display:grid;grid-template-columns:130px minmax(0,1fr);gap:5px 8px}.kv dt{color:var(--muted)}.kv dd{margin:0;overflow-wrap:anywhere}.raw{color:#d3b87a}.role{color:var(--blue)}",
        "    @media(max-width:960px){.layout{grid-template-columns:1fr}.topbar nav{margin-left:0}.table-wrap{max-height:none}}",
        "  </style>",
        "</head>",
        "<body>",
        "<header>",
        "  <div class=\"topbar\"><h1>대사 흐름 역추적</h1><nav><a href=\"../web/index.html\">관리 홈</a><a href=\"../web/dialogue_review.html\">대사/VM</a><a href=\"story_prompts.html\">대사 프롬프트</a><a href=\"event_text_source_flow.html\">text source flow</a></nav></div>",
        "  <div class=\"toolbar\"><input id=\"search\" type=\"search\" placeholder=\"프롬프트, 대사, 주소, opcode 검색\"><select id=\"flowFilter\"><option value=\"\">all flow</option></select><label class=\"toggle\"><input id=\"controlOnly\" type=\"checkbox\">선택/분기만</label><span id=\"summaryText\" class=\"muted\">loading...</span></div>",
        "</header>",
        "<main>",
        "  <section class=\"metrics\" aria-label=\"metrics\"><div class=\"metric\"><span>prompts</span><b id=\"mPrompts\">-</b></div><div class=\"metric\"><span>groups</span><b id=\"mGroups\">-</b><small id=\"mGroupsSub\"></small></div><div class=\"metric\"><span>choice</span><b id=\"mChoice\">-</b></div><div class=\"metric\"><span>branch</span><b id=\"mBranch\">-</b></div><div class=\"metric\"><span>reverse refs</span><b id=\"mRefs\">-</b></div><div class=\"metric\"><span>trace commands</span><b id=\"mTrace\">-</b></div><div class=\"metric\"><span>chapter seeds</span><b id=\"mChapters\">-</b><small id=\"mChaptersSub\"></small></div></section>",
        "  <section class=\"panel\"><div class=\"panel-head\"><h2>장면 후보 그룹</h2><span id=\"groupCount\" class=\"muted\"></span></div><div class=\"table-wrap\"><table><thead><tr><th>group</th><th>kind/key</th><th>stability</th><th>prompts</th><th>controls</th><th>sample</th></tr></thead><tbody id=\"groupRows\"></tbody></table></div></section>",
        "  <section class=\"layout\"><section class=\"panel\"><div class=\"panel-head\"><h2>프롬프트 흐름 목록</h2><span id=\"rowCount\" class=\"muted\"></span></div><div class=\"table-wrap\"><table><thead><tr><th>prompt</th><th>flow</th><th>text</th><th>control</th><th>addresses</th></tr></thead><tbody id=\"rows\"></tbody></table></div></section><aside class=\"panel\"><div class=\"panel-head\"><h2>상세 근거</h2><span id=\"detailTitle\" class=\"muted\"></span></div><div id=\"detail\" class=\"detail-grid muted\">행을 선택하세요.</div></aside></section>",
        "</main>",
        "<script>",
        f"const DATA_URL='{payload_name}';",
        "const $=(id)=>document.getElementById(id);let data=null;let selectedId='';",
        "function esc(v){return String(v??'').replace(/[&<>\"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c]))}",
        "function chip(v,cls=''){return `<span class=\"chip ${esc(cls)}\">${esc(v)}</span>`}",
        "function short(v,n=150){const s=String(v||'').replace(/\\s+/g,' ').trim();return s.length>n?s.slice(0,n-1)+'...':s}",
        "function flowCls(row){if(row.flowEvidenceClass==='control-referenced')return 'good';if(row.flowEvidenceClass==='control-local')return 'blue';if(row.flowEvidenceClass==='referenced')return 'warn';return ''}",
        "function rowText(row){return [row.id,row.blockId,row.classification,row.flowEvidenceClass,row.displayText,row.renderVaHex,row.waitVaHex,(row.flowGroupIds||[]).join(' '),JSON.stringify(row.opcodeCounts),row.reverseRefTotal,row.reverseTargetCount].join(' ').toLowerCase()}",
        "function renderMetrics(){ $('mPrompts').textContent=data.promptCount; $('mGroups').textContent=data.flowGroupCount||0; $('mGroupsSub').textContent=Object.entries(data.flowGroupCounts||{}).map(([k,v])=>`${k}:${v}`).join(' '); $('mChoice').textContent=data.choicePromptCount; $('mBranch').textContent=data.branchPromptCount; $('mRefs').textContent=data.reverseReferencedPromptCount; $('mTrace').textContent=data.traceCommandCount; $('mTrace').title=`retained in JSON: ${data.retainedTraceCommandCount||0}`; $('mChapters').textContent=data.chapterSeedCount||0; $('mChaptersSub').textContent=data.chapterBindingStatus||'manual anchors'; $('summaryText').textContent=data.storyOrderStatus; }",
        "function renderRows(){const tbody=$('rows');tbody.innerHTML=data.rows.map(row=>{const control=[row.choiceOpcodeCount?chip(`choice ${row.choiceOpcodeCount}`,'blue'):'',row.branchOpcodeCount?chip(`branch ${row.branchOpcodeCount}`,'warn'):'',row.reverseRefTotal?chip(`refs ${row.reverseRefTotal}`,'good'):''].join(' ');const groups=(row.flowGroupIds||[]).slice(0,3).map(id=>chip(id,'blue')).join(' ');return `<tr data-id=\"${esc(row.id)}\" data-search=\"${esc(rowText(row))}\"><td><code>${esc(row.id)}</code><br><small>${esc(row.blockId||'-')}</small></td><td>${chip(row.flowEvidenceClass,flowCls(row))}<br><small>${esc(row.classification||'')}</small></td><td>${esc(short(row.displayText,180))}<br><small>${groups}</small></td><td><div class=\"chips\">${control||chip('local render')}</div></td><td><code>${esc(row.renderVaHex)}</code><br><small>wait ${esc(row.waitVaHex)}</small></td></tr>`}).join('');for(const tr of tbody.querySelectorAll('tr'))tr.addEventListener('click',()=>selectRow(tr.dataset.id));applyFilters();}",
        "function renderGroups(){const tbody=$('groupRows');const groups=data.flowGroups||[];$('groupCount').textContent=`${groups.length} groups`;tbody.innerHTML=groups.map(group=>{const controls=[group.choicePromptCount?chip(`choice ${group.choicePromptCount}`,'blue'):'',group.branchPromptCount?chip(`branch ${group.branchPromptCount}`,'warn'):'',group.reverseRefTotal?chip(`refs ${group.reverseRefTotal}`,'good'):''].join(' ');const sample=(group.samplePrompts||[]).slice(0,3).map(p=>`${p.id} ${short(p.text,80)}`).join(' / ');const stabilityClass=group.stabilityClass==='grounded-local'?'good':group.stabilityClass==='broad-candidate'?'bad':group.stabilityClass?.includes('candidate')?'warn':'blue';return `<tr data-group-id=\"${esc(group.id)}\"><td><code>${esc(group.id)}</code></td><td>${chip(group.kind,group.kind==='stream'?'blue':group.kind==='branch-target-run'?'warn':'good')}<br><small><code>${esc(group.key)}</code></small></td><td>${chip(group.stabilityClass||'-',stabilityClass)}<br><small>${esc(group.confidenceStatus||'')}</small></td><td>${group.promptCount}<br><small>${esc(group.firstPromptId)} - ${esc(group.lastPromptId)}</small></td><td>${controls}</td><td>${esc(sample)}</td></tr>`}).join('');for(const tr of tbody.querySelectorAll('tr'))tr.addEventListener('click',()=>selectGroup(tr.dataset.groupId));}",
        "function renderFilters(){const sel=$('flowFilter');for(const v of Object.keys(data.flowEvidenceCounts||{}).sort()){const o=document.createElement('option');o.value=v;o.textContent=`${v} (${data.flowEvidenceCounts[v]})`;sel.appendChild(o)}}",
        "function traceHtml(row){if(!row.trace?.length){return row.traceTrimmed?`<div class=\"muted\">trace 상세는 JSON 경량화를 위해 제거됨 · 원래 ${esc(row.traceCommandCount||0)} commands</div>`:'<div class=\"muted\">trace 없음</div>'}return `<div class=\"trace\">${row.trace.map(cmd=>`<div class=\"trace-row\"><div><code>${esc(cmd.vaHex)}</code> ${chip(cmd.opcodeHex,'blue')} ${esc(cmd.label)}</div><div class=\"raw\">${esc(cmd.rawBytes)}</div>${cmd.roles?.length?`<div class=\"role\">${esc(cmd.roles.join(', '))}</div>`:''}${cmd.branchTargetVaHex?`<div>branch target <code>${esc(cmd.branchTargetVaHex)}</code> ${cmd.branchTargetVaHex&&cmd.branchTargetVaHex!=='-'?esc((row.branchTargets||[]).find(b=>b.commandVaHex===cmd.vaHex)?.targetPromptId||''):''}</div>`:''}${cmd.textSourceValueHex?`<div>text source <code>${esc(cmd.textSourceValueHex)}</code> low ${esc(cmd.textSourceLow16Hex)}</div>`:''}</div>`).join('')}</div>`}",
        "function refsHtml(row){const targets=(row.reverseTargets||[]).filter(t=>t.reverseRefs?.refCount);if(!targets.length){return row.reverseTargetsTrimmed?`<div class=\"muted\">역참조 상세는 JSON 경량화를 위해 제거됨 · targets ${esc(row.reverseTargetCount||0)}, refs ${esc(row.reverseRefTotal||0)}</div>`:'<div class=\"muted\">역참조 없음</div>'}return `<div class=\"refs\">${targets.map(t=>`<div class=\"ref-row\"><div>${chip(t.kind,'warn')} <code>${esc(t.targetVaHex)}</code> ${esc(t.label)} · refs ${t.reverseRefs.refCount}</div>${(t.reverseRefs.sampleRefs||[]).map(r=>`<div><code>${esc(r.refVaHex)}</code> ${esc(r.refSection)} ${r.sourcePromptId?`source prompt ${esc(r.sourcePromptId)}`:''}<details><summary>context dwords</summary>${(r.contextDwords||[]).map(d=>`<div>${d.isTarget?'* ':''}<code>${esc(d.refVaHex)}</code> = <code>${esc(d.valueHex)}</code> ${d.targetPromptId?`-> ${esc(d.targetPromptId)}`:''}</div>`).join('')}</details></div>`).join('')}</div>`).join('')}</div>`}",
        "function detailHtml(row){return `<div class=\"bubble\"><pre>${esc(row.displayText||'')}</pre></div><dl class=\"kv\"><dt>flow</dt><dd>${chip(row.flowEvidenceClass,flowCls(row))}</dd><dt>render/wait</dt><dd><code>${esc(row.renderVaHex)}</code> / <code>${esc(row.waitVaHex)}</code></dd><dt>resources</dt><dd>${esc([...(row.fieldMaps||[]),...(row.tilesets||[]),...(row.resourceNames||[])].join(', ')||'-')}</dd><dt>kind counts</dt><dd><code>${esc(JSON.stringify(row.lineKindCounts||{}))}</code></dd><dt>links</dt><dd><a href=\"story_prompts.html#${esc(row.id)}\">대사 프롬프트</a></dd></dl><h2>Opcode Trace</h2>${traceHtml(row)}<h2>Reverse References</h2>${refsHtml(row)}`}",
        "function groupDetailHtml(group){const prompts=(group.samplePrompts||[]).map(p=>`<div class=\"ref-row\"><code>${esc(p.id)}</code> <small>${esc(p.renderVaHex)} / ${esc(p.waitVaHex)}</small><div>${esc(p.text)}</div></div>`).join('');return `<dl class=\"kv\"><dt>kind</dt><dd>${chip(group.kind,'blue')}</dd><dt>key</dt><dd><code>${esc(group.key)}</code></dd><dt>stability</dt><dd>${chip(group.stabilityClass||'-','warn')} ${esc(group.confidenceStatus||'')}</dd><dt>reason</dt><dd>${esc(group.confidenceReason||'')}</dd><dt>remaining risk</dt><dd>${esc(group.remainingRisk||'')}</dd><dt>prompts</dt><dd>${group.promptCount} (${esc(group.firstPromptId)} - ${esc(group.lastPromptId)})</dd><dt>controls</dt><dd>choice ${group.choicePromptCount}, branch ${group.branchPromptCount}, refs ${group.reverseRefTotal}</dd><dt>status</dt><dd>${esc(group.storyOrderStatus)}</dd></dl><h2>Sample Prompts</h2><div class=\"refs\">${prompts}</div>`}",
        "function selectRow(id){selectedId=id;const row=data.rows.find(r=>r.id===id);if(!row)return;for(const tr of document.querySelectorAll('#rows tr'))tr.classList.toggle('selected',tr.dataset.id===id);$('detailTitle').textContent=id;$('detail').classList.remove('muted');$('detail').innerHTML=detailHtml(row);history.replaceState(null,'',`?prompt=${encodeURIComponent(id)}`)}",
        "function selectGroup(id){const group=(data.flowGroups||[]).find(g=>g.id===id);if(!group)return;$('search').value=id;applyFilters();for(const tr of document.querySelectorAll('#groupRows tr'))tr.classList.toggle('selected',tr.dataset.groupId===id);$('detailTitle').textContent=id;$('detail').classList.remove('muted');$('detail').innerHTML=groupDetailHtml(group);history.replaceState(null,'',`?group=${encodeURIComponent(id)}`)}",
        "function applyFilters(){const q=$('search').value.trim().toLowerCase();const flow=$('flowFilter').value;const control=$('controlOnly').checked;let count=0;for(const tr of document.querySelectorAll('#rows tr')){const row=data.rows.find(r=>r.id===tr.dataset.id);const ok=(!q||tr.dataset.search.includes(q))&&(!flow||row.flowEvidenceClass===flow)&&(!control||row.choiceOpcodeCount||row.branchOpcodeCount);tr.classList.toggle('hidden',!ok);if(ok)count++;} $('rowCount').textContent=`${count} visible`;}",
        "async function load(){const res=await fetch(DATA_URL,{cache:'no-store'});data=await res.json();renderMetrics();renderFilters();renderGroups();renderRows();$('search').addEventListener('input',applyFilters);$('flowFilter').addEventListener('change',applyFilters);$('controlOnly').addEventListener('change',applyFilters);const params=new URLSearchParams(location.search);const group=params.get('group');const prompt=params.get('prompt')||params.get('q')||'story-prompt-3095';if(group){selectGroup(group)}else{$('search').value=prompt;applyFilters();selectRow(prompt)}}",
        "load().catch(err=>{$('summaryText').textContent=String(err)})",
        "</script>",
        "</body>",
        "</html>",
        "",
    ])


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--story-prompts", type=Path, default=OUT / "story_prompts.json")
    parser.add_argument("--event-text-source-flow", type=Path, default=OUT / "event_text_source_flow.json")
    parser.add_argument("--out-json", type=Path, default=OUT / "story_flow_review.json")
    parser.add_argument("--out-html", type=Path, default=OUT / "story_flow_review.html")
    args = parser.parse_args()

    exe = args.exe.read_bytes()
    summary = build_summary(
        load_json(args.story_prompts, {}),
        load_json(args.event_text_source_flow, {}),
        exe,
    )
    write_json(args.out_json, summary)
    args.out_html.write_text(html_page(summary), encoding="utf-8")
    print(
        f"wrote story flow review -> {args.out_html} "
        f"({summary['promptCount']} prompts, {summary['traceCommandCount']} trace commands)"
    )


if __name__ == "__main__":
    main()
