#!/usr/bin/env python3
"""Build a focused choice-boundary review for the Scene/Event VM work.

The prompt-sequence review exposes choice rows and nearby branch calls, but one
address in that trace (`0x004bfdb4`) is easy to misread as the selected option's
next-prompt target.  This builder fixes that boundary:

- `0x004bfdb4` is classified as a common system/menu display stream.
- prompt trace marker `40 18 3a NN` is kept separate from the
  `script_handler_table` 0x18 gate cluster.
- the `0x16 -> 0x59e2a0 -> 0x18` handler cluster is preserved as grounded
  handler evidence, but it is not promoted as the prompt choice edge because no
  producer instance appears in the selected `map1_01a` prompt trace.
"""
from __future__ import annotations

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


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
WEB = ROOT / "web"
EXE = ROOT / "Hwanse2.exe"

TEXT_START = 0x00401000
TEXT_FILE_OFFSET = 0x00000400
TEXT_SIZE = 0x0003978C
RDATA_START = 0x0043B000
RDATA_FILE_OFFSET = 0x00039C00
RDATA_SIZE = 0x00007E7C
DATA_START = 0x0043C000
DATA_FILE_OFFSET = 0x0003A000
DATA_SIZE = 0x0011DE00

COMMON_STREAM_VA = 0x004BFDB4
COMMON_STREAM_START = 0x004BFD5C
COMMON_STREAM_END = 0x004BFFDC
OPCODE_16_HANDLER = 0x0040BA3F
OPCODE_18_HANDLER = 0x0040BAB2
CHOICE_TARGET_GLOBAL = 0x0059E2A0
CHOICE_LIMIT_GLOBAL = 0x0059DB1E
CHOICE_INDEX_GLOBAL = 0x0059DB1F
CHOICE_ACTIVE_GLOBAL = 0x00457749


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, indent=2) + "\n", encoding="utf-8")


def write_text(path: Path, text: str) -> None:
    path.write_text(text, encoding="utf-8")


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


def short(value: Any, limit: int = 180) -> str:
    if isinstance(value, (dict, list)):
        value = json.dumps(value, ensure_ascii=False, sort_keys=True)
    text = " ".join((str(value) if value is not None else "").split())
    return text if len(text) <= limit else text[: limit - 1] + "..."


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


def rel(path: Path) -> str:
    try:
        return path.resolve().relative_to(ROOT).as_posix()
    except ValueError:
        return path.as_posix()


def va_to_offset(va: int) -> tuple[int | None, str | None]:
    sections = [
        (".text", TEXT_START, TEXT_FILE_OFFSET, TEXT_SIZE),
        (".rdata", RDATA_START, RDATA_FILE_OFFSET, RDATA_SIZE),
        (".data", DATA_START, DATA_FILE_OFFSET, DATA_SIZE),
    ]
    for name, start, offset, size in sections:
        if start <= va < start + size:
            return offset + (va - start), name
    return None, None


def offset_to_va(offset: int) -> tuple[int | None, str | None]:
    sections = [
        (".text", TEXT_START, TEXT_FILE_OFFSET, TEXT_SIZE),
        (".rdata", RDATA_START, RDATA_FILE_OFFSET, RDATA_SIZE),
        (".data", DATA_START, DATA_FILE_OFFSET, DATA_SIZE),
    ]
    for name, start, sec_offset, size in sections:
        if sec_offset <= offset < sec_offset + size:
            return start + (offset - sec_offset), name
    return None, None


def read_bytes(exe: bytes, va: int, size: int) -> bytes:
    offset, _section = va_to_offset(va)
    if offset is None:
        return b""
    return exe[offset : offset + size]


def scan_global_refs(exe: bytes, value: int) -> list[dict[str, Any]]:
    needle = struct.pack("<I", value)
    rows = []
    start = 0
    while True:
        found = exe.find(needle, start)
        if found < 0:
            break
        va, section = offset_to_va(found)
        if va is not None and section == ".text":
            op_start = max(TEXT_FILE_OFFSET, found - 3)
            op_end = min(TEXT_FILE_OFFSET + TEXT_SIZE, found + 8)
            rows.append(
                {
                    "immediateVaHex": hx(va),
                    "nearBytes": exe[op_start:op_end].hex(" "),
                }
            )
        start = found + 1
    return rows


def decode_common_stream(exe: bytes) -> list[dict[str, Any]]:
    """Decode only the stable visible commands at the common stream entry.

    The stream uses command words such as `40 03 00 00`, where the second byte
    is the visible opcode.  Only opcodes whose length is already grounded in
    the dialogue/event review are decoded here.
    """
    length_by_opcode = {
        0x03: 4,
        0x04: 4,
        0x07: 8,
        0x08: 4,
        0x09: 8,
        0x0A: 4,
        0x0B: 4,
        0x0D: 8,
        0x15: 8,
        0x18: 4,
        0x1B: 4,
        0x37: 4,
    }
    labels = {
        0x03: "set display/font row",
        0x04: "set display timer/state",
        0x07: "set object fixed-point position",
        0x08: "set display style triplet",
        0x09: "call event stream branch",
        0x0A: "return from event stream branch",
        0x0B: "render current context text id",
        0x0D: "set current context text id",
        0x15: "move text origin/cursor relative",
        0x18: "choice/selected-target gate in this handler table",
        0x1B: "select script data bank",
        0x37: "set global timer/state",
    }
    rows: list[dict[str, Any]] = []
    va = COMMON_STREAM_VA
    for _ in range(18):
        raw = read_bytes(exe, va, 8)
        if len(raw) < 4 or raw[0] != 0x40:
            break
        opcode = raw[1]
        length = length_by_opcode.get(opcode, 4)
        operand = None
        if length >= 8 and len(raw) >= 8:
            operand = struct.unpack_from("<I", raw, 4)[0]
        rows.append(
            {
                "vaHex": hx(va),
                "opcodeHex": f"0x{opcode:02x}",
                "length": length,
                "operandHex": hx(operand) if operand is not None else None,
                "label": labels.get(opcode, "unknown/common stream command"),
                "rawBytes": raw[:length].hex(" "),
            }
        )
        va += length
    return rows


def find_event_dialogue_block(blocks_payload: dict[str, Any]) -> dict[str, Any]:
    for block in blocks_payload.get("blocks") or []:
        if block.get("blockId") == "event-dialogue-block-021":
            return block
    for block in blocks_payload.get("blocks") or []:
        if block.get("startVaHex") == hx(COMMON_STREAM_START):
            return block
    return {}


def handler_entry(opcodes: dict[str, Any], opcode_hex: str) -> dict[str, Any]:
    for row in opcodes.get("opcodeDictionary") or []:
        if row.get("opcodeHex") == opcode_hex:
            return row
    return {}


def prompt_choice_rows(prompt_payload: dict[str, Any]) -> list[dict[str, Any]]:
    rows = []
    for row in prompt_payload.get("choiceBoundaries") or []:
        rows.append(
            {
                "choicePromptOrder": row.get("choicePromptOrder"),
                "choiceStartVaHex": row.get("choiceStartVaHex"),
                "choiceText": row.get("choiceText"),
                "branchCallsToCommonStream": [
                    item
                    for item in row.get("branchCallRows") or []
                    if item.get("branchTargetVaHex") == hx(COMMON_STREAM_VA)
                ],
                "remainingGap": row.get("remainingGap"),
            }
        )
    return rows


def map_trace_opcode_rows(trace_payload: dict[str, Any], opcode_hex: str) -> list[dict[str, Any]]:
    rows = []
    trace = trace_payload.get("map1_01aTrace") or {}
    for prompt in trace.get("prompts") or []:
        for row in prompt.get("trace") or []:
            if row.get("opcodeHex") != opcode_hex:
                continue
            rows.append(
                {
                    "promptOrder": prompt.get("order"),
                    "promptId": prompt.get("promptId"),
                    "promptStatus": prompt.get("status"),
                    "promptStartVaHex": prompt.get("startVaHex"),
                    "vaHex": row.get("vaHex"),
                    "rawBytes": row.get("rawBytes"),
                    "branchTargetVaHex": row.get("branchTargetVaHex"),
                    "roles": row.get("roles") or [],
                }
            )
    return rows


def choice_marker_arg_scan(story_flow: dict[str, Any]) -> dict[str, Any]:
    rows = []
    for prompt in story_flow.get("rows") or []:
        if prompt.get("status") != "choice-marker-delimited":
            continue
        for trace_row in prompt.get("trace") or []:
            if trace_row.get("opcodeHex") != "0x18" or not trace_row.get("choiceMarker"):
                continue
            raw = trace_row.get("rawBytes") or ""
            try:
                byte_values = [int(part, 16) for part in raw.split()]
            except ValueError:
                byte_values = []
            if len(byte_values) < 4:
                continue
            arg_byte = byte_values[3]
            line_count = int(prompt.get("lineCount") or 0)
            rows.append(
                {
                    "promptId": prompt.get("id"),
                    "startVaHex": prompt.get("startVaHex"),
                    "markerVaHex": trace_row.get("vaHex"),
                    "rawBytes": raw,
                    "lineCount": line_count,
                    "argByte": arg_byte,
                    "argByteHex": f"0x{arg_byte:02x}",
                    "argLow7": arg_byte & 0x7F,
                    "argLow6": arg_byte & 0x3F,
                    "argHighBitsHex": f"0x{arg_byte & 0xC0:02x}",
                    "exactCountMatch": line_count == arg_byte,
                    "low7CountMatch": line_count == (arg_byte & 0x7F),
                    "low6CountMatch": line_count == (arg_byte & 0x3F),
                    "displayText": short(prompt.get("displayText"), 120),
                }
            )
    distribution = Counter(row["argByteHex"] for row in rows)
    return {
        "total": len(rows),
        "exactCountMatches": sum(1 for row in rows if row["exactCountMatch"]),
        "low7CountMatches": sum(1 for row in rows if row["low7CountMatch"]),
        "low6CountMatches": sum(1 for row in rows if row["low6CountMatch"]),
        "highBitRows": sum(1 for row in rows if row["argByte"] & 0xC0),
        "distribution": dict(sorted(distribution.items())),
        "sampleRows": rows[:80],
        "mismatchSamples": [row for row in rows if not row["low6CountMatch"]][:30],
    }


def build(args: argparse.Namespace) -> dict[str, Any]:
    exe = args.exe.read_bytes()
    dialogue_blocks = load_json(args.dialogue_blocks, {})
    opcode_dictionary = load_json(args.opcode_dictionary, {})
    prompt_sequence = load_json(args.prompt_sequence, {})
    script_table = load_json(args.script_handler_table, {})
    map1_trace = load_json(args.map1_trace, {})
    story_flow = load_json(args.story_flow, {})
    block = find_event_dialogue_block(dialogue_blocks)
    opcode16 = handler_entry(opcode_dictionary, "0x16")
    opcode18 = handler_entry(opcode_dictionary, "0x18")

    refs = {
        "choiceTargetGlobalRefs": scan_global_refs(exe, CHOICE_TARGET_GLOBAL),
        "choiceLimitGlobalRefs": scan_global_refs(exe, CHOICE_LIMIT_GLOBAL),
        "randomGateSuccessCounterRefs": scan_global_refs(exe, CHOICE_INDEX_GLOBAL),
        "choiceActiveFlagRefs": scan_global_refs(exe, CHOICE_ACTIVE_GLOBAL),
    }
    common_stream_rows = decode_common_stream(exe)
    common_stream_opcode_counts = Counter(row["opcodeHex"] for row in common_stream_rows)
    choice_rows = prompt_choice_rows(prompt_sequence)
    common_branch_call_count = sum(len(row["branchCallsToCommonStream"]) for row in choice_rows)
    trace_opcode16_rows = map_trace_opcode_rows(map1_trace, "0x16")
    trace_opcode18_rows = map_trace_opcode_rows(map1_trace, "0x18")
    trace_branch_rows = map_trace_opcode_rows(map1_trace, "0x09")
    trace_common_branch_rows = [
        row for row in trace_branch_rows if row.get("branchTargetVaHex") == hx(COMMON_STREAM_VA)
    ]
    choice_arg_scan = choice_marker_arg_scan(story_flow)

    handler_cluster = [
        {
            "name": "selected target producer",
            "opcodeHex": "0x16",
            "handlerVaHex": hx(OPCODE_16_HANDLER),
            "evidence": "reads stream+0x04 and stores the dword into 0x0059e2a0, then advances stream by 8",
            "promotion": "grounded-handler",
            "source": "out/script_handler_table.json / disassembly bytes",
        },
        {
            "name": "selection state initializer candidate",
            "opcodeHex": "unindexed-nearby-routine",
            "handlerVaHex": "0x0040ba5f",
            "evidence": "reads stream+0x04/+0x06/+0x07/+0x08 and writes 0x59db18/0x59db1c/0x59db1e/0x59db1f",
            "promotion": "partial-neighbor-routine",
            "source": "local disassembly cluster; not promoted as an opcode entry",
        },
        {
            "name": "selected target gate",
            "opcodeHex": "0x18",
            "handlerVaHex": hx(OPCODE_18_HANDLER),
            "evidence": "advances stream by 4; compares 0x59db1e and 0x59db1f; when active flag is nonzero it jumps context+0x40 to 0x0059e2a0",
            "promotion": "grounded-handler-partial-route",
            "source": "out/script_handler_table.json / disassembly bytes",
        },
        {
            "name": "random gate success counter updater",
            "opcodeHex": "external-random-gate",
            "handlerVaHex": "0x00433107",
            "evidence": "calls internal rand16_mod(10000), compares against 0x59db1c, and increments 0x59db1f only on random-threshold success",
            "promotion": "grounded-not-choice-index",
            "source": "global-ref scan + random gate review",
        },
    ]

    decisions = [
        {
            "item": "branch target 0x004bfdb4",
            "promotion": "reclassified-grounded",
            "evidence": "event-dialogue-block-021 classifies the range as system-text-like and its text lines are menu/system labels",
            "remainingGap": "not a selected option -> next prompt target; treat it as a common render/menu stream",
        },
        {
            "item": "opcode 0x18 inline operand",
            "promotion": "rejected-as-prompt-target",
            "evidence": "prompt trace marker 40 18 3a NN is a 4-byte row, so the following 40 00 00 00 is the next command, not an inline target",
            "remainingGap": "script_handler_table 0x18 also exists, but it is not proven to be the same prompt-choice marker domain",
        },
        {
            "item": "prompt marker vs handler cluster domain",
            "promotion": "blocked-domain-link",
            "evidence": "map1_01a prompt trace shows 0x18 markers and common 0x09 branches, while handler-table 0x16 producer rows are absent from that selected prompt trace",
            "remainingGap": "do not use the 0x16 -> 0x59e2a0 -> 0x18 handler cluster as selected-option proof until the stream binding is shown",
        },
        {
            "item": "prompt choice marker argument",
            "promotion": "partial",
            "evidence": f"global choice marker rows={choice_arg_scan['total']}, exact count matches={choice_arg_scan['exactCountMatches']}, low6 count matches={choice_arg_scan['low6CountMatches']}, high-bit rows={choice_arg_scan['highBitRows']}",
            "remainingGap": "plain 0x02..0x05 markers often equal visible choice count, but high-bit/menu markers include flags or merged-menu context",
        },
        {
            "item": "script-handler target storage",
            "promotion": "grounded-handler-unlinked",
            "evidence": "opcode 0x16 handler writes stream+0x04 to global 0x0059e2a0",
            "remainingGap": f"which producer instance executes for each displayed choice branch is not proven; map1_01a selected prompt trace shows opcode 0x16 rows={len(trace_opcode16_rows)}",
        },
        {
            "item": "map1_01a selected trace producer instance",
            "promotion": "blocked-static-gap",
            "evidence": f"trace rows: opcode 0x18={len(trace_opcode18_rows)}, branch calls to 0x004bfdb4={len(trace_common_branch_rows)}, opcode 0x16 producer rows={len(trace_opcode16_rows)}",
            "remainingGap": "selected option -> next prompt still needs a different producer path or runtime-selected branch sample",
        },
        {
            "item": "0x0059db1f counter",
            "promotion": "reclassified-grounded",
            "evidence": "0x00433107 increments 0x59db1f from an internal random-threshold check, so it is not the user selected option index",
            "remainingGap": "exact selected option -> next prompt edge still needs a different producer proof or runtime sample",
        },
    ]

    summary = {
        "promotionStatus": "common-stream-grounded-handler-cluster-unlinked",
        "commonStreamVaHex": hx(COMMON_STREAM_VA),
        "commonStreamBlockId": block.get("blockId"),
        "commonStreamClassification": block.get("classification"),
        "commonStreamIsSelectedChoiceTarget": False,
        "commonStreamSystemTextLike": block.get("classification") == "system-text-like",
        "commonStreamBranchCallCountInShownChoices": common_branch_call_count,
        "opcode18HandlerVaHex": hx(OPCODE_18_HANDLER),
        "opcode18DecodedAdvanceBytes": 4,
        "opcode18ReadsInlineTarget": False,
        "opcode18CanJumpToDwordAtPlus4": False,
        "opcode18JumpsViaGlobalHex": hx(CHOICE_TARGET_GLOBAL),
        "promptChoiceMarkerLinkedToHandlerCluster": False,
        "opcode18PromptMarkerSameAsHandlerOpcodeProven": False,
        "opcode16HandlerVaHex": hx(OPCODE_16_HANDLER),
        "opcode16WritesChoiceTargetGlobalHex": hx(CHOICE_TARGET_GLOBAL),
        "choiceLimitGlobalHex": hx(CHOICE_LIMIT_GLOBAL),
        "randomGateSuccessCounterGlobalHex": hx(CHOICE_INDEX_GLOBAL),
        "choiceActiveFlagHex": hx(CHOICE_ACTIVE_GLOBAL),
        "choiceIndexInterpretationRejected": True,
        "choiceBranchProofFound": False,
        "selectionToNextPromptProofFound": False,
        "producerInstanceProofFound": False,
        "map1TraceOpcode16ProducerRowCount": len(trace_opcode16_rows),
        "map1TraceChoiceMarkerRowCount": len(trace_opcode18_rows),
        "map1TraceCommonBranchCallRowCount": len(trace_common_branch_rows),
        "choiceMarkerArgScanTotal": choice_arg_scan["total"],
        "choiceMarkerArgExactCountMatches": choice_arg_scan["exactCountMatches"],
        "choiceMarkerArgLow6CountMatches": choice_arg_scan["low6CountMatches"],
        "choiceMarkerArgHighBitRows": choice_arg_scan["highBitRows"],
        "choiceTargetGlobalRefCount": len(refs["choiceTargetGlobalRefs"]),
        "randomGateSuccessCounterRefCount": len(refs["randomGateSuccessCounterRefs"]),
        "commonStreamOpcodeCounts": dict(sorted(common_stream_opcode_counts.items())),
    }

    return {
        "scope": "scene/event VM choice target and common branch stream review",
        "promotionStatus": summary["promotionStatus"],
        "summary": summary,
        "decisions": decisions,
        "handlerCluster": handler_cluster,
        "globalRefs": refs,
        "commonStreamBlock": {
            "blockId": block.get("blockId"),
            "startVaHex": block.get("startVaHex"),
            "endVaHex": block.get("endVaHex"),
            "classification": block.get("classification"),
            "sourceCommandVas": block.get("sourceCommandVas") or [],
            "renderCommandVas": block.get("renderCommandVas") or [],
            "sourceValues": block.get("sourceValues") or [],
            "textLines": block.get("textLines") or [],
            "sampleText": block.get("sampleText") or [],
            "commandOpcodeCounts": block.get("commandOpcodeCounts") or {},
        },
        "commonStreamDecodedRows": common_stream_rows,
        "choiceRows": choice_rows,
        "choiceMarkerArgScan": choice_arg_scan,
        "map1TraceOpcode16Rows": trace_opcode16_rows,
        "map1TraceChoiceMarkerRows": trace_opcode18_rows,
        "map1TraceCommonBranchRows": trace_common_branch_rows,
        "opcode16Handler": opcode16,
        "opcode18Handler": opcode18,
        "scriptTableEntries": [
            row
            for row in script_table.get("entries") or []
            if row.get("opcodeHex") in {"0x16", "0x18"}
        ],
        "remainingProofs": [
            "proof that the selected prompt trace dispatches through the script_handler_table 0x16/0x18 cluster, or proof that it does not",
            "exact producer instance that sets the selected option -> next prompt target for each displayed choice",
            "actual user choice cursor/confirm producer; 0x0059db1f is now excluded as a random-gate counter",
            "selected option -> next prompt edge for choice rows 10/15/19/22",
            "direct field scene execution proof for the selected prompt sequence",
        ],
        "sourceArtifacts": {
            "promptSequenceReview": rel(args.prompt_sequence),
            "dialogueBlocks": rel(args.dialogue_blocks),
            "opcodeDictionary": rel(args.opcode_dictionary),
            "scriptHandlerTable": rel(args.script_handler_table),
            "sceneVmReference": "docs/SCENE_EVENT_VM_REFERENCE.md",
            "map1Trace": rel(args.map1_trace),
            "storyFlowReview": rel(args.story_flow),
        },
    }


def html_doc(payload: dict[str, Any]) -> str:
    s = payload["summary"]

    def tag(value: Any) -> str:
        text = str(value)
        cls = "good" if text in {"grounded-handler", "reclassified-grounded", "grounded-handler-partial-route"} else "warn" if "partial" in text or "grounded" in text else "bad"
        return f'<span class="tag {cls}">{h(text)}</span>'

    metrics = [
        ("common stream", s["commonStreamVaHex"]),
        ("common stream role", "system/menu render stream"),
        ("opcode 0x18 handler", s["opcode18HandlerVaHex"]),
        ("0x18 advance", f"{s['opcode18DecodedAdvanceBytes']} bytes"),
        ("selected target global", s["opcode18JumpsViaGlobalHex"]),
        ("prompt marker linked", s["promptChoiceMarkerLinkedToHandlerCluster"]),
        ("map1 0x16 producer rows", s["map1TraceOpcode16ProducerRowCount"]),
        ("map1 0x18/common branches", f"{s['map1TraceChoiceMarkerRowCount']} / {s['map1TraceCommonBranchCallRowCount']}"),
        ("choice marker arg rows", s["choiceMarkerArgScanTotal"]),
        ("arg exact/low6 matches", f"{s['choiceMarkerArgExactCountMatches']} / {s['choiceMarkerArgLow6CountMatches']}"),
        ("arg high-bit rows", s["choiceMarkerArgHighBitRows"]),
        ("next prompt proof", s["selectionToNextPromptProofFound"]),
    ]
    metric_html = "\n".join(f"<div class='metric'><span>{h(k)}</span><strong>{h(v)}</strong></div>" for k, v in metrics)
    decision_rows = "\n".join(
        f"<tr><td>{h(row['item'])}</td><td>{tag(row['promotion'])}</td><td>{h(row['evidence'])}</td><td>{h(row['remainingGap'])}</td></tr>"
        for row in payload["decisions"]
    )
    handler_rows = "\n".join(
        f"<tr><td>{h(row['name'])}</td><td><code>{h(row['opcodeHex'])}</code></td><td><code>{h(row['handlerVaHex'])}</code></td><td>{tag(row['promotion'])}</td><td>{h(row['evidence'])}</td><td>{h(row['source'])}</td></tr>"
        for row in payload["handlerCluster"]
    )
    global_ref_rows = []
    for name, refs in payload["globalRefs"].items():
        for ref in refs:
            global_ref_rows.append(
                f"<tr><td>{h(name)}</td><td><code>{h(ref['immediateVaHex'])}</code></td><td><code>{h(ref['nearBytes'])}</code></td></tr>"
            )
    common_rows = "\n".join(
        f"<tr><td><code>{h(row['vaHex'])}</code></td><td><code>{h(row['opcodeHex'])}</code></td><td>{h(row['label'])}</td><td><code>{h(row['operandHex'] or '-')}</code></td><td><code>{h(row['rawBytes'])}</code></td></tr>"
        for row in payload["commonStreamDecodedRows"]
    )
    choice_rows = "\n".join(
        f"<tr><td>{h(row['choicePromptOrder'])}</td><td><code>{h(row['choiceStartVaHex'])}</code></td><td>{h(row['choiceText'])}</td><td>{len(row['branchCallsToCommonStream'])}</td><td>{h(row['remainingGap'])}</td></tr>"
        for row in payload["choiceRows"]
    )
    marker_distribution_rows = "\n".join(
        f"<tr><td><code>{h(marker)}</code></td><td>{h(count)}</td></tr>"
        for marker, count in payload["choiceMarkerArgScan"]["distribution"].items()
    )
    marker_sample_rows = "\n".join(
        f"<tr><td>{h(row['promptId'])}</td><td><code>{h(row['startVaHex'])}</code></td><td><code>{h(row['rawBytes'])}</code></td><td>{h(row['lineCount'])}</td><td>{h(row['argByteHex'])}</td><td>{h(row['argLow6'])}</td><td>{h(row['argHighBitsHex'])}</td><td>{h(row['displayText'])}</td></tr>"
        for row in payload["choiceMarkerArgScan"]["sampleRows"]
    )
    text_rows = "\n".join(
        f"<tr><td><code>{h(row.get('vaHex'))}</code></td><td>{h(row.get('text'))}</td></tr>"
        for row in payload["commonStreamBlock"]["textLines"]
    )
    proof_items = "\n".join(f"<li>{h(row)}</li>" for row in payload["remainingProofs"])
    artifact_items = "\n".join(f"<li><code>{h(k)}</code>: <a href='../{h(v)}'>{h(v)}</a></li>" for k, v in payload["sourceArtifacts"].items())
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link rel="icon" href="../favicon.ico" />
  <title>Scene/Event VM choice target 검토</title>
  <style>
    :root {{ color-scheme: light; --border:#d8dee6; --ink:#17202a; --muted:#607080; --panel:#fff; --head:#eef2f6; --bg:#f6f7f9; }}
    * {{ box-sizing:border-box; }}
    body {{ margin:0; background:var(--bg); color:var(--ink); font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; line-height:1.45; }}
    main {{ max-width:1500px; margin:0 auto; padding:18px; }}
    header {{ display:flex; justify-content:space-between; align-items:flex-start; gap:16px; margin-bottom:14px; }}
    h1 {{ margin:0; font-size:24px; }}
    h2 {{ margin:0; font-size:17px; }}
    a {{ color:#185abc; font-weight:700; text-decoration:none; }}
    a:hover {{ text-decoration:underline; }}
    nav {{ display:flex; flex-wrap:wrap; gap:10px; justify-content:flex-end; }}
    section, details {{ background:var(--panel); border:1px solid var(--border); border-radius:8px; margin:14px 0; overflow:hidden; }}
    summary {{ cursor:pointer; padding:12px 14px; background:var(--head); font-weight:800; }}
    .head {{ display:flex; justify-content:space-between; gap:12px; padding:12px 14px; background:var(--head); border-bottom:1px solid var(--border); }}
    .body {{ padding:14px; }}
    .metrics {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; }}
    .metric {{ border:1px solid var(--border); border-radius:6px; background:#f8fafc; padding:10px; }}
    .metric span {{ display:block; color:var(--muted); font-size:12px; }}
    .metric strong {{ display:block; font-size:17px; word-break:break-word; }}
    table {{ width:100%; border-collapse:collapse; }}
    th,td {{ padding:8px 10px; border-bottom:1px solid var(--border); vertical-align:top; text-align:left; font-size:13px; }}
    th {{ background:#f8fafc; color:#344050; }}
    code {{ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }}
    .tag {{ display:inline-block; padding:2px 7px; border-radius:999px; background:#edf2f7; color:#334155; font-size:12px; white-space:nowrap; }}
    .tag.good {{ color:#0f766e; background:#e6f4f1; }}
    .tag.warn {{ color:#a15c00; background:#fff4df; }}
    .tag.bad {{ color:#b42318; background:#fdebea; }}
    .muted {{ color:var(--muted); }}
  </style>
</head>
<body>
<main data-page="scene-event-vm-choice-target-review">
  <header>
    <div>
      <h1>Scene/Event VM choice target 검토</h1>
      <p class="muted"><code>0x004bfdb4</code> 공통 표시 스트림을 분리하고, prompt trace <code>40 18</code> 마커와 handler-table <code>0x16/0x18</code> cluster가 아직 연결되지 않았음을 명시한다.</p>
    </div>
    <nav aria-label="review links">
      <a href="index.html">관리 홈</a>
      <a href="scene_event_vm_review.html">VM 검토</a>
      <a href="scene_event_vm_prompt_sequence_review.html">prompt sequence</a>
      <a href="scene_event_vm_branch_flag_review.html">branch/flag</a>
      <a href="scene_event_vm_random_gate_review.html">random gate</a>
      <a href="selected_scene_text_root_consumer_review.html">consumer</a>
      <a href="scene_event_runtime_evidence_handoff.html">runtime handoff</a>
      <a href="../out/scene_event_vm_choice_target_review.json">choice target JSON</a>
    </nav>
  </header>
  <section>
    <div class="head"><h2>요약</h2><span>{tag(payload['promotionStatus'])}</span></div>
    <div class="body metrics">{metric_html}</div>
  </section>
  <section>
    <div class="head"><h2>Decisions</h2><span>오해 방지 기준</span></div>
    <table><thead><tr><th>item</th><th>promotion</th><th>evidence</th><th>remaining gap</th></tr></thead><tbody>{decision_rows}</tbody></table>
  </section>
  <section>
    <div class="head"><h2>Handler Cluster</h2><span>0x16 -> 0x59e2a0 -> 0x18</span></div>
    <table><thead><tr><th>name</th><th>opcode</th><th>handler</th><th>promotion</th><th>evidence</th><th>source</th></tr></thead><tbody>{handler_rows}</tbody></table>
  </section>
  <section>
    <div class="head"><h2>Choice Rows</h2><span>next prompt still blocked</span></div>
    <table><thead><tr><th>choice</th><th>start</th><th>text</th><th>calls to 0x004bfdb4</th><th>remaining gap</th></tr></thead><tbody>{choice_rows}</tbody></table>
  </section>
  <section>
    <div class="head"><h2>Prompt Choice Marker Argument</h2><span>global scan</span></div>
    <div class="body">
      <p class="muted">Plain marker bytes often match visible choice count. High-bit values such as <code>0x82</code>, <code>0x83</code>, <code>0x8c</code>, and <code>0xc2</code> remain flag/menu-layout candidates, not next-target proof.</p>
    </div>
    <table><thead><tr><th>marker byte</th><th>rows</th></tr></thead><tbody>{marker_distribution_rows}</tbody></table>
  </section>
  <details>
    <summary>Prompt Choice Marker Samples</summary>
    <table><thead><tr><th>prompt</th><th>start</th><th>raw</th><th>lines</th><th>arg</th><th>low6</th><th>high</th><th>text</th></tr></thead><tbody>{marker_sample_rows}</tbody></table>
  </details>
  <section>
    <div class="head"><h2>Common Stream Decode</h2><span>system/menu display stream</span></div>
    <table><thead><tr><th>VA</th><th>opcode</th><th>label</th><th>operand</th><th>raw</th></tr></thead><tbody>{common_rows}</tbody></table>
  </section>
  <details>
    <summary>Common Stream Text Lines</summary>
    <table><thead><tr><th>VA</th><th>text</th></tr></thead><tbody>{text_rows}</tbody></table>
  </details>
  <details>
    <summary>Global Reference Scan</summary>
    <table><thead><tr><th>global</th><th>immediate VA</th><th>near bytes</th></tr></thead><tbody>{''.join(global_ref_rows)}</tbody></table>
  </details>
  <section>
    <div class="head"><h2>Remaining Proofs</h2><span>다음 분석 대상</span></div>
    <div class="body"><ul>{proof_items}</ul></div>
  </section>
  <details>
    <summary>Source Artifacts</summary>
    <div class="body"><ul>{artifact_items}</ul></div>
  </details>
</main>
<script>
window.HWANSE_SCENE_EVENT_VM_CHOICE_TARGET_REVIEW_READY = true;
window.HWANSE_SCENE_EVENT_VM_CHOICE_TARGET_REVIEW = {{
  choiceTargetReviewImplemented: true,
  commonStreamVaHex: "{s['commonStreamVaHex']}",
  commonStreamIsSelectedChoiceTarget: false,
  opcode18ReadsInlineTarget: false,
  opcode18DecodedAdvanceBytes: {s['opcode18DecodedAdvanceBytes']},
  opcode18JumpsViaGlobalHex: "{s['opcode18JumpsViaGlobalHex']}",
  promptChoiceMarkerLinkedToHandlerCluster: false,
  opcode18PromptMarkerSameAsHandlerOpcodeProven: false,
  opcode16WritesChoiceTargetGlobalHex: "{s['opcode16WritesChoiceTargetGlobalHex']}",
  map1TraceOpcode16ProducerRowCount: {s['map1TraceOpcode16ProducerRowCount']},
  map1TraceChoiceMarkerRowCount: {s['map1TraceChoiceMarkerRowCount']},
  map1TraceCommonBranchCallRowCount: {s['map1TraceCommonBranchCallRowCount']},
  choiceMarkerArgScanTotal: {s['choiceMarkerArgScanTotal']},
  choiceMarkerArgExactCountMatches: {s['choiceMarkerArgExactCountMatches']},
  choiceMarkerArgLow6CountMatches: {s['choiceMarkerArgLow6CountMatches']},
  choiceMarkerArgHighBitRows: {s['choiceMarkerArgHighBitRows']},
  choiceIndexInterpretationRejected: true,
  choiceBranchProofFound: false,
  selectionToNextPromptProofFound: false,
  promotionStatus: "{h(payload['promotionStatus'])}"
}};
</script>
</body>
</html>
"""


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--dialogue-blocks", type=Path, default=OUT / "event_dialogue_blocks.json")
    parser.add_argument("--opcode-dictionary", type=Path, default=OUT / "scene_event_vm_opcode_dictionary.json")
    parser.add_argument("--prompt-sequence", type=Path, default=OUT / "scene_event_vm_prompt_sequence_review.json")
    parser.add_argument("--script-handler-table", type=Path, default=OUT / "script_handler_table.json")
    parser.add_argument("--map1-trace", type=Path, default=OUT / "map1_01a_scene_trace.json")
    parser.add_argument("--story-flow", type=Path, default=OUT / "story_flow_review.json")
    parser.add_argument("--json-out", type=Path, default=OUT / "scene_event_vm_choice_target_review.json")
    parser.add_argument("--html-out", type=Path, default=WEB / "scene_event_vm_choice_target_review.html")
    parser.add_argument("--web-out", type=Path, default=WEB / "scene_event_vm_choice_target_review.html")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    payload = build(args)
    write_json(args.json_out, payload)
    html_payload = html_doc(payload)
    write_text(args.html_out, html_payload)
    write_text(args.web_out, html_payload)
    print(f"wrote scene/event VM choice target review -> {args.web_out}")


if __name__ == "__main__":
    main()
