#!/usr/bin/env python3
"""Index dialogue-looking event stream blocks anchored by opcode 0x0d text sources."""
from __future__ import annotations

import argparse
import html
import json
import re
import struct
from collections import Counter
from pathlib import Path

from probe_exe_scene_tables import classify_cns, find_cns_strings, read_sections, va_to_offset
from summarize_event_text_source_flow import load_json


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
WINDOW_BEFORE = 0x60
WINDOW_AFTER = 0x1C0
MERGE_GAP = 0x40
TEXT_RE = re.compile(
    r"[\u3131-\u318e\uac00-\ud7a3A-Za-z0-9]"
    r"[\u3131-\u318e\uac00-\ud7a3A-Za-z0-9 !?.:_+\-,~()/]{1,}"
)
COMMAND_LABELS = {
    0x02: "text separator/continuation",
    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 triplet",
    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",
    0x1B: "select script data bank",
    0x35: "literal CP949 text payload",
    0x37: "set global timer/state",
}
VM_TRACE_OPCODES = {"0x02", "0x0b", "0x0c", "0x0d"}
GROUNDED_CONTROL_OPCODES = {
    0x03: {
        "handlerVaHex": "0x0041b7bb",
        "decodedLength": 4,
        "action": "set-display-font-row",
        "classification": "handler-grounded display/font row state; browser does not execute full VM effect",
    },
    0x04: {
        "handlerVaHex": "0x0041b7ed",
        "decodedLength": 4,
        "action": "set-display-timer-state",
        "classification": "handler-grounded display timer/state seed; browser does not execute full VM effect",
    },
    0x06: {
        "handlerVaHex": "0x0041b8ee",
        "decodedLength": 4,
        "action": "wait-for-input-release",
        "classification": "handler-grounded input wait latch; browser does not execute original wait loop",
    },
    0x07: {
        "handlerVaHex": "0x0041b956",
        "decodedLength": 8,
        "action": "set-object-fixed-position",
        "classification": "handler-grounded object position write; browser does not execute full object VM",
    },
    0x08: {
        "handlerVaHex": "0x0041b9a0",
        "decodedLength": None,
        "action": "set-display-style-triplet",
        "classification": "handler-grounded display style write with mode-dependent length",
    },
    0x09: {
        "handlerVaHex": "0x0041bac6",
        "decodedLength": 8,
        "action": "call-event-stream-branch",
        "classification": "handler-grounded VM branch call; browser records but does not follow control flow",
    },
    0x0A: {
        "handlerVaHex": "0x0041bb15",
        "decodedLength": None,
        "action": "return-from-event-stream-branch",
        "classification": "handler-grounded VM stack return; browser records but does not pop original stack",
    },
    0x0E: {
        "handlerVaHex": "0x0041bd4f",
        "decodedLength": 8,
        "action": "set-cursor-relative-to-text-origin",
        "classification": "handler-grounded cursor offset; browser does not execute full layout VM",
    },
    0x15: {
        "handlerVaHex": "0x0041d476",
        "decodedLength": 8,
        "action": "move-text-origin-and-cursor",
        "classification": "handler-grounded text origin/cursor offset; browser does not execute full layout VM",
    },
    0x1B: {
        "handlerVaHex": "0x0041d89d",
        "decodedLength": 4,
        "action": "select-script-data-bank",
        "classification": "handler-grounded data-bank selection; browser does not execute full data VM",
    },
    0x37: {
        "handlerVaHex": "0x00420028",
        "decodedLength": 4,
        "action": "set-global-timer-state",
        "classification": "handler-grounded global timer/state seed; browser does not execute full VM effect",
    },
}
SYSTEM_MARKERS = {
    "처음부터",
    "이어서",
    "이어서하기",
    "시나리오",
    "시나리오 선택",
    "도구",
    "무기",
    "방어구",
    "소지",
    "모드",
    "프로듀서",
    "맵 디자인",
}


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


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


def is_hangul(char: str) -> bool:
    return "\u3131" <= char <= "\u318e" or "\uac00" <= char <= "\ud7a3"


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


def 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 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 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
    clamped_start = max(start_va, section["va"])
    clamped_end = min(end_va, section["va"] + section["raw_size"])
    if clamped_end <= clamped_start:
        return None
    start_off = va_to_offset(sections, clamped_start)
    end_off = va_to_offset(sections, clamped_end - 1)
    if start_off is None or end_off is None:
        return None
    return start_off, end_off + 1, clamped_start


def normal_text(decoded: str) -> str:
    chars = []
    for char in decoded:
        code = ord(char)
        if char == "\u3000" or char.isspace():
            chars.append(" ")
        elif 0x20 <= code <= 0x7E or is_hangul(char):
            chars.append(char)
        else:
            chars.append(" ")
    return "".join(chars)


def clean_match_text(text: str) -> str:
    text = " ".join(text.split())
    return re.sub(r"^[0-9]{1,2} (?=[\u3131-\u318e\uac00-\ud7a3])", "", text)


def text_lines(data: bytes, base_va: int) -> list[dict]:
    lines = []
    current = bytearray()
    current_start = 0
    for index, byte in enumerate(data + b"\0"):
        if byte in {0x00, 0x40}:
            if len(current) >= 4:
                decoded = normal_text(current.decode("cp949", "ignore"))
                for match in TEXT_RE.finditer(decoded):
                    text = clean_match_text(match.group(0))
                    hangul_count = sum(1 for char in text if is_hangul(char))
                    if len(text) >= 2 and hangul_count >= 2:
                        lines.append({
                            "vaHex": hex32(base_va + current_start),
                            "text": text,
                            "hangulCount": hangul_count,
                        })
            current.clear()
            current_start = index + 1
        else:
            if not current:
                current_start = index
            current.append(byte)
    return lines


def event_text_source_rows(event_text_source_flow: dict) -> list[dict]:
    rows = []
    for row in event_text_source_flow.get("rows") or []:
        if row.get("opcodeHex") != "0x0d":
            continue
        if row.get("confidence") != "medium" or row.get("currentDwordPointer"):
            continue
        if not row.get("nearbyTextSnippets"):
            continue
        rows.append(row)
    rows.sort(key=lambda row: int(row["vaHex"], 16))
    return rows


def merge_source_windows(source_rows: list[dict]) -> list[dict]:
    windows = []
    for row in source_rows:
        va = int(row["vaHex"], 16)
        start = va - WINDOW_BEFORE
        end = va + WINDOW_AFTER
        if not windows or start > windows[-1]["endVa"] + MERGE_GAP:
            windows.append({
                "startVa": start,
                "endVa": end,
                "sourceRows": [row],
            })
        else:
            windows[-1]["endVa"] = max(windows[-1]["endVa"], end)
            windows[-1]["sourceRows"].append(row)
    return windows


def command_opcode_counts(data: bytes) -> Counter:
    counts: Counter[str] = Counter()
    for index in range(max(0, len(data) - 1)):
        if data[index] == 0x40:
            counts[hex8(data[index + 1])] += 1
    return counts


def cns_refs_in_interval(
    exe: bytes,
    sections: list[dict],
    strings: dict[int, str],
    start_va: int,
    end_va: int,
    radius: int = 0x80,
) -> list[dict]:
    offsets = interval_offsets(sections, start_va - radius, end_va + radius)
    if offsets is None:
        return []
    start_off, end_off, _ = offsets
    start_off -= start_off % 4
    rows = []
    seen = set()
    for off in range(start_off, max(start_off, end_off - 3), 4):
        value = dword_at(exe, off)
        if value not in strings:
            continue
        key = (off, value)
        if key in seen:
            continue
        seen.add(key)
        ref_va = section_for_offset_va(sections, off)
        rows.append({
            "refVaHex": hex32(ref_va) if ref_va is not None else None,
            "targetVaHex": hex32(value),
            "name": strings[value],
            "kind": classify_cns(strings[value]),
        })
    return rows


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


def unique_by_name(rows: list[dict]) -> list[dict]:
    deduped = []
    seen = set()
    for row in rows:
        name = row.get("name")
        if not name or name in seen:
            continue
        seen.add(name)
        deduped.append(row)
    return deduped


def unique_contexts(rows: list[dict]) -> list[dict]:
    deduped = []
    seen = set()
    for row in rows:
        key = (
            row.get("map"),
            row.get("recordVaHex"),
            row.get("conditionVaHex"),
            tuple(row.get("targets") or []),
        )
        if key in seen:
            continue
        seen.add(key)
        deduped.append(row)
    return deduped


def first_unique_text(lines: list[dict], limit: int = 14) -> list[str]:
    values = []
    seen = set()
    for row in lines:
        text = row["text"]
        if text in seen:
            continue
        seen.add(text)
        values.append(text)
        if len(values) >= limit:
            break
    return values


def classify_block(block: dict) -> str:
    if block["textLineCount"] < 2:
        return "text-fragment"
    unique_text = {row["text"] for row in block.get("textLines") or []}
    if len(unique_text & SYSTEM_MARKERS) >= 2:
        return "system-text-like"
    if block.get("fieldMaps"):
        return "map-dialogue-like"
    if any(name.startswith("btl_") for name in block.get("resourceNames") or []):
        return "battle-dialogue-like"
    if block["textLineCount"] >= 3:
        return "story-dialogue-like"
    return "text-fragment"


def source_value_counts(rows: list[dict]) -> list[dict]:
    counts = Counter()
    for row in rows:
        value = (row.get("payload") or {}).get("textSourceValueHex")
        if value:
            counts[value] += 1
    return [
        {"valueHex": value, "count": count}
        for value, count in counts.most_common()
    ]


def command_trace(rows: list[dict]) -> list[dict]:
    commands = []
    for row in rows:
        opcode = row.get("opcodeHex")
        if opcode not in VM_TRACE_OPCODES:
            continue
        payload = row.get("payload") or {}
        command = {
            "vaHex": row.get("vaHex"),
            "opcodeHex": opcode,
            "handlerVaHex": row.get("handlerVaHex"),
            "offsetMod4": row.get("offsetMod4"),
            "confidence": row.get("confidence"),
            "classification": row.get("classification"),
        }
        if opcode == "0x0d":
            command["action"] = "set-context-text-source"
            command["textSourceValueHex"] = payload.get("textSourceValueHex")
            command["sourceKind"] = payload.get("sourceKind")
        elif opcode == "0x0b":
            command["action"] = "render-current-context-text"
            command["sourceKind"] = payload.get("sourceKind")
        elif opcode == "0x0c":
            command["action"] = "spawn-child-object-with-context"
            command["sourceKind"] = payload.get("sourceKind")
        snippets = []
        for snippet in row.get("nearbyTextSnippets") or []:
            text = clean_match_text(normal_text(str(snippet.get("text") or "")))
            if text:
                snippets.append({
                    "vaHex": snippet.get("vaHex"),
                    "text": text,
                })
            if len(snippets) >= 6:
                break
        if snippets:
            command["nearbyText"] = snippets
        commands.append(command)
    commands.sort(key=lambda row: int(str(row.get("vaHex") or "0"), 16))
    return commands


def separator_command_trace(data: bytes, base_va: int) -> list[dict]:
    commands = []
    for index in range(max(0, len(data) - 1)):
        if data[index] != 0x40 or data[index + 1] != 0x02:
            continue
        commands.append({
            "vaHex": hex32(base_va + index),
            "opcodeHex": "0x02",
            "handlerVaHex": "0x0041b771",
            "decodedLength": 4,
            "decodedLengthHex": "0x4",
            "confidence": "handler-grounded-storage-scan",
            "classification": "dialogue cursor/separator command; copies context+0xce to 0xd6 and advances context+0xda",
            "action": "advance-dialogue-cursor-or-separator",
            "sourceKind": "context-font-row",
        })
    return commands


def grounded_control_events(data: bytes, base_va: int) -> list[dict]:
    events = []
    for index in range(max(0, len(data) - 1)):
        if data[index] != 0x40:
            continue
        opcode = data[index + 1]
        meta = GROUNDED_CONTROL_OPCODES.get(opcode)
        if not meta:
            continue
        decoded_length = meta.get("decodedLength")
        event = {
            "vaHex": hex32(base_va + index),
            "opcodeHex": hex8(opcode),
            "handlerVaHex": meta["handlerVaHex"],
            "decodedLength": decoded_length,
            "decodedLengthHex": f"0x{decoded_length:x}" if isinstance(decoded_length, int) else None,
            "confidence": "handler-grounded-storage-scan",
            "classification": meta["classification"],
            "action": meta["action"],
            "browserExecutesEffect": False,
        }
        if opcode in {0x03, 0x06, 0x1B, 0x37}:
            event["operandByte2"] = data[index + 2] if index + 2 < len(data) else None
        elif opcode in {0x04, 0x0E, 0x15}:
            event["operandWord2"] = word_at(data, index + 2)
            event["operandWord4"] = word_at(data, index + 4)
        elif opcode == 0x07:
            event["xWord"] = word_at(data, index + 2)
            event["yWord"] = word_at(data, index + 4)
        elif opcode == 0x08:
            mode = data[index + 2] if index + 2 < len(data) else None
            event["mode"] = mode
            if mode == 0:
                event["decodedLength"] = 4
                event["decodedLengthHex"] = "0x4"
                event["tableIndexByte"] = data[index + 3] if index + 3 < len(data) else None
            elif mode == 1:
                event["decodedLength"] = 8
                event["decodedLengthHex"] = "0x8"
                event["immediateStyleBytes"] = [
                    data[index + item] if index + item < len(data) else None
                    for item in (5, 6, 7)
                ]
            else:
                event["decodedLengthHex"] = "mode0=0x4/mode1=0x8"
        elif opcode == 0x09:
            target = dword_at(data, index + 4)
            event["branchTargetVaHex"] = hex32(target) if target is not None else None
            event["continuationVaHex"] = hex32(base_va + index + 8)
        events.append(event)
    return events


def literal_text_events(block: dict) -> list[dict]:
    limit = int((block.get("commandOpcodeCounts") or {}).get("0x35") or 0)
    if limit <= 0:
        return []
    events = []
    seen = set()
    for row in block.get("textLines") or []:
        text = row.get("text")
        if not text or text in seen:
            continue
        seen.add(text)
        events.append({
            "vaHex": row.get("vaHex"),
            "opcodeHex": "0x35",
            "action": "literal-cp949-text-payload",
            "text": text,
            "confidence": "storage-inferred",
            "source": "dialogue-block-text-lines",
        })
        if len(events) >= limit:
            break
    return events


def vm_trace(block: dict, rows_in_window: list[dict], data: bytes, base_va: int) -> dict:
    command_counts = block.get("commandOpcodeCounts") or {}
    commands = command_trace(rows_in_window) + separator_command_trace(data, base_va)
    commands.sort(key=lambda row: int(str(row.get("vaHex") or "0"), 16))
    literal_events = literal_text_events(block)
    control_events = grounded_control_events(data, base_va)
    separator_events = [
        command
        for command in commands
        if command.get("opcodeHex") == "0x02"
    ]
    supported = {"0x02", "0x0b", "0x0c", "0x0d", "0x35"}
    unsupported = {
        opcode: count
        for opcode, count in sorted(command_counts.items())
        if opcode not in supported
    }
    return {
        "source": "partial-opcode-replay",
        "implementedOpcodes": ["0x02", "0x0d", "0x0b", "0x35"],
        "observedCommandCount": sum(command_counts.values()),
        "traceCommandCount": len(commands),
        "separatorCommandCount": command_counts.get("0x02", 0),
        "separatorEventCount": len(separator_events),
        "separatorEvents": separator_events[:32],
        "groundedControlCommandCount": sum(command_counts.get(hex8(opcode), 0) for opcode in GROUNDED_CONTROL_OPCODES),
        "groundedControlEventCount": len(control_events),
        "groundedControlOpcodeSet": sorted({event["opcodeHex"] for event in control_events}),
        "groundedControlEvents": control_events[:64],
        "sourceCommandCount": block.get("sourceCommandCount", 0),
        "renderCommandCount": block.get("renderCommandCount", 0),
        "literalTextCommandCount": command_counts.get("0x35", 0),
        "literalTextEventCount": len(literal_events),
        "literalTextEvents": literal_events,
        "unsupportedOpcodeCounts": unsupported,
        "commands": commands,
        "routeLinkedEventVmExecution": bool(block.get("routeContexts")),
        "browserEventVmPartialReplayImplemented": True,
        "browserEventVmFullImplementation": False,
    }


def build_block(
    exe: bytes,
    sections: list[dict],
    strings: dict[int, str],
    event_rows: list[dict],
    window: dict,
    index: int,
) -> dict | None:
    start_va = window["startVa"]
    end_va = window["endVa"]
    offsets = interval_offsets(sections, start_va, end_va)
    if offsets is None:
        return None
    start_off, end_off, base_va = offsets
    data = exe[start_off:end_off]
    rows_in_window = [
        row
        for row in event_rows
        if start_va <= int(row.get("vaHex", "0"), 16) < end_va
    ]
    cns_refs = cns_refs_in_interval(exe, sections, strings, start_va, end_va)
    for row in rows_in_window:
        for cns in row.get("nearbyCns") or []:
            cns_refs.append({
                "refVaHex": cns.get("refVaHex"),
                "targetVaHex": None,
                "name": cns.get("name"),
                "kind": classify_cns(cns.get("name", "")),
            })
    resources = unique_by_name(cns_refs)
    field_maps = [row["name"] for row in resources if row.get("kind") == "map"]
    tilesets = [row["name"] for row in resources if row.get("kind") == "tileset"]
    route_contexts = unique_contexts([
        context
        for row in rows_in_window
        for context in (row.get("routeContexts") or [])
    ])
    line_rows = text_lines(data, base_va)
    command_counts = command_opcode_counts(data)
    source_rows = [row for row in rows_in_window if row.get("opcodeHex") == "0x0d"]
    render_rows = [row for row in rows_in_window if row.get("opcodeHex") == "0x0b"]
    block = {
        "index": index,
        "blockId": f"event-dialogue-block-{index:03d}",
        "startVa": start_va,
        "startVaHex": hex32(start_va),
        "endVa": end_va,
        "endVaHex": hex32(end_va),
        "byteCount": end_va - start_va,
        "sourceCommandCount": len(source_rows),
        "sourceCommandVas": [row["vaHex"] for row in source_rows],
        "renderCommandCount": len(render_rows),
        "renderCommandVas": [row["vaHex"] for row in render_rows[:32]],
        "sourceValues": source_value_counts(source_rows),
        "commandOpcodeCounts": dict(sorted(command_counts.items())),
        "commandLabels": {
            hex8(opcode): label
            for opcode, label in sorted(COMMAND_LABELS.items())
            if command_counts.get(hex8(opcode))
        },
        "resourceNames": [row["name"] for row in resources],
        "resources": resources,
        "fieldMaps": field_maps,
        "tilesets": tilesets,
        "routeContexts": route_contexts,
        "textLineCount": len(line_rows),
        "uniqueTextLineCount": len({row["text"] for row in line_rows}),
        "textLines": line_rows,
        "sampleText": first_unique_text(line_rows),
    }
    block["classification"] = classify_block(block)
    block["vmTrace"] = vm_trace(block, rows_in_window, data, base_va)
    return block


def build_summary(exe: bytes, event_text_source_flow: dict) -> dict:
    sections = read_sections(exe)
    strings = find_cns_strings(exe, sections)
    event_rows = event_text_source_flow.get("rows") or []
    source_rows = event_text_source_rows(event_text_source_flow)
    windows = merge_source_windows(source_rows)
    blocks = []
    for index, window in enumerate(windows, start=1):
        block = build_block(exe, sections, strings, event_rows, window, index)
        if block is not None and block["textLineCount"]:
            blocks.append(block)
    class_counts = Counter(block["classification"] for block in blocks)
    command_counts: Counter[str] = Counter()
    unique_lines = set()
    for block in blocks:
        command_counts.update(block.get("commandOpcodeCounts") or {})
        unique_lines.update(row["text"] for row in block.get("textLines") or [])
    route_linked_blocks = [block for block in blocks if block.get("routeContexts")]
    dialogue_like = [
        block
        for block in blocks
        if block.get("classification") in {
            "map-dialogue-like",
            "battle-dialogue-like",
            "story-dialogue-like",
        }
    ]
    return {
        "scope": "event/object VM dialogue-looking CP949 blocks anchored by opcode 0x0d context+0x28 producers",
        "windowBefore": WINDOW_BEFORE,
        "windowAfter": WINDOW_AFTER,
        "mergeGap": MERGE_GAP,
        "sourceCommandSeedCount": len(source_rows),
        "blockCount": len(blocks),
        "dialogueLikeBlockCount": len(dialogue_like),
        "routeLinkedBlockCount": len(route_linked_blocks),
        "mapLinkedBlockCount": sum(1 for block in blocks if block.get("fieldMaps")),
        "textLineCount": sum(block["textLineCount"] for block in blocks),
        "uniqueTextLineCount": len(unique_lines),
        "classificationCounts": dict(sorted(class_counts.items())),
        "commandOpcodeCounts": dict(sorted(command_counts.items())),
        "conclusion": (
            "Opcode 0x0d source commands now anchor a reusable index of nearby CP949 story-dialogue-looking "
            "blocks, including their adjacent 0x0b render commands and literal text payload markers. "
            "Opcode 0x02 cursor/separator commands are now preserved in the partial replay trace. "
            "This identifies dialogue storage candidates for playback work, but it still does not prove route-linked "
            "event execution or implement the event VM in the browser."
        ),
        "blocks": blocks,
    }


def text_sample(block: dict, limit: int = 10) -> str:
    sample = " / ".join(block.get("sampleText", [])[:limit])
    return sample or "-"


def source_value_summary(block: dict) -> str:
    values = block.get("sourceValues") or []
    if not values:
        return "-"
    return ", ".join(
        f"{row['valueHex']}x{row['count']}"
        for row in values[:8]
    )


def resource_summary(block: dict) -> str:
    names = block.get("resourceNames") or []
    return ", ".join(names[:8]) or "-"


def opcode_summary(block: dict) -> str:
    counts = block.get("commandOpcodeCounts") or {}
    interesting = [
        opcode
        for opcode in ["0x0d", "0x0b", "0x35", "0x02", "0x0c"]
        if counts.get(opcode)
    ]
    return ", ".join(f"{opcode}:{counts[opcode]}" for opcode in interesting) or "-"


def block_detail_html(block: dict) -> str:
    lines = "\n".join(
        f"<li><code>{html.escape(row['vaHex'])}</code> {html.escape(row['text'])}</li>"
        for row in block.get("textLines", [])[:80]
    )
    if block.get("textLineCount", 0) > 80:
        lines += f"<li>... {block['textLineCount'] - 80} more lines</li>"
    return (
        f"<details><summary>{html.escape(block['blockId'])} "
        f"{html.escape(block['classification'])} "
        f"{html.escape(block['startVaHex'])}..{html.escape(block['endVaHex'])}</summary>"
        f"<p>source/render: {block['sourceCommandCount']}/{block['renderCommandCount']}; "
        f"source values: {html.escape(source_value_summary(block))}; "
        f"resources: {html.escape(resource_summary(block))}</p>"
        f"<ol>{lines}</ol></details>"
    )


def html_page(summary: dict) -> str:
    rows = []
    for block in summary.get("blocks") or []:
        rows.append(
            "<tr>"
            f"<td><code>{html.escape(block['blockId'])}</code></td>"
            f"<td><code>{html.escape(block['startVaHex'])}..{html.escape(block['endVaHex'])}</code></td>"
            f"<td>{html.escape(block['classification'])}</td>"
            f"<td>{block['sourceCommandCount']}/{block['renderCommandCount']}</td>"
            f"<td>{html.escape(source_value_summary(block))}</td>"
            f"<td>{html.escape(opcode_summary(block))}</td>"
            f"<td>{html.escape(resource_summary(block))}</td>"
            f"<td>{html.escape(text_sample(block, 8))}</td>"
            "</tr>"
        )
    detail_rows = "\n".join(block_detail_html(block) for block in summary.get("blocks") or [])
    return "\n".join([
        "<!doctype html>",
        '<html lang="en">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>Event Dialogue Blocks</title>",
        "  <style>body{font-family:system-ui,sans-serif;background:#101010;color:#eee;margin:24px}table{border-collapse:collapse;width:100%;margin-bottom:24px}td,th{border:1px solid #333;padding:6px 8px;vertical-align:top}th{background:#1d1d1d}code{color:#f5d76e}details{border:1px solid #333;margin:10px 0;padding:8px;background:#161616}summary{cursor:pointer;font-weight:600}li{margin:3px 0}</style>",
        "</head>",
        "<body>",
        "  <h1>Event Dialogue Blocks</h1>",
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        f"  <p>Blocks: {summary['blockCount']}; dialogue-like: {summary['dialogueLikeBlockCount']}; "
        f"route-linked: {summary['routeLinkedBlockCount']}; text lines: {summary['textLineCount']} "
        f"({summary['uniqueTextLineCount']} unique).</p>",
        "  <table><thead><tr><th>block</th><th>range</th><th>class</th><th>source/render</th><th>source values</th><th>opcodes</th><th>resources</th><th>sample text</th></tr></thead>",
        f"  <tbody>{''.join(rows) or '<tr><td colspan=\"8\">No dialogue-looking blocks.</td></tr>'}</tbody></table>",
        "  <h2>Block Text</h2>",
        detail_rows or "<p>No block text.</p>",
        "</body>",
        "</html>",
        "",
    ])


def runtime_blocks(summary: dict) -> list[dict]:
    blocks = []
    for block in summary.get("blocks") or []:
        blocks.append({
            "blockId": block["blockId"],
            "classification": block["classification"],
            "startVaHex": block["startVaHex"],
            "endVaHex": block["endVaHex"],
            "sourceCommandVas": block.get("sourceCommandVas") or [],
            "sourceValues": block.get("sourceValues") or [],
            "resourceNames": block.get("resourceNames") or [],
            "fieldMaps": block.get("fieldMaps") or [],
            "tilesets": block.get("tilesets") or [],
            "vmTrace": block.get("vmTrace") or {},
            "lines": [row["text"] for row in block.get("textLines") or []],
        })
    return blocks


def write_outputs(summary: dict, out_dir: Path = OUT) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "event_dialogue_blocks.json").write_text(
        json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    (out_dir / "event_dialogue_blocks.html").write_text(html_page(summary), encoding="utf-8")
    (out_dir / "event_dialogue_blocks_runtime.js").write_text(
        "window.HWANSE_EVENT_DIALOGUE_BLOCKS = "
        + json.dumps(runtime_blocks(summary), ensure_ascii=False, separators=(",", ":"))
        + ";\n",
        encoding="utf-8",
    )


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--event-text-source-flow", type=Path, default=OUT / "event_text_source_flow.json")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(
        args.exe.read_bytes(),
        load_json(args.event_text_source_flow, {}),
    )
    write_outputs(summary, args.out_dir)
    print(
        "wrote event dialogue blocks -> "
        f"{args.out_dir / 'event_dialogue_blocks.html'} "
        f"({summary['blockCount']} blocks)"
    )


if __name__ == "__main__":
    main()
