#!/usr/bin/env python3
"""Build a byte/block coverage view for interpreted Hwanse2.exe regions."""
from __future__ import annotations

import argparse
import html
import json
import re
import struct
from collections import Counter, defaultdict
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any


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

STATUS_PRIORITY = {
    "interpreted": 3,
    "partial": 2,
    "candidate": 1,
}

STATUS_LABELS = {
    "interpreted": "interpreted / accepted",
    "partial": "partial / bounded",
    "candidate": "candidate / unpromoted",
}

CATEGORY_LABELS = {
    "pe": "PE headers",
    "imports": "imports/IAT/call refs",
    "text-table": "text tables",
    "dialogue": "dialogue/event screen prompts",
    "monster": "monster stat/name data",
    "map-scene": "map scene/resource records",
    "event-transition": "event/transition candidates",
    "event-vm": "event VM handlers/commands",
    "directx": "DirectDraw/DirectInput runtime sites",
    "battle": "battle numeric/resource candidates",
    "opening": "opening/runtime sequence",
    "input": "input keymap tables",
    "ending": "ending/rank evaluation",
    "actor-state": "actor state slots",
    "inventory-state": "inventory item slots",
}

CATEGORY_CLASSES = {
    "pe": "cat-pe",
    "imports": "cat-imports",
    "text-table": "cat-text",
    "dialogue": "cat-dialogue",
    "monster": "cat-monster",
    "map-scene": "cat-map",
    "event-transition": "cat-event",
    "event-vm": "cat-vm",
    "directx": "cat-directx",
    "battle": "cat-battle",
    "opening": "cat-opening",
    "input": "cat-input",
    "ending": "cat-ending",
    "actor-state": "cat-actor",
    "inventory-state": "cat-inventory",
}


@dataclass(frozen=True)
class Section:
    name: str
    virtual_address: int
    virtual_size: int
    raw_offset: int
    raw_size: int
    characteristics: int

    @property
    def raw_end(self) -> int:
        return self.raw_offset + self.raw_size

    @property
    def rva_end(self) -> int:
        return self.virtual_address + max(self.virtual_size, self.raw_size)


@dataclass
class CoverageRange:
    start: int
    end: int
    status: str
    category: str
    label: str
    source: str
    confidence: str = "medium"
    va_hex: str = ""
    notes: str = ""

    @property
    def byte_count(self) -> int:
        return max(0, self.end - self.start)

    def to_json(self) -> dict[str, Any]:
        data = asdict(self)
        data["startHex"] = f"0x{self.start:06x}"
        data["endHex"] = f"0x{self.end:06x}"
        data["byteCount"] = self.byte_count
        data["categoryLabel"] = CATEGORY_LABELS.get(self.category, self.category)
        data["statusLabel"] = STATUS_LABELS.get(self.status, self.status)
        return data


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


def parse_int(value: Any) -> int | None:
    if value is None:
        return None
    if isinstance(value, int):
        return value
    if isinstance(value, str):
        text = value.strip()
        if not text:
            return None
        try:
            return int(text, 16) if text.lower().startswith("0x") else int(text)
        except ValueError:
            return None
    return None


def parse_pe(data: bytes) -> tuple[int, int, list[Section]]:
    if data[:2] != b"MZ":
        raise ValueError("Hwanse2.exe is not an MZ executable")
    pe_offset = struct.unpack_from("<I", data, 0x3C)[0]
    if data[pe_offset : pe_offset + 4] != b"PE\0\0":
        raise ValueError("Hwanse2.exe is not a PE executable")
    section_count = struct.unpack_from("<H", data, pe_offset + 6)[0]
    optional_header_size = struct.unpack_from("<H", data, pe_offset + 20)[0]
    optional_offset = pe_offset + 24
    magic = struct.unpack_from("<H", data, optional_offset)[0]
    if magic == 0x10B:
        image_base = struct.unpack_from("<I", data, optional_offset + 28)[0]
    elif magic == 0x20B:
        image_base = struct.unpack_from("<Q", data, optional_offset + 24)[0]
    else:
        raise ValueError(f"unsupported PE optional header magic 0x{magic:04x}")
    size_of_headers = struct.unpack_from("<I", data, optional_offset + 60)[0]
    sections = []
    section_offset = optional_offset + optional_header_size
    for index in range(section_count):
        offset = section_offset + index * 40
        name = data[offset : offset + 8].split(b"\0", 1)[0].decode("ascii", "replace")
        virtual_size, virtual_address, raw_size, raw_offset = struct.unpack_from("<IIII", data, offset + 8)
        characteristics = struct.unpack_from("<I", data, offset + 36)[0]
        sections.append(
            Section(
                name=name,
                virtual_address=virtual_address,
                virtual_size=virtual_size,
                raw_offset=raw_offset,
                raw_size=raw_size,
                characteristics=characteristics,
            )
        )
    return image_base, size_of_headers, sections


class ExeMapper:
    def __init__(self, data: bytes, image_base: int, sections: list[Section]) -> None:
        self.data = data
        self.image_base = image_base
        self.sections = sections

    def rva_to_file(self, rva: int) -> int | None:
        for section in self.sections:
            if section.virtual_address <= rva < section.rva_end:
                delta = rva - section.virtual_address
                if delta >= section.raw_size:
                    return None
                return section.raw_offset + delta
        if 0 <= rva < len(self.data):
            return rva
        return None

    def va_to_file(self, va: int) -> int | None:
        return self.rva_to_file(va - self.image_base)

    def file_section(self, file_offset: int) -> str:
        for section in self.sections:
            if section.raw_offset <= file_offset < section.raw_end:
                return section.name
        return "headers" if file_offset >= 0 and file_offset < min((s.raw_offset for s in self.sections), default=0) else ""

    def c_string_range_from_va(self, va: int, max_len: int = 1024) -> tuple[int, int] | None:
        start = self.va_to_file(va)
        if start is None or start < 0 or start >= len(self.data):
            return None
        end = start
        limit = min(len(self.data), start + max_len)
        while end < limit and self.data[end] != 0:
            end += 1
        if end < len(self.data):
            end += 1
        if end <= start:
            end = min(len(self.data), start + 1)
        return start, end


class RangeBuilder:
    def __init__(self, mapper: ExeMapper) -> None:
        self.mapper = mapper
        self.ranges: list[CoverageRange] = []

    def add_file_range(
        self,
        start: int | None,
        length: int | None,
        status: str,
        category: str,
        label: str,
        source: str,
        confidence: str = "medium",
        va_hex: str = "",
        notes: str = "",
    ) -> None:
        if start is None or length is None or length <= 0:
            return
        start = max(0, min(start, len(self.mapper.data)))
        end = max(start, min(start + length, len(self.mapper.data)))
        if end <= start:
            return
        self.ranges.append(CoverageRange(start, end, status, category, label, source, confidence, va_hex, notes))

    def add_va_range(
        self,
        start_va: int | None,
        length: int | None,
        status: str,
        category: str,
        label: str,
        source: str,
        confidence: str = "medium",
        notes: str = "",
    ) -> None:
        if start_va is None:
            return
        start = self.mapper.va_to_file(start_va)
        self.add_file_range(
            start,
            length,
            status,
            category,
            label,
            source,
            confidence,
            f"0x{start_va:08x}",
            notes,
        )

    def add_va_span(
        self,
        start_va: int | None,
        end_va: int | None,
        status: str,
        category: str,
        label: str,
        source: str,
        confidence: str = "medium",
        notes: str = "",
    ) -> None:
        if start_va is None or end_va is None or end_va <= start_va:
            return
        self.add_va_range(start_va, end_va - start_va, status, category, label, source, confidence, notes)

    def add_c_string(
        self,
        va: int | None,
        status: str,
        category: str,
        label: str,
        source: str,
        confidence: str = "medium",
        notes: str = "",
    ) -> None:
        if va is None:
            return
        span = self.mapper.c_string_range_from_va(va)
        if not span:
            return
        start, end = span
        self.add_file_range(start, end - start, status, category, label, source, confidence, f"0x{va:08x}", notes)


def collect_text_tables(builder: RangeBuilder) -> None:
    data = load_json(OUT / "text_tables.json", {})
    for table in data.get("tables", []) or []:
        key = table.get("key") or "unknown"
        entries = table.get("entries") or []
        ref_vas = [parse_int(entry.get("refVaHex") or entry.get("refVa")) for entry in entries]
        ref_vas = [value for value in ref_vas if value is not None]
        if ref_vas:
            start_va = min(ref_vas)
            end_va = max(ref_vas) + 4
            builder.add_va_span(
                start_va,
                end_va,
                "interpreted",
                "text-table",
                f"text pointer table: {key}",
                "out/text_tables.json",
                "high",
                f"{len(entries)} entries",
            )
        else:
            start_va = parse_int(table.get("clusterStartVaHex"))
            end_va = parse_int(table.get("clusterEndVaHex"))
            builder.add_va_span(
                start_va,
                end_va + 4 if end_va else None,
                "interpreted",
                "text-table",
                f"text pointer table: {key}",
                "out/text_tables.json",
                "medium",
            )
        for entry in entries:
            text_va = parse_int(entry.get("textVaHex") or entry.get("textVa"))
            index = entry.get("index")
            text = str(entry.get("text") or "")
            short_text = text[:24] + ("..." if len(text) > 24 else "")
            builder.add_c_string(
                text_va,
                "interpreted",
                "text-table",
                f"{key}[{index}] {short_text}",
                "out/text_tables.json",
                "high",
            )


def collect_enemy_stats(builder: RangeBuilder) -> None:
    data = load_json(OUT / "enemy_stat_table.json", {})
    table = data.get("table") or {}
    start = parse_int(table.get("statTableFileOffset"))
    row_count = parse_int(data.get("rowCount")) or 0
    row_size = parse_int(table.get("rowSize")) or 0
    builder.add_file_range(
        start,
        row_count * row_size,
        "interpreted",
        "monster",
        "enemy stat table rows",
        "out/enemy_stat_table.json",
        "high",
        table.get("statTableVaHex") or "",
        f"{row_count} rows x {row_size} bytes",
    )
    pointer_va = parse_int(table.get("namePointerTableVaHex") or table.get("namePointerTableVa"))
    pointer_count = parse_int(data.get("namePointerCount")) or 0
    builder.add_va_range(
        pointer_va,
        pointer_count * 4,
        "interpreted",
        "monster",
        "enemy name pointer table",
        "out/enemy_stat_table.json",
        "high",
        f"{pointer_count} pointers",
    )
    seen_names = set()
    for row in data.get("rows", []) or []:
        name_va = parse_int(row.get("nameVaHex") or row.get("nameVa"))
        if name_va in seen_names:
            continue
        seen_names.add(name_va)
        builder.add_c_string(
            name_va,
            "interpreted",
            "monster",
            f"enemy name: {row.get('cleanName') or row.get('name') or row.get('index')}",
            "out/enemy_stat_table.json",
            "high",
        )


def collect_dialogue_blocks(builder: RangeBuilder) -> None:
    data = load_json(OUT / "event_dialogue_blocks.json", {})
    for block in data.get("blocks", []) or []:
        start_va = parse_int(block.get("startVaHex") or block.get("startVa"))
        end_va = parse_int(block.get("endVaHex") or block.get("endVa"))
        label = block.get("blockId") or f"dialogue block {block.get('index')}"
        classification = block.get("classification") or ""
        builder.add_va_span(
            start_va,
            end_va,
            "interpreted",
            "dialogue",
            f"{label} {classification}".strip(),
            "out/event_dialogue_blocks.json",
            "high" if block.get("dialogueLike") is not False else "medium",
            f"{block.get('textLineCount', 0)} text lines",
        )


def collect_story_prompts(builder: RangeBuilder) -> None:
    data = load_json(OUT / "story_prompts.json", {})
    prompts = data.get("prompts") or []
    for prompt in prompts:
        prompt_id = str(prompt.get("id") or f"story-prompt-{prompt.get('globalIndex', '?')}")
        classification = str(prompt.get("classification") or "").strip()
        status = str(prompt.get("status") or "").strip()
        line_count = parse_int(prompt.get("lineCount")) or len(prompt.get("lines") or [])
        display_text = str(prompt.get("displayText") or prompt.get("text") or "")
        compact_text = " ".join(display_text.split())
        if len(compact_text) > 56:
            compact_text = compact_text[:53] + "..."
        label_tail = f" {classification}" if classification else ""
        notes = "; ".join(part for part in [f"{line_count} lines", status, compact_text] if part)

        start_va = parse_int(prompt.get("startVaHex") or prompt.get("startVa"))
        end_va = parse_int(prompt.get("endVaHex") or prompt.get("endVa"))
        builder.add_va_span(
            start_va,
            end_va,
            "interpreted",
            "dialogue",
            f"screen prompt text: {prompt_id}{label_tail}",
            "out/story_prompts.json",
            "high",
            notes,
        )

        # The prompt artifact is assembled from render/wait opcode windows, not only
        # from the c-string bytes. Include that bounded scan span so the coverage map
        # reflects the accepted screen-prompt reconstruction work.
        scan_start_va = parse_int(prompt.get("scanStartVaHex"))
        scan_end_va = parse_int(prompt.get("scanEndVaHex"))
        builder.add_va_span(
            scan_start_va,
            scan_end_va,
            "interpreted",
            "dialogue",
            f"screen prompt opcode span: {prompt_id}{label_tail}",
            "out/story_prompts.json",
            "high",
            f"render {prompt.get('renderVaHex') or '-'}; wait {prompt.get('waitVaHex') or '-'}",
        )

        if start_va is None or end_va is None:
            for row in prompt.get("lineRows") or []:
                line_va = parse_int(row.get("vaHex"))
                builder.add_c_string(
                    line_va,
                    "interpreted",
                    "dialogue",
                    f"screen prompt line: {prompt_id}",
                    "out/story_prompts.json",
                    "high",
                    str(row.get("kind") or ""),
                )


def collect_map_scene_records(builder: RangeBuilder) -> None:
    data = load_json(OUT / "scene_manifest.json", [])
    for row in data or []:
        file_offset = parse_int(row.get("recordFileOffset"))
        builder.add_file_range(
            file_offset,
            16,
            "interpreted",
            "map-scene",
            f"scene record: {row.get('map')}",
            "out/scene_manifest.json",
            "medium",
            row.get("recordVaHex") or "",
            row.get("resourceSource") or "",
        )
        for resource in (row.get("resources") or []) + (row.get("precedingResources") or []):
            ref_va = parse_int(resource.get("refVaHex") or resource.get("refVa"))
            builder.add_va_range(
                ref_va,
                4,
                "interpreted",
                "map-scene",
                f"scene resource ref: {row.get('map')} -> {resource.get('filename') or resource.get('name')}",
                "out/scene_manifest.json",
                "medium",
            )


def collect_event_transition_candidates(builder: RangeBuilder) -> None:
    data = load_json(OUT / "scene_events.json", [])
    for row in data or []:
        file_offset = parse_int(row.get("recordFileOffset"))
        builder.add_file_range(
            file_offset,
            16,
            "partial",
            "event-transition",
            f"event record candidate: {row.get('map')} kind {row.get('eventKind')}",
            "out/scene_events.json",
            "medium",
            row.get("recordVaHex") or "",
            "event/trigger semantics are still separated from map rendering",
        )
        dispatch_va = parse_int(row.get("eventDispatchVa"))
        builder.add_va_range(
            dispatch_va,
            8,
            "partial",
            "event-transition",
            f"event dispatch candidate: {row.get('map')}",
            "out/scene_events.json",
            "medium",
        )
        for ref in row.get("eventDispatchRefs") or []:
            ref_offset = parse_int(ref.get("refFileOffset"))
            builder.add_file_range(
                ref_offset,
                4,
                "partial",
                "event-transition",
                f"event dispatch ref: {row.get('map')}",
                "out/scene_events.json",
                "medium",
                ref.get("refVaHex") or "",
            )
            condition_va = parse_int(ref.get("conditionVa"))
            builder.add_va_range(
                condition_va,
                32,
                "candidate",
                "event-transition",
                f"event condition payload candidate: {row.get('map')}",
                "out/scene_events.json",
                "low",
            )
            payload_va = parse_int(ref.get("conditionPayloadVa"))
            builder.add_va_range(
                payload_va,
                32,
                "candidate",
                "event-transition",
                f"event linked payload candidate: {row.get('map')}",
                "out/scene_events.json",
                "low",
            )


def collect_event_vm(builder: RangeBuilder) -> None:
    table = load_json(OUT / "script_handler_table.json", {})
    handler_table_va = parse_int(table.get("handlerTableVaHex") or table.get("handlerTableVa"))
    opcode_count = parse_int(table.get("opcodeCount")) or 0
    builder.add_va_range(
        handler_table_va,
        opcode_count * 4,
        "partial",
        "event-vm",
        "event/save selector handler table",
        "out/script_handler_table.json",
        "medium",
        f"{opcode_count} opcodes",
    )
    for entry in table.get("entries", []) or []:
        if not entry.get("isCodeHandler"):
            continue
        handler_va = parse_int(entry.get("handlerVaHex") or entry.get("handlerVa"))
        builder.add_va_range(
            handler_va,
            16,
            "partial",
            "event-vm",
            f"opcode {entry.get('opcodeHex')} handler entry",
            "out/script_handler_table.json",
            "medium",
        )
        for ref in entry.get("references") or []:
            word_va = parse_int(ref.get("wordVaHex"))
            builder.add_va_range(
                word_va,
                4,
                "candidate",
                "event-vm",
                f"opcode stream word {entry.get('opcodeHex')} {ref.get('source')}->{ref.get('target')}",
                "out/script_handler_table.json",
                "low",
            )
    flow = load_json(OUT / "event_text_source_flow.json", {})
    for effect in flow.get("handlerEffects") or []:
        handler_va = parse_int(effect.get("handlerVaHex"))
        builder.add_va_range(
            handler_va,
            24,
            "partial",
            "event-vm",
            f"event text opcode {effect.get('opcodeHex')} {effect.get('name')}",
            "out/event_text_source_flow.json",
            "medium",
            effect.get("effect") or "",
        )
    for row in flow.get("selectedRows") or []:
        va = parse_int(row.get("vaHex") or row.get("va"))
        length = parse_int((row.get("payload") or {}).get("length")) or 4
        builder.add_va_range(
            va,
            min(max(length, 4), 32),
            "partial",
            "event-vm",
            f"text-source command {row.get('opcodeHex')}",
            "out/event_text_source_flow.json",
            "medium",
        )


def collect_imports(builder: RangeBuilder) -> None:
    data = load_json(OUT / "exe_imports.json", {})
    for dll in data.get("imports", []) or []:
        dll_name = dll.get("dll") or "import"
        for func in dll.get("functions", []) or []:
            name = func.get("name") or f"hint {func.get('hint')}"
            iat_va = parse_int(func.get("iatVa"))
            builder.add_va_range(
                iat_va,
                4,
                "interpreted",
                "imports",
                f"IAT {dll_name}!{name}",
                "out/exe_imports.json",
                "high",
            )
            for ref in func.get("refs") or []:
                ref_va = parse_int(ref.get("va"))
                builder.add_va_range(
                    ref_va,
                    4,
                    "interpreted",
                    "imports",
                    f"import ref {dll_name}!{name}",
                    "out/exe_imports.json",
                    "medium",
                )
            for ref in func.get("callRefs") or []:
                ref_va = parse_int(ref.get("va"))
                builder.add_va_range(
                    ref_va,
                    6,
                    "interpreted",
                    "imports",
                    f"import call {dll_name}!{name}",
                    "out/exe_imports.json",
                    "medium",
                )


def collect_directx(builder: RangeBuilder) -> None:
    data = load_json(OUT / "directdraw_flow.json", {})
    direct = data.get("directDrawCreate") or {}
    for key, value in direct.items():
        if key.endswith("GlobalHex") or key.endswith("Hex") and "Global" in key:
            va = parse_int(value)
            builder.add_va_range(va, 4, "interpreted", "directx", key, "out/directdraw_flow.json", "medium")
    for va_hex in direct.get("callSites") or []:
        va = parse_int(va_hex)
        builder.add_va_range(va, 8, "interpreted", "directx", "DirectDrawCreate wrapper call", "out/directdraw_flow.json", "high")
    for call in (data.get("displayMode") or {}).get("setCooperativeLevelCalls") or []:
        va = parse_int(call.get("vaHex"))
        status = "interpreted" if call.get("verified") else "candidate"
        builder.add_va_range(va, 12, status, "directx", f"DirectDraw {call.get('method')}", "out/directdraw_flow.json", "high")
    set_mode = (data.get("displayMode") or {}).get("setDisplayModeCall") or {}
    builder.add_va_range(
        parse_int(set_mode.get("vaHex")),
        12,
        "interpreted" if set_mode.get("verified") else "candidate",
        "directx",
        "DirectDraw SetDisplayMode",
        "out/directdraw_flow.json",
        "high",
        set_mode.get("notes") or "",
    )
    for call in (data.get("surfaces") or {}).get("createSurfaceCalls") or []:
        va = parse_int(call.get("vaHex"))
        builder.add_va_range(
            va,
            12,
            "interpreted" if call.get("verified") else "candidate",
            "directx",
            f"DirectDraw {call.get('role') or call.get('method')}",
            "out/directdraw_flow.json",
            "high" if call.get("verified") else "low",
        )
    for slot in (data.get("surfaces") or {}).get("primarySurfaceSlots") or []:
        va = parse_int(slot.get("wrapperGlobalHex"))
        builder.add_va_range(
            va,
            4,
            "interpreted",
            "directx",
            f"surface wrapper slot {slot.get('slot')}",
            "out/directdraw_flow.json",
            "medium",
        )


def collect_input(builder: RangeBuilder) -> None:
    data = load_json(OUT / "input_keymap.json", {})
    for table in data.get("tables") or []:
        va = parse_int(table.get("vaHex") or table.get("va"))
        groups = parse_int(table.get("groups")) or 0
        slots = parse_int(table.get("slotsPerGroup")) or 0
        builder.add_va_range(
            va,
            groups * slots * 2,
            "interpreted",
            "input",
            f"input keymap table: {table.get('name')}",
            "out/input_keymap.json",
            "medium",
            f"{groups} groups x {slots} slots",
        )


def collect_dan_rank_system(builder: RangeBuilder) -> None:
    data = load_json(OUT / "dan_rank_system_review.json", {})
    selector = data.get("danSelector") or {}
    entry_va = parse_int(selector.get("entryVaHex"))
    builder.add_va_range(
        entry_va,
        0x80,
        "interpreted",
        "ending",
        "Dan/rank display selector entry",
        "out/dan_rank_system_review.json",
        "high",
        "0x13 41 64 branch rows select lower ranks; max rank is fallthrough",
    )
    for row in selector.get("branchRows") or []:
        opcode_va = parse_int(row.get("opcodeVaHex"))
        builder.add_va_range(
            opcode_va,
            8,
            "interpreted",
            "ending",
            f"Dan/rank selector branch {row.get('compareValue')} -> {row.get('displayTitle')}",
            "out/dan_rank_system_review.json",
            "high",
            row.get("targetVaHex") or "",
        )
    pattern_hits = data.get("danScorePatternHits") or data.get("danScoreWriters") or {}
    for row in pattern_hits.get("rows") or []:
        opcode_va = parse_int(row.get("vaHex") or row.get("opcodeVaHex"))
        promoted = row.get("promotion") == "generic-vm-score-writer-grounded"
        builder.add_va_range(
            opcode_va,
            4,
            "interpreted" if promoted else "candidate",
            "ending",
            (
                f"Dan/rank score writer {row.get('ruleLabel') or ''} +{row.get('scoreDelta')}"
                if promoted
                else f"Dan/rank raw 0x10 41 64 hit {row.get('packedDwordHex') or ''}"
            ),
            "out/dan_rank_system_review.json",
            "medium" if promoted else "low",
            (
                "generic Event VM opcode 0x10 decodes 0x10 41 64 xx as global[0x64] += xx"
                if promoted
                else "not promoted outside the grounded Dan score block"
            ),
        )
    flag_source = load_json(OUT / "dan_rank_flag_source_review.json", {})
    for flag in flag_source.get("flags") or []:
        for row in (flag.get("writerRows") or []):
            opcode_va = parse_int(row.get("vaHex"))
            builder.add_va_range(
                opcode_va,
                4,
                "interpreted",
                "ending",
                f"Dan/rank flag source {flag.get('label')} {row.get('action')}",
                "out/dan_rank_flag_source_review.json",
                flag.get("confidence") or "medium",
                row.get("nearbyEntries", [{}])[0].get("groupId", "") if row.get("nearbyEntries") else "",
            )
        for row in (flag.get("testRows") or []):
            opcode_va = parse_int(row.get("vaHex"))
            builder.add_va_range(
                opcode_va,
                8,
                "interpreted",
                "ending",
                f"Dan/rank flag consumer {flag.get('label')} {row.get('consumerRange') or ''}",
                "out/dan_rank_flag_source_review.json",
                flag.get("confidence") or "medium",
                row.get("bytes") or "",
            )
    gate = load_json(OUT / "skill_equipment_gate_review.json", {})
    handlers = gate.get("handlerSummary") or {}
    for opcode_name in ("e2", "e3"):
        info = handlers.get(opcode_name) or {}
        start_va = parse_int(info.get("handlerVaHex"))
        end_va = parse_int(info.get("handlerEndVaHex"))
        builder.add_va_span(
            start_va,
            end_va,
            "interpreted",
            "event-vm",
            f"Event VM {opcode_name} skill/equipment gate handler",
            "out/skill_equipment_gate_review.json",
            "high",
            info.get("summary") or "",
        )
        dispatch_va = parse_int(handlers.get("genericDispatchTableVaHex"))
        if dispatch_va is not None:
            opcode_value = int(opcode_name[1:], 16)
            builder.add_va_range(
                dispatch_va + opcode_value * 4,
                4,
                "interpreted",
                "event-vm",
                f"generic Event VM dispatch entry {opcode_name}",
                "out/skill_equipment_gate_review.json",
                "high",
                info.get("handlerVaHex") or "",
            )
    matrix = gate.get("skillMatrix") or {}
    builder.add_va_range(
        parse_int(matrix.get("tableVaHex")),
        parse_int(matrix.get("size")) or 0,
        "interpreted",
        "ending",
        "e3 skill completion matrix",
        "out/skill_equipment_gate_review.json",
        "high",
        "actor stride 24, category stride 6",
    )
    for row in gate.get("streamRows") or []:
        opcode_va = parse_int(row.get("vaHex"))
        builder.add_va_range(
            opcode_va,
            8,
            "interpreted",
            "ending" if row.get("context") in ("dan-rank-score", "perfect-clear-audit") else "event-vm",
            f"{row.get('opcode')} {row.get('actor')} {row.get('selectorMeaning')} gate",
            "out/skill_equipment_gate_review.json",
            "high",
            row.get("context") or "",
        )


def collect_skill_equipment_state_layout(builder: RangeBuilder) -> None:
    data = load_json(OUT / "skill_equipment_state_layout_review.json", {})
    for row in data.get("functions") or []:
        start_va = parse_int(row.get("startVaHex") or row.get("startVa"))
        end_va = parse_int(row.get("endVaHex") or row.get("endVa"))
        builder.add_va_span(
            start_va,
            end_va,
            "interpreted",
            "actor-state",
            f"skill/equipment state helper: {row.get('label') or row.get('key')}",
            "out/skill_equipment_state_layout_review.json",
            "high",
            row.get("role") or "",
        )
        for ref in row.get("callRefs") or []:
            builder.add_va_range(
                parse_int(ref.get("callVaHex") or ref.get("callVa")),
                5,
                "interpreted",
                "actor-state",
                f"call ref -> {row.get('key')}",
                "out/skill_equipment_state_layout_review.json",
                "medium",
                ref.get("targetVaHex") or "",
            )
    for table in data.get("tables") or []:
        builder.add_va_range(
            parse_int(table.get("vaHex") or table.get("va")),
            parse_int(table.get("length")),
            "interpreted",
            "actor-state",
            f"skill/equipment state switch table: {table.get('label') or table.get('key')}",
            "out/skill_equipment_state_layout_review.json",
            "high",
            "detail+0x29 type byte dispatch",
        )
    for row in data.get("actorStateRows") or []:
        base_va = parse_int(row.get("baseVaHex") or row.get("baseVa"))
        actor = row.get("actor") or "actor"
        builder.add_va_range(
            None if base_va is None else base_va + 0x48,
            2,
            "interpreted",
            "actor-state",
            f"{actor} current equipment slots",
            "out/skill_equipment_state_layout_review.json",
            "high",
            "actor+0x48 weapon/liquor, actor+0x49 armor",
        )
        builder.add_va_range(
            None if base_va is None else base_va + 0x4A,
            12,
            "interpreted",
            "actor-state",
            f"{actor} owned equipment slots",
            "out/skill_equipment_state_layout_review.json",
            "high",
            "2 groups x 6 slots",
        )
        builder.add_va_range(
            None if base_va is None else base_va + 0x56,
            24,
            "interpreted",
            "actor-state",
            f"{actor} skill slots",
            "out/skill_equipment_state_layout_review.json",
            "high",
            "4 categories x 6 slots",
        )


def collect_item_inventory_state_layout(builder: RangeBuilder) -> None:
    data = load_json(OUT / "item_inventory_state_layout_review.json", {})
    for row in data.get("functions") or []:
        builder.add_va_span(
            parse_int(row.get("startVaHex") or row.get("startVa")),
            parse_int(row.get("endVaHex") or row.get("endVa")),
            "interpreted",
            "inventory-state",
            f"item inventory helper: {row.get('label') or row.get('key')}",
            "out/item_inventory_state_layout_review.json",
            "high",
            row.get("role") or "",
        )
    layout = data.get("storageLayout") or {}
    consumables = layout.get("consumables") or {}
    important = layout.get("importantItems") or {}
    builder.add_va_range(
        parse_int(consumables.get("idBaseVaHex")),
        12,
        "interpreted",
        "inventory-state",
        "global consumable id/count slots",
        "out/item_inventory_state_layout_review.json",
        "high",
        consumables.get("slotAddressRule") or "6 id/count pairs, max 10",
    )
    builder.add_va_range(
        parse_int(important.get("idBaseVaHex")),
        60,
        "interpreted",
        "inventory-state",
        "global important item id/value slots",
        "out/item_inventory_state_layout_review.json",
        "high",
        important.get("slotAddressRule") or "30 id/value pairs, max 1",
    )
    for row in data.get("itemRows") or []:
        builder.add_va_range(
            parse_int(row.get("recordVaHex")),
            8,
            "interpreted",
            "inventory-state",
            f"item static record #{row.get('itemId')} {row.get('name')}",
            "out/item_inventory_state_layout_review.json",
            "high",
            row.get("storageKind") or "",
        )
        builder.add_va_range(
            parse_int(row.get("detailRecordVaHex")),
            18,
            "interpreted",
            "inventory-state",
            f"item detail record #{row.get('itemId')} {row.get('name')}",
            "out/item_inventory_state_layout_review.json",
            "high",
            f"flag {row.get('flagHex')} {row.get('storageKind')}",
        )


def collect_battle_candidates(builder: RangeBuilder) -> None:
    data = load_json(OUT / "battle_formula_calculator_notes.json", {})
    level = data.get("levelUp") or {}
    combat = data.get("combatFormula") or {}
    for key, label in [
        ("routineVaHex", "level-up stat increment routine"),
        ("rngVaHex", "battle/stat RNG helper"),
    ]:
        va = parse_int(level.get(key))
        builder.add_va_range(
            va,
            16,
            "interpreted",
            "battle",
            label,
            "out/battle_formula_calculator_notes.json",
            "high",
        )
    for key, label in [
        ("hitAvoidVaHex", "hit/avoid branch"),
        ("criticalGateVaHex", "critical/alternate result gate"),
        ("statusGateVaHex", "status-effect gate"),
        ("normalDamageVaHex", "normal damage formula"),
        ("criticalDamageVaHex", "critical/alternate damage formula"),
    ]:
        va = parse_int(combat.get(key))
        builder.add_va_range(
            va,
            16,
            "interpreted",
            "battle",
            label,
            "out/battle_formula_calculator_notes.json",
            "high",
        )

    collect_battle_analysis_artifacts(builder)


VA_HEX_RE = re.compile(r"0x[0-9a-fA-F]{6,8}")


def iter_va_hex_values(value: Any, path: str = "") -> list[tuple[str, int]]:
    rows: list[tuple[str, int]] = []
    if isinstance(value, dict):
        for key, child in value.items():
            child_path = f"{path}.{key}" if path else str(key)
            rows.extend(iter_va_hex_values(child, child_path))
    elif isinstance(value, list):
        for child in value:
            rows.extend(iter_va_hex_values(child, f"{path}[]"))
    elif isinstance(value, int):
        lowered = path.lower()
        if ("va" in lowered or "handler" in lowered or "script" in lowered or "tail" in lowered) and 0x00400000 <= value < 0x00600000:
            rows.append((path, value))
    elif isinstance(value, str):
        lowered = path.lower()
        if "hex" in lowered or "va" in lowered or "script" in lowered or "tail" in lowered or "handler" in lowered:
            for match in VA_HEX_RE.findall(value):
                parsed = parse_int(match)
                if parsed is not None and 0x00400000 <= parsed < 0x00600000:
                    rows.append((path, parsed))
    return rows


def battle_artifact_status(path: Path) -> tuple[str, str]:
    name = path.name
    if "candidate" in name or "fallback" in name:
        return "candidate", "low"
    if (
        "review" in name
        and not any(
            key in name
            for key in [
                "formula",
                "result",
                "frame_gate",
                "monster_action",
                "helper",
                "sound",
                "status",
                "display",
                "damage",
                "mode",
                "reward",
                "recovery",
                "target",
                "sukyeong",
                "knockout",
                "timed",
                "one_turn",
            ]
        )
    ):
        return "partial", "medium"
    return "interpreted", "high"


def va_range_length_for_path(source_path: Path, json_path: str) -> int:
    lowered = json_path.lower()
    name = source_path.name
    if "descriptorva" in lowered:
        return 0x80
    if "recttableva" in lowered or "displayactiontableva" in lowered or "familytableva" in lowered:
        return 0x40
    if "handlerva" in lowered:
        return 0x20
    if "payloadva" in lowered:
        return 0x20
    if "entrystartva" in lowered or "scriptva" in lowered or "pointerva" in lowered or "tailva" in lowered or "targetva" in lowered:
        return 0x10
    if "vahex" in lowered or lowered.endswith(".va"):
        return 0x08
    if "opening" in name:
        return 0x10
    return 0x08


def collect_va_artifact_ranges(
    builder: RangeBuilder,
    artifact_paths: list[Path],
    category: str,
    default_status: str | None = None,
) -> None:
    for artifact_path in artifact_paths:
        data = load_json(artifact_path, None)
        if data is None:
            continue
        status, confidence = battle_artifact_status(artifact_path)
        if default_status:
            status = default_status
        seen: set[tuple[int, int, str]] = set()
        for json_path, va in iter_va_hex_values(data):
            length = va_range_length_for_path(artifact_path, json_path)
            key = (va, length, status)
            if key in seen:
                continue
            seen.add(key)
            builder.add_va_range(
                va,
                length,
                status,
                category,
                f"{artifact_path.stem}: {json_path}",
                str(artifact_path.relative_to(ROOT)),
                confidence,
            )


def collect_battle_analysis_artifacts(builder: RangeBuilder) -> None:
    excluded = {
        "battle_analysis_summary.json",
        "battle_background_review_browser_smoke.json",
        "battle_backgrounds.json",
        "battle_event_candidates.json",
        "battle_enemy_candidates.json",
        "battle_action_handler_context.json",
    }
    battle_artifacts = [
        path
        for path in sorted(OUT.glob("battle_*.json"))
        if path.name not in excluded
    ]
    collect_va_artifact_ranges(builder, battle_artifacts, "battle")

    opening_artifacts = sorted(OUT.glob("opening_*.json"))
    runtime_artifacts = [
        path
        for path in [
            OUT / "runtime_checkpoint_entry_review.json",
        ]
        if path.exists()
    ]
    collect_va_artifact_ranges(builder, opening_artifacts + runtime_artifacts, "opening", "partial")


def collect_all_ranges(builder: RangeBuilder, size_of_headers: int) -> list[CoverageRange]:
    builder.add_file_range(
        0,
        size_of_headers,
        "interpreted",
        "pe",
        "PE headers and section table",
        "Hwanse2.exe",
        "high",
        "",
        "PE container layout parsed locally",
    )
    collect_imports(builder)
    collect_text_tables(builder)
    collect_enemy_stats(builder)
    collect_dialogue_blocks(builder)
    collect_story_prompts(builder)
    collect_map_scene_records(builder)
    collect_event_transition_candidates(builder)
    collect_event_vm(builder)
    collect_directx(builder)
    collect_input(builder)
    collect_dan_rank_system(builder)
    collect_skill_equipment_state_layout(builder)
    collect_item_inventory_state_layout(builder)
    collect_battle_candidates(builder)
    return sorted(builder.ranges, key=lambda item: (item.start, item.end, item.category, item.label))


def merge_intervals(intervals: list[tuple[int, int]]) -> list[tuple[int, int]]:
    cleaned = sorted((start, end) for start, end in intervals if end > start)
    merged: list[tuple[int, int]] = []
    for start, end in cleaned:
        if not merged or start > merged[-1][1]:
            merged.append((start, end))
        else:
            merged[-1] = (merged[-1][0], max(merged[-1][1], end))
    return merged


def interval_length(intervals: list[tuple[int, int]]) -> int:
    return sum(end - start for start, end in merge_intervals(intervals))


def coverage_summary(ranges: list[CoverageRange], exe_size: int) -> dict[str, Any]:
    by_status: dict[str, list[tuple[int, int]]] = defaultdict(list)
    by_category: dict[str, list[tuple[int, int]]] = defaultdict(list)
    all_intervals = []
    for item in ranges:
        interval = (item.start, item.end)
        by_status[item.status].append(interval)
        by_category[item.category].append(interval)
        all_intervals.append(interval)
    status_bytes = {status: interval_length(intervals) for status, intervals in by_status.items()}
    category_bytes = {category: interval_length(intervals) for category, intervals in by_category.items()}
    known_bytes = interval_length(all_intervals)
    interpreted_bytes = status_bytes.get("interpreted", 0)
    return {
        "exeSize": exe_size,
        "rangeCount": len(ranges),
        "knownOrTouchedBytes": known_bytes,
        "knownOrTouchedRatio": known_bytes / exe_size if exe_size else 0,
        "interpretedBytes": interpreted_bytes,
        "interpretedRatio": interpreted_bytes / exe_size if exe_size else 0,
        "statusBytes": status_bytes,
        "statusRatios": {key: value / exe_size if exe_size else 0 for key, value in status_bytes.items()},
        "categoryBytes": category_bytes,
        "categoryRatios": {key: value / exe_size if exe_size else 0 for key, value in category_bytes.items()},
    }


def build_blocks(ranges: list[CoverageRange], mapper: ExeMapper, block_size: int, columns: int) -> list[dict[str, Any]]:
    block_count = (len(mapper.data) + block_size - 1) // block_size
    blocks: list[dict[str, Any]] = []
    ranges_by_block: list[list[CoverageRange]] = [[] for _ in range(block_count)]
    for item in ranges:
        first = item.start // block_size
        last = (item.end - 1) // block_size
        for index in range(max(0, first), min(block_count - 1, last) + 1):
            ranges_by_block[index].append(item)
    for index in range(block_count):
        start = index * block_size
        end = min(len(mapper.data), start + block_size)
        status_intervals: dict[str, list[tuple[int, int]]] = defaultdict(list)
        category_bytes = Counter()
        labels = []
        for item in ranges_by_block[index]:
            overlap_start = max(start, item.start)
            overlap_end = min(end, item.end)
            if overlap_end <= overlap_start:
                continue
            status_intervals[item.status].append((overlap_start, overlap_end))
            category_bytes[item.category] += overlap_end - overlap_start
            if len(labels) < 5:
                labels.append(item.label)
        status_bytes = {status: interval_length(items) for status, items in status_intervals.items()}
        covered = interval_length([interval for items in status_intervals.values() for interval in items])
        if status_bytes:
            dominant_status = max(status_bytes, key=lambda status: (STATUS_PRIORITY.get(status, 0), status_bytes[status]))
            dominant_category = category_bytes.most_common(1)[0][0] if category_bytes else ""
        else:
            dominant_status = "unknown"
            dominant_category = ""
        blocks.append(
            {
                "index": index,
                "row": index // columns,
                "column": index % columns,
                "start": start,
                "end": end,
                "startHex": f"0x{start:06x}",
                "endHex": f"0x{end:06x}",
                "section": mapper.file_section(start),
                "coveredBytes": covered,
                "coveredRatio": covered / max(1, end - start),
                "dominantStatus": dominant_status,
                "dominantCategory": dominant_category,
                "statusBytes": status_bytes,
                "categoryBytes": dict(category_bytes),
                "labels": labels,
            }
        )
    return blocks


def section_rows(sections: list[Section], exe_size: int) -> list[dict[str, Any]]:
    rows = []
    for section in sections:
        rows.append(
            {
                "name": section.name,
                "rawOffset": section.raw_offset,
                "rawOffsetHex": f"0x{section.raw_offset:06x}",
                "rawSize": section.raw_size,
                "rawEndHex": f"0x{section.raw_end:06x}",
                "virtualAddressHex": f"0x{section.virtual_address:08x}",
                "virtualSize": section.virtual_size,
                "fileRatio": section.raw_size / exe_size if exe_size else 0,
                "characteristicsHex": f"0x{section.characteristics:08x}",
            }
        )
    return rows


def source_artifact_stats() -> dict[str, Any]:
    story = load_json(OUT / "story_prompts.json", {})
    prompts = story.get("prompts") or []
    return {
        "storyPromptCount": parse_int(story.get("promptCount")) or len(prompts),
        "storySourceTextLineCount": parse_int(story.get("sourceTextLineCount")) or 0,
        "storySourceUniqueTextLineCount": parse_int(story.get("sourceUniqueTextLineCount")) or 0,
        "storyBlockCount": parse_int(story.get("blockCount")) or 0,
    }


def fmt_bytes(value: int) -> str:
    if value >= 1024 * 1024:
        return f"{value / (1024 * 1024):.2f} MiB"
    if value >= 1024:
        return f"{value / 1024:.1f} KiB"
    return f"{value} B"


def pct(value: float) -> str:
    return f"{value * 100:.2f}%"


def json_payload(
    mapper: ExeMapper,
    sections: list[Section],
    ranges: list[CoverageRange],
    blocks: list[dict[str, Any]],
    block_size: int,
    columns: int,
) -> dict[str, Any]:
    summary = coverage_summary(ranges, len(mapper.data))
    summary["blockSize"] = block_size
    summary["blockCount"] = len(blocks)
    summary["columns"] = columns
    summary["coloredBlockCount"] = sum(1 for block in blocks if block["coveredBytes"])
    summary["coloredBlockRatio"] = summary["coloredBlockCount"] / len(blocks) if blocks else 0
    return {
        "scope": "Static byte/block interpretation coverage for Hwanse2.exe",
        "source": "Hwanse2.exe",
        "notes": [
            "Interpreted means a byte range is mapped to an accepted local interpretation, not that the original runtime behavior is fully reproduced.",
            "Partial and candidate ranges are shown separately so unresolved reverse-engineering work remains visible.",
            "Some ranges overlap; percentage counters merge intervals per status/category.",
            "Story prompt counts are logical screen units; the coverage ratio is still measured by unique EXE byte ranges.",
        ],
        "summary": summary,
        "sourceArtifactStats": source_artifact_stats(),
        "sections": section_rows(sections, len(mapper.data)),
        "categories": CATEGORY_LABELS,
        "statuses": STATUS_LABELS,
        "ranges": [item.to_json() for item in ranges],
        "blocks": blocks,
    }


def summary_payload(payload: dict[str, Any]) -> dict[str, Any]:
    return {
        "scope": "Compact EXE interpretation coverage summary for web/index.html",
        "source": payload.get("source"),
        "summary": payload.get("summary") or {},
        "blocks": [
            {
                "index": block.get("index"),
                "dominantStatus": block.get("dominantStatus"),
                "coveredBytes": block.get("coveredBytes"),
            }
            for block in payload.get("blocks") or []
        ],
    }


def markdown(payload: dict[str, Any]) -> str:
    summary = payload["summary"]
    source_stats = payload.get("sourceArtifactStats") or {}
    lines = [
        "# EXE Interpretation Coverage",
        "",
        "Generated from `Hwanse2.exe` and current `out/*.json` reverse-engineering artifacts.",
        "",
        f"- EXE size: {fmt_bytes(summary['exeSize'])}",
        f"- Block size: {fmt_bytes(summary['blockSize'])}",
        f"- Blocks: {summary['blockCount']} ({summary['columns']} columns)",
        f"- Interpreted bytes: {fmt_bytes(summary['interpretedBytes'])} ({pct(summary['interpretedRatio'])})",
        f"- Known/touched bytes: {fmt_bytes(summary['knownOrTouchedBytes'])} ({pct(summary['knownOrTouchedRatio'])})",
        f"- Colored blocks: {summary['coloredBlockCount']}/{summary['blockCount']} ({pct(summary['coloredBlockRatio'])})",
        f"- Story screen prompts: {source_stats.get('storyPromptCount', 0)} prompts from {source_stats.get('storySourceTextLineCount', 0)} source text lines",
        "",
        "## Status",
        "",
        "| status | bytes | ratio |",
        "| --- | ---: | ---: |",
    ]
    for status in ["interpreted", "partial", "candidate"]:
        value = summary["statusBytes"].get(status, 0)
        lines.append(f"| {STATUS_LABELS[status]} | {fmt_bytes(value)} | {pct(summary['statusRatios'].get(status, 0))} |")
    lines.extend(["", "## Categories", "", "| category | bytes | ratio |", "| --- | ---: | ---: |"])
    category_bytes = summary["categoryBytes"]
    for category, value in sorted(category_bytes.items(), key=lambda item: (-item[1], item[0])):
        lines.append(f"| {CATEGORY_LABELS.get(category, category)} | {fmt_bytes(value)} | {pct(summary['categoryRatios'].get(category, 0))} |")
    lines.extend(["", "## Largest Ranges", "", "| status | category | offset | size | label |", "| --- | --- | --- | ---: | --- |"])
    ranges = sorted(payload["ranges"], key=lambda item: item["byteCount"], reverse=True)[:80]
    for item in ranges:
        lines.append(
            f"| {item['status']} | {item['categoryLabel']} | `{item['startHex']}-{item['endHex']}` | "
            f"{fmt_bytes(item['byteCount'])} | {item['label']} |"
        )
    lines.append("")
    return "\n".join(lines)


def html_page(payload: dict[str, Any], detail_json_name: str | None = None) -> str:
    summary = payload["summary"]
    source_stats = payload.get("sourceArtifactStats") or {}
    ranges = payload["ranges"]
    blocks = payload["blocks"]
    columns = summary["columns"]
    status_rows = []
    for status in ["interpreted", "partial", "candidate"]:
        value = summary["statusBytes"].get(status, 0)
        status_rows.append(
            f"<tr><td><span class=\"swatch status-{status}\"></span>{html.escape(STATUS_LABELS[status])}</td>"
            f"<td>{html.escape(fmt_bytes(value))}</td><td>{pct(summary['statusRatios'].get(status, 0))}</td></tr>"
        )
    category_rows = []
    for category, value in sorted(summary["categoryBytes"].items(), key=lambda item: (-item[1], item[0])):
        category_rows.append(
            f"<tr><td><span class=\"swatch {CATEGORY_CLASSES.get(category, '')}\"></span>{html.escape(CATEGORY_LABELS.get(category, category))}</td>"
            f"<td>{html.escape(fmt_bytes(value))}</td><td>{pct(summary['categoryRatios'].get(category, 0))}</td></tr>"
        )
    section_rows_html = []
    for section in payload["sections"]:
        section_rows_html.append(
            f"<tr><td><code>{html.escape(section['name'])}</code></td><td><code>{section['rawOffsetHex']}</code></td>"
            f"<td><code>{section['rawEndHex']}</code></td><td>{html.escape(fmt_bytes(section['rawSize']))}</td>"
            f"<td><code>{section['virtualAddressHex']}</code></td><td>{pct(section['fileRatio'])}</td></tr>"
        )
    block_cells = []
    for block in blocks:
        status = block["dominantStatus"]
        category = block["dominantCategory"]
        class_names = ["cell"]
        if status in STATUS_PRIORITY:
            class_names.append(f"status-{status}")
        if category:
            class_names.append(CATEGORY_CLASSES.get(category, ""))
        ratio = block["coveredRatio"]
        fill = max(10.0, ratio * 100) if ratio > 0 else 0.0
        labels = "; ".join(block["labels"])
        status_bits = ", ".join(f"{key}:{value}" for key, value in sorted(block["statusBytes"].items()))
        title = (
            f"block {block['index']} {block['startHex']}-{block['endHex']}\\n"
            f"section: {block['section']}\\n"
            f"covered: {block['coveredBytes']} bytes ({ratio * 100:.2f}%)\\n"
            f"status: {status_bits or 'none'}\\n"
            f"{labels}"
        )
        block_cells.append(
            f"<button class=\"{' '.join(name for name in class_names if name)}\" style=\"--fill:{fill:.2f}%\" "
            f"data-status=\"{html.escape(status)}\" data-category=\"{html.escape(category)}\" "
            f"data-index=\"{block['index']}\" title=\"{html.escape(title)}\" aria-label=\"{html.escape(title)}\"></button>"
        )
    range_rows = []
    for item in ranges:
        range_rows.append(
            f"<tr data-status=\"{html.escape(item['status'])}\" data-category=\"{html.escape(item['category'])}\">"
            f"<td><span class=\"swatch status-{html.escape(item['status'])}\"></span>{html.escape(item['statusLabel'])}</td>"
            f"<td>{html.escape(item['categoryLabel'])}</td>"
            f"<td><code>{html.escape(item['startHex'])}</code></td>"
            f"<td><code>{html.escape(item['endHex'])}</code></td>"
            f"<td>{html.escape(fmt_bytes(item['byteCount']))}</td>"
            f"<td>{html.escape(item['label'])}</td>"
            f"<td><code>{html.escape(item.get('va_hex') or item.get('vaHex') or '')}</code></td>"
            f"<td>{html.escape(item['source'])}</td>"
            "</tr>"
        )
    payload_json = json.dumps(
        {
            "summary": summary,
            "statusBytes": summary["statusBytes"],
            "categoryBytes": summary["categoryBytes"],
        },
        ensure_ascii=False,
    )
    detail_link = (
        f'  <p><a href="{html.escape(detail_json_name)}">Full range JSON</a></p>'
        if detail_json_name
        else "  <p>Full range JSON is omitted by default; regenerate with <code>--detail-out</code> when needed.</p>"
    )
    return "\n".join(
        [
            "<!doctype html>",
            '<html lang="ko">',
            "<head>",
            '  <meta charset="utf-8">',
            '  <meta name="viewport" content="width=device-width, initial-scale=1">',
            "  <title>EXE 해석 커버리지</title>",
            "  <style>",
            "    :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #0b0d0f; color: #eef3f5; --line:#2f3941; --panel:#151a1f; --muted:#aeb8bf; }",
            "    * { box-sizing: border-box; }",
            "    body { margin: 0; padding: 20px; }",
            "    h1 { margin: 0 0 8px; font-size: 24px; }",
            "    h2 { margin: 22px 0 10px; font-size: 17px; }",
            "    p { margin: 0 0 12px; color: var(--muted); line-height: 1.45; max-width: 1080px; }",
            "    .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin: 16px 0; }",
            "    .metric { border: 1px solid var(--line); background: var(--panel); border-radius: 6px; padding: 10px; }",
            "    .metric strong { display: block; font-size: 20px; }",
            "    .metric span { color: var(--muted); font-size: 12px; }",
            "    .grid-wrap { overflow: auto; border: 1px solid var(--line); background: #101418; border-radius: 6px; padding: 10px; }",
            f"    .grid {{ display: grid; grid-template-columns: repeat({columns}, 11px); gap: 2px; width: max-content; }}",
            "    .cell { width: 11px; height: 11px; border: 0; border-radius: 2px; padding: 0; background: #222a30; cursor: default; }",
            "    .cell.status-interpreted { background: linear-gradient(to top, #73d99a var(--fill), #20282e var(--fill)); }",
            "    .cell.status-partial { background: linear-gradient(to top, #f2c85d var(--fill), #20282e var(--fill)); }",
            "    .cell.status-candidate { background: linear-gradient(to top, #9aa7ff var(--fill), #20282e var(--fill)); }",
            "    .cell:hover { outline: 2px solid #fff; outline-offset: 1px; }",
            "    .tables { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; }",
            "    table { width: 100%; border-collapse: collapse; font-size: 13px; }",
            "    th, td { border-bottom: 1px solid var(--line); padding: 7px 8px; text-align: left; vertical-align: top; }",
            "    th { background: #11171c; color: #d8e2e8; position: sticky; top: 0; }",
            "    code { color: #c8e6ff; }",
            "    .table-wrap { max-height: 560px; overflow: auto; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); }",
            "    .swatch { display: inline-block; width: 10px; height: 10px; margin-right: 6px; border-radius: 2px; vertical-align: -1px; background: #777; }",
            "    .status-interpreted { background: #73d99a; }",
            "    .status-partial { background: #f2c85d; }",
            "    .status-candidate { background: #9aa7ff; }",
            "    .cat-pe { background: #a6e3a1; } .cat-imports { background: #8bd5ff; } .cat-text { background: #f5c2e7; }",
            "    .cat-dialogue { background: #f38ba8; } .cat-monster { background: #fab387; } .cat-map { background: #94e2d5; }",
            "    .cat-event { background: #cba6f7; } .cat-vm { background: #f9e2af; } .cat-directx { background: #89b4fa; }",
            "    .cat-battle { background: #eba0ac; } .cat-opening { background: #f5a97f; } .cat-input { background: #b4befe; }",
            "    .cat-ending { background: #a6d189; } .cat-actor { background: #f2cdcd; } .cat-inventory { background: #c6d0f5; }",
            "    .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 10px 0; }",
            "    input, select { height: 34px; border: 1px solid var(--line); border-radius: 4px; background: #101418; color: #eef3f5; padding: 0 9px; }",
            "    a { color: #8bd5ff; text-decoration: none; }",
            "  </style>",
            "</head>",
            "<body>",
            "  <h1>EXE 해석 커버리지</h1>",
            "  <p>EXE 전체 용량을 고정 크기 블록으로 나누고, 현재 산출물에서 의미가 확인된 범위를 색칠한 정적 지도다. 초록은 확정 해석, 노랑은 부분 해석, 보라는 후보/미승격 범위다.</p>",
            "  <p>주의: 이 비율은 바이트 범위 기준이다. 런타임 동작을 완전히 재현했다는 뜻이 아니라, 해당 바이트가 어떤 데이터/코드 역할인지 현재 작업에서 설명 가능하다는 뜻이다.</p>",
            f"  <p>대본은 <code>story_prompts.json</code> 기준 {source_stats.get('storyPromptCount', 0)}개 화면 프롬프트를 반영한다. 프롬프트 개수와 EXE 바이트 비율은 다른 척도이며, 중복/공유되는 원천 문자열 범위는 합쳐서 계산한다.</p>",
            detail_link,
            '  <section class="summary">',
            f"    <div class=\"metric\"><strong>{html.escape(fmt_bytes(summary['exeSize']))}</strong><span>EXE size</span></div>",
            f"    <div class=\"metric\"><strong>{pct(summary['interpretedRatio'])}</strong><span>interpreted bytes · {html.escape(fmt_bytes(summary['interpretedBytes']))}</span></div>",
            f"    <div class=\"metric\"><strong>{pct(summary['knownOrTouchedRatio'])}</strong><span>known/touched bytes · {html.escape(fmt_bytes(summary['knownOrTouchedBytes']))}</span></div>",
            f"    <div class=\"metric\"><strong>{summary['coloredBlockCount']}/{summary['blockCount']}</strong><span>colored blocks · {html.escape(fmt_bytes(summary['blockSize']))} each</span></div>",
            f"    <div class=\"metric\"><strong>{source_stats.get('storyPromptCount', 0)}</strong><span>story screen prompts</span></div>",
            f"    <div class=\"metric\"><strong>{len(ranges)}</strong><span>coverage ranges</span></div>",
            "  </section>",
            "  <h2>Block Map</h2>",
            '  <div class="grid-wrap"><div class="grid">',
            "\n".join(block_cells),
            "  </div></div>",
            '  <section class="tables">',
            "    <div>",
            "      <h2>Status</h2>",
            '      <div class="table-wrap"><table><thead><tr><th>status</th><th>bytes</th><th>ratio</th></tr></thead><tbody>',
            "\n".join(status_rows),
            "      </tbody></table></div>",
            "    </div>",
            "    <div>",
            "      <h2>Category</h2>",
            '      <div class="table-wrap"><table><thead><tr><th>category</th><th>bytes</th><th>ratio</th></tr></thead><tbody>',
            "\n".join(category_rows),
            "      </tbody></table></div>",
            "    </div>",
            "    <div>",
            "      <h2>PE Sections</h2>",
            '      <div class="table-wrap"><table><thead><tr><th>section</th><th>raw start</th><th>raw end</th><th>size</th><th>RVA</th><th>file ratio</th></tr></thead><tbody>',
            "\n".join(section_rows_html),
            "      </tbody></table></div>",
            "    </div>",
            "  </section>",
            "  <h2>Ranges</h2>",
            '  <div class="controls">',
            '    <input id="rangeSearch" type="search" placeholder="filter ranges">',
            '    <select id="statusFilter"><option value="">all status</option><option value="interpreted">interpreted</option><option value="partial">partial</option><option value="candidate">candidate</option></select>',
            '    <select id="categoryFilter"><option value="">all categories</option>',
            "\n".join(f'      <option value="{html.escape(key)}">{html.escape(value)}</option>' for key, value in CATEGORY_LABELS.items()),
            "    </select>",
            "  </div>",
            '  <div class="table-wrap"><table id="rangeTable"><thead><tr><th>status</th><th>category</th><th>start</th><th>end</th><th>size</th><th>label</th><th>VA</th><th>source</th></tr></thead><tbody>',
            "\n".join(range_rows),
            "  </tbody></table></div>",
            f"  <script>window.HWANSE_EXE_COVERAGE_SUMMARY = {payload_json};</script>",
            "  <script>",
            "    const search = document.getElementById('rangeSearch');",
            "    const statusFilter = document.getElementById('statusFilter');",
            "    const categoryFilter = document.getElementById('categoryFilter');",
            "    const rows = Array.from(document.querySelectorAll('#rangeTable tbody tr'));",
            "    function applyFilters() {",
            "      const q = search.value.trim().toLowerCase();",
            "      const status = statusFilter.value;",
            "      const category = categoryFilter.value;",
            "      rows.forEach((row) => {",
            "        const ok = (!status || row.dataset.status === status) && (!category || row.dataset.category === category) && (!q || row.textContent.toLowerCase().includes(q));",
            "        row.hidden = !ok;",
            "      });",
            "    }",
            "    [search, statusFilter, categoryFilter].forEach((el) => el.addEventListener('input', applyFilters));",
            "  </script>",
            "</body>",
            "</html>",
            "",
        ]
    )


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument(
        "--detail-out",
        type=Path,
        default=None,
        help="Optional full range payload. Omit during normal cleanup/builds; it is large and regenerable.",
    )
    parser.add_argument("--summary-out", type=Path, default=OUT / "exe_interpretation_coverage_summary.json")
    parser.add_argument(
        "--html-out",
        type=Path,
        default=None,
        help="Optional heavy HTML report path. Omit for JSON + compact summary only.",
    )
    parser.add_argument("--block-size", type=int, default=1024)
    parser.add_argument("--columns", type=int, default=64)
    args = parser.parse_args()

    exe_data = args.exe.read_bytes()
    image_base, size_of_headers, sections = parse_pe(exe_data)
    mapper = ExeMapper(exe_data, image_base, sections)
    builder = RangeBuilder(mapper)
    ranges = collect_all_ranges(builder, size_of_headers)
    blocks = build_blocks(ranges, mapper, max(1, args.block_size), max(1, args.columns))
    payload = json_payload(mapper, sections, ranges, blocks, max(1, args.block_size), max(1, args.columns))

    args.summary_out.parent.mkdir(parents=True, exist_ok=True)
    if args.detail_out:
        args.detail_out.parent.mkdir(parents=True, exist_ok=True)
        args.detail_out.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    args.summary_out.write_text(json.dumps(summary_payload(payload), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    if args.html_out:
        detail_json_name = args.detail_out.name if args.detail_out else None
        args.html_out.write_text(html_page(payload, detail_json_name), encoding="utf-8")
    summary = payload["summary"]
    output_note = str(args.summary_out)
    if args.detail_out:
        output_note += f" + {args.detail_out}"
    if args.html_out:
        output_note += f" + {args.html_out}"
    print(
        f"wrote EXE interpretation coverage -> {output_note} "
        f"interpreted={pct(summary['interpretedRatio'])} known={pct(summary['knownOrTouchedRatio'])} "
        f"blocks={summary['coloredBlockCount']}/{summary['blockCount']}"
    )


if __name__ == "__main__":
    main()
