#!/usr/bin/env python3
"""Scan broader coordinate encodings for map1_01a -> map2_02d exit candidates."""
from __future__ import annotations

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

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

from probe_exe_scene_tables import offset_to_va, read_sections, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
SOURCE = "map1_01a"
TARGET = "map2_02d"
RIGHT_EXIT = {"side": "right", "x": 34, "y": 19}
SIDE_ORDER = {"top": 0, "bottom": 1, "left": 2, "right": 3}
SCAN_SECTIONS = {".text", ".rdata", ".data"}
SELECTION_OPCODES = {0x10, 0x11, 0x12, 0x13}
FAILED_EXIT_COORDINATE_VARIANT_GATE_IDS = [
    "strict-source-coordinate",
    "target-spawn-strict-coordinate",
    "promotable-coordinate-owner",
]
EXIT_COORDINATE_VARIANT_MISSING_EVIDENCE = [
    "strict map1_01a source coordinate evidence for map2_02d",
    "strict target-spawn coordinate evidence linked to map2_02d",
    "promotable coordinate owner rather than selector-script/character-descriptor data",
]
EXIT_COORDINATE_VARIANT_EVIDENCE_REFS = [
    {"path": "Hwanse2.exe", "fields": [".text", ".rdata", ".data"]},
    {"path": "out/save_scene_selectors.json", "fields": ["selectedPointer", "fieldMaps", "linkedCns"]},
    {"path": "out/map_exit_candidates.json", "fields": ["exitCandidates", "blockedTargetCandidates", "sample"]},
]


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


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


def pack_xy(x: int, y: int) -> int:
    return (x & 0xFFFF) | ((y & 0xFFFF) << 16)


def pack_yx(x: int, y: int) -> int:
    return (y & 0xFFFF) | ((x & 0xFFFF) << 16)


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


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


def dword_at_va(exe: bytes, sections: list[dict], va: int) -> int | None:
    offset = va_to_offset(sections, va)
    if offset is None or offset + 4 > len(exe):
        return None
    return struct.unpack_from("<I", exe, offset)[0]


def selector_owner_ranges(selectors: list[dict]) -> list[dict]:
    starts = [
        row
        for row in selectors
        if isinstance(row.get("selectedPointer"), int)
    ]
    starts.sort(key=lambda row: row["selectedPointer"])
    ranges = []
    for index, row in enumerate(starts):
        start = row["selectedPointer"]
        end = starts[index + 1]["selectedPointer"] if index + 1 < len(starts) else 0x00600000
        ranges.append({
            "selector": f"{row.get('group')}:{row.get('slot')}",
            "rootHex": hex32(start),
            "rootEndHex": hex32(end),
            "start": start,
            "end": end,
            "fieldMaps": row.get("fieldMaps") or [],
            "linkedCns": row.get("linkedCns") or [],
        })
    return ranges


def owner_for_va(ranges: list[dict], va: int) -> dict | None:
    for row in ranges:
        if row["start"] <= va < row["end"]:
            return row
    return None


def selection_operation(opcode: int) -> str:
    return {
        0x10: "fillBranchStateTable",
        0x11: "readSelectedStateAndBranch",
        0x12: "selectActiveStateSlot",
        0x13: "selectMatchingRuntimeSlot",
    }.get(opcode, "other")


def count_field(rows: list[dict], key: str, *, missing: str | None = None) -> dict[str, int]:
    counter: Counter[str] = Counter()
    for row in rows:
        value = row.get(key)
        if value is None:
            if missing is None:
                continue
            value = missing
        counter[str(value)] += 1
    return dict(sorted(counter.items()))


def merge_count_maps(rows: list[dict], key: str) -> dict[str, int]:
    counter: Counter[str] = Counter()
    for row in rows:
        counter.update(row.get(key) or {})
    return dict(sorted(counter.items()))


def classify_hit(value: int, owner: dict | None) -> str:
    opcode = value & 0xFF
    if opcode in SELECTION_OPCODES:
        return "save-selector-selection-opcode"
    if owner and owner.get("selector") == "24:0" and "cara_at1.cns" in (owner.get("linkedCns") or []):
        return "character-descriptor-script-word"
    if owner and SOURCE in (owner.get("fieldMaps") or []) and TARGET in (owner.get("fieldMaps") or []):
        return "selector-script-word"
    return "unclassified-data-word"


def hit_row(exe: bytes, sections: list[dict], owner_ranges: list[dict], offset: int, value: int) -> dict | None:
    section = section_for_offset(sections, offset)
    if section is None or section["name"] not in SCAN_SECTIONS:
        return None
    va = offset_to_va(sections, offset)
    if va is None:
        return None
    owner = owner_for_va(owner_ranges, va)
    opcode = value & 0xFF
    stream_plus_1 = (value >> 8) & 0xFF
    stream_plus_2 = (value >> 16) & 0xFF
    aligned = (offset - section["raw"]) % 4 == 0
    row = {
        "vaHex": hex32(va),
        "section": section["name"],
        "dwordAligned": aligned,
        "valueHex": hex32(value),
        "u16Lo": value & 0xFFFF,
        "u16Hi": value >> 16,
        "lowOpcodeHex": hex8(opcode),
        "classification": classify_hit(value, owner),
        "promotableCoordinateEvidence": False,
    }
    if owner:
        row.update({
            "ownerSelector": owner["selector"],
            "ownerRootHex": owner["rootHex"],
            "ownerFieldMaps": owner["fieldMaps"],
            "ownerLinkedCnsSample": owner["linkedCns"][:8],
        })
    if opcode in SELECTION_OPCODES:
        row.update({
            "selectionOperation": selection_operation(opcode),
            "selectionBufferOffsetHex": hex8(stream_plus_2),
            "streamPlus1Hex": hex8(stream_plus_1),
        })
    context = []
    for item_va in range(va - 0x10, va + 0x14, 4):
        value_at = dword_at_va(exe, sections, item_va)
        if value_at is None:
            continue
        context.append({
            "vaHex": hex32(item_va),
            "valueHex": hex32(value_at),
            "lowOpcodeHex": hex8(value_at & 0xFF),
            "isHit": item_va == va,
        })
    row["contextRows"] = context
    return row


def scan_value(
    exe: bytes,
    sections: list[dict],
    owner_ranges: list[dict],
    label: str,
    value: int,
) -> dict:
    pattern = struct.pack("<I", value)
    hits = []
    section_counts: dict[str, int] = {}
    aligned_count = 0
    search = 0
    while True:
        offset = exe.find(pattern, search)
        if offset < 0:
            break
        search = offset + 1
        row = hit_row(exe, sections, owner_ranges, offset, value)
        if row is None:
            continue
        hits.append(row)
        section_counts[row["section"]] = section_counts.get(row["section"], 0) + 1
        if row["dwordAligned"]:
            aligned_count += 1
    interesting = [
        row for row in hits
        if row.get("ownerSelector") in {"2:0", "24:0", "1:0", "0:0"}
    ]
    current_root_hits = [row for row in hits if row.get("ownerSelector") == "2:0"]
    character_descriptor_hits = [row for row in hits if row.get("ownerSelector") == "24:0"]
    promotable_hits = [row for row in hits if row.get("promotableCoordinateEvidence") is True]
    interesting_promotable_hits = [
        row for row in interesting
        if row.get("promotableCoordinateEvidence") is True
    ]
    return {
        "label": label,
        "valueHex": hex32(value),
        "hitCount": len(hits),
        "alignedHitCount": aligned_count,
        "sectionCounts": dict(sorted(section_counts.items())),
        "interestingHitCount": len(interesting),
        "currentRootHitCount": len(current_root_hits),
        "characterDescriptorHitCount": len(character_descriptor_hits),
        "classificationCounts": count_field(hits, "classification"),
        "ownerSelectorCounts": count_field(hits, "ownerSelector", missing="unowned"),
        "interestingClassificationCounts": count_field(interesting, "classification"),
        "currentRootClassificationCounts": count_field(current_root_hits, "classification"),
        "characterDescriptorClassificationCounts": count_field(
            character_descriptor_hits,
            "classification",
        ),
        "promotableHitCount": len(promotable_hits),
        "interestingPromotableHitCount": len(interesting_promotable_hits),
        "allHitsNonPromotable": len(promotable_hits) == 0,
        "allInterestingHitsNonPromotable": len(interesting_promotable_hits) == 0,
        "hits": interesting[:24],
    }


def scan_sequence(exe: bytes, sections: list[dict], values: list[int]) -> list[dict]:
    pattern = b"".join(struct.pack("<I", value) for value in values)
    hits = []
    search = 0
    while True:
        offset = exe.find(pattern, search)
        if offset < 0:
            break
        search = offset + 1
        section = section_for_offset(sections, offset)
        va = offset_to_va(sections, offset)
        if va is None or section is None or section["name"] not in SCAN_SECTIONS:
            continue
        hits.append({
            "vaHex": hex32(va),
            "section": section["name"],
            "dwordAligned": (offset - section["raw"]) % 4 == 0,
            "valuesHex": [hex32(value) for value in values],
        })
    return hits


def candidate_sort_key(candidate: dict) -> tuple[int, int, int]:
    tile = candidate.get("tile") or {}
    return (
        SIDE_ORDER.get(candidate.get("side"), 99),
        int(tile.get("y") or 0),
        int(tile.get("x") or 0),
    )


def exit_candidates_from_map_exits(map_exit_candidates: list[dict]) -> list[dict]:
    candidates = []
    for source_row in map_exit_candidates:
        if source_row.get("map") != SOURCE:
            continue
        for candidate in source_row.get("exitCandidates") or []:
            sample = candidate.get("sample") or {}
            targets = [
                *(candidate.get("blockedTargetCandidates") or []),
                *(candidate.get("selectorTargetCandidates") or []),
            ]
            target_hint = next(
                (
                    hint for hint in candidate.get("targetHints") or []
                    if hint.get("target") == TARGET
                ),
                None,
            )
            if TARGET in targets and isinstance(sample.get("x"), int) and isinstance(sample.get("y"), int):
                candidates.append({
                    "side": candidate.get("side"),
                    "tile": {"x": sample.get("x"), "y": sample.get("y")},
                    "span": candidate.get("span") or {},
                    "standable": sample.get("standable"),
                    "targetHint": target_hint,
                    "reviewUrl": candidate.get("reviewUrl"),
                    "trialUrl": candidate.get("trialUrl"),
                })
    if candidates:
        return sorted(candidates, key=candidate_sort_key)
    return [{
        "side": "top",
        "tile": {"x": 18, "y": 0},
        "span": {},
        "standable": None,
        "reviewUrl": None,
        "trialUrl": None,
    }, {
        "side": "bottom",
        "tile": {"x": 16, "y": 47},
        "span": {},
        "standable": None,
        "reviewUrl": None,
        "trialUrl": None,
    }, {
        "side": "left",
        "tile": {"x": 3, "y": 14},
        "span": {},
        "standable": None,
        "reviewUrl": None,
        "trialUrl": None,
    }, {
        "side": RIGHT_EXIT["side"],
        "tile": {"x": RIGHT_EXIT["x"], "y": RIGHT_EXIT["y"]},
        "span": {},
        "standable": None,
        "reviewUrl": None,
        "trialUrl": None,
    }]


def candidate_from_map_exits(map_exit_candidates: list[dict]) -> dict:
    candidates = exit_candidates_from_map_exits(map_exit_candidates)
    for candidate in candidates:
        tile = candidate.get("tile") or {}
        if (
            candidate.get("side") == RIGHT_EXIT["side"]
            and tile.get("x") == RIGHT_EXIT["x"]
            and tile.get("y") == RIGHT_EXIT["y"]
        ):
            return candidate
    return candidates[-1]


def outside_tile(side: str, x: int, y: int) -> tuple[str, int, int]:
    if side == "top":
        return "outside-top tile", x, y - 1
    if side == "bottom":
        return "outside-bottom tile", x, y + 1
    if side == "left":
        return "outside-left tile", x - 1, y
    if side == "right":
        return "outside-right tile", x + 1, y
    return "outside tile", x, y


def variant_values(x: int, y: int, side: str) -> list[tuple[str, int]]:
    outside_label, outside_x, outside_y = outside_tile(side, x, y)
    pixel_x = x * 16
    pixel_y = y * 16
    origin_x = pixel_x - 16
    origin_y = pixel_y - 16
    center_x = pixel_x + 8
    center_y = pixel_y + 8
    outside_pixel_x = outside_x * 16
    outside_pixel_y = outside_y * 16
    screen_origin_x = pixel_x + 24
    screen_origin_y = pixel_y + 8
    screen_center_x = screen_origin_x + 8
    screen_center_y = screen_origin_y + 8
    outside_screen_origin_x = outside_pixel_x + 24
    outside_screen_origin_y = outside_pixel_y + 8
    return [
        ("exact tile x,y", pack_xy(x, y)),
        ("exact tile y,x", pack_yx(x, y)),
        ("one-based tile x,y", pack_xy(x + 1, y + 1)),
        ("one-based tile y,x", pack_yx(x + 1, y + 1)),
        (f"{outside_label} x,y", pack_xy(outside_x, outside_y)),
        (f"{outside_label} y,x", pack_yx(outside_x, outside_y)),
        ("pixel x*16,y*16", pack_xy(pixel_x, pixel_y)),
        ("pixel y*16,x*16", pack_yx(pixel_x, pixel_y)),
        ("runtime pixel origin x*16-16,y*16-16", pack_xy(origin_x, origin_y)),
        ("runtime pixel origin y*16-16,x*16-16", pack_yx(origin_x, origin_y)),
        ("runtime pixel center x*16+8,y*16+8", pack_xy(center_x, center_y)),
        ("runtime pixel center y*16+8,x*16+8", pack_yx(center_x, center_y)),
        (f"{outside_label} pixel x*16,y*16", pack_xy(outside_pixel_x, outside_pixel_y)),
        (f"{outside_label} pixel y*16,x*16", pack_yx(outside_pixel_x, outside_pixel_y)),
        ("screen-bias pixel x*16+24,y*16+8", pack_xy(screen_origin_x, screen_origin_y)),
        ("screen-bias pixel y*16+8,x*16+24", pack_yx(screen_origin_x, screen_origin_y)),
        ("screen-bias pixel center x*16+32,y*16+16", pack_xy(screen_center_x, screen_center_y)),
        ("screen-bias pixel center y*16+16,x*16+32", pack_yx(screen_center_x, screen_center_y)),
        (f"{outside_label} screen-bias pixel x*16+24,y*16+8", pack_xy(outside_screen_origin_x, outside_screen_origin_y)),
        (f"{outside_label} screen-bias pixel y*16+8,x*16+24", pack_yx(outside_screen_origin_x, outside_screen_origin_y)),
    ]


def unique_points(points: list[tuple[str, int, int]]) -> list[tuple[str, int, int]]:
    seen = set()
    rows = []
    for label, x, y in points:
        key = (x, y)
        if key in seen:
            continue
        seen.add(key)
        rows.append((label, x, y))
    return rows


def span_corner_points(candidate: dict) -> list[tuple[str, int, int]]:
    span = candidate.get("span") or {}
    axis = span.get("axis")
    start = span.get("from")
    end = span.get("to")
    perpendicular_start = span.get("perpendicularFrom")
    perpendicular_end = span.get("perpendicularTo")
    if not all(isinstance(value, int) for value in [start, end, perpendicular_start, perpendicular_end]):
        return []
    if axis == "x":
        return unique_points([
            ("span min/min", start, perpendicular_start),
            ("span max/min", end, perpendicular_start),
            ("span min/max", start, perpendicular_end),
            ("span max/max", end, perpendicular_end),
        ])
    if axis == "y":
        return unique_points([
            ("span min/min", perpendicular_start, start),
            ("span max/min", perpendicular_end, start),
            ("span min/max", perpendicular_start, end),
            ("span max/max", perpendicular_end, end),
        ])
    return []


def span_bound_scans(
    exe: bytes,
    sections: list[dict],
    owner_ranges: list[dict],
    candidate: dict,
) -> list[dict]:
    rows = []
    for corner_label, x, y in span_corner_points(candidate):
        rows.append(scan_value(exe, sections, owner_ranges, f"{corner_label} x,y", pack_xy(x, y)))
        rows[-1]["point"] = {"x": x, "y": y}
        rows[-1]["packedOrder"] = "xy"
        rows.append(scan_value(exe, sections, owner_ranges, f"{corner_label} y,x", pack_yx(x, y)))
        rows[-1]["point"] = {"x": x, "y": y}
        rows[-1]["packedOrder"] = "yx"
    return rows


def span_sequence_scans(exe: bytes, sections: list[dict], candidate: dict) -> list[dict]:
    points = span_corner_points(candidate)
    if not points:
        return []
    xs = [x for _, x, _ in points]
    ys = [y for _, _, y in points]
    min_point = (min(xs), min(ys))
    max_point = (max(xs), max(ys))
    sequences = [
        ("span min,max x,y sequence", [pack_xy(*min_point), pack_xy(*max_point)]),
        ("span min,max y,x sequence", [pack_yx(*min_point), pack_yx(*max_point)]),
    ]
    rows = []
    for label, values in sequences:
        hits = scan_sequence(exe, sections, values)
        rows.append({
            "label": label,
            "valuesHex": [hex32(value) for value in values],
            "hitCount": len(hits),
            "hits": hits[:12],
        })
    return rows


def axis_sequence_values(x: int, y: int, side: str, packed_order: str) -> list[int]:
    if side in {"top", "bottom"}:
        points = [(x, y - 1), (x, y), (x, y + 1)]
    else:
        points = [(x - 1, y), (x, y), (x + 1, y)]
    if packed_order == "yx":
        return [pack_yx(px, py) for px, py in points]
    return [pack_xy(px, py) for px, py in points]


def opcode_sequence_values(x: int, y: int) -> list[int]:
    return [pack_yx(x, y - 2), pack_yx(x, y - 1), pack_yx(x, y)]


def neighborhood_current_root_hits(exe: bytes, sections: list[dict], owner_ranges: list[dict], x: int, y: int) -> list[dict]:
    rows = []
    for nx in range(x - 2, x + 5):
        for ny in range(y - 2, y + 3):
            value = pack_yx(nx, ny)
            scan = scan_value(exe, sections, owner_ranges, f"neighbor y,x {ny},{nx}", value)
            current_hits = [row for row in scan["hits"] if row.get("ownerSelector") == "2:0"]
            if not current_hits:
                continue
            rows.append({
                "tileInterpretedAsXy": {"x": nx, "y": ny},
                "yxPackedHex": hex32(value),
                "currentRootHitCount": len(current_hits),
                "sampleHits": current_hits[:4],
                "allSamplesAreOpcodeShaped": all(row.get("classification") == "save-selector-selection-opcode" for row in current_hits),
            })
    return rows


def summarize_candidate(
    exe: bytes,
    sections: list[dict],
    owner_ranges: list[dict],
    candidate: dict,
) -> dict:
    tile = candidate["tile"]
    x = tile["x"]
    y = tile["y"]
    side = candidate.get("side") or "exit"
    scans = [scan_value(exe, sections, owner_ranges, label, value) for label, value in variant_values(x, y, side)]
    exact_xy = scans[0]
    exact_yx = scans[1]
    current_root_yx_hits = [
        row for row in exact_yx["hits"]
        if row.get("ownerSelector") == "2:0"
    ]
    exact_xy_character_hits = [
        row for row in exact_xy["hits"]
        if row.get("ownerSelector") == "24:0"
    ]
    xy_axis_sequence = scan_sequence(exe, sections, axis_sequence_values(x, y, side, "xy"))
    yx_axis_sequence = scan_sequence(exe, sections, axis_sequence_values(x, y, side, "yx"))
    yx_opcode_sequence = scan_sequence(exe, sections, opcode_sequence_values(x, y))
    span_scans = span_bound_scans(exe, sections, owner_ranges, candidate)
    span_sequences = span_sequence_scans(exe, sections, candidate)
    target_hint = candidate.get("targetHint") or {}
    target_spawn_scans = []
    if isinstance(target_hint.get("x"), int) and isinstance(target_hint.get("y"), int):
        target_x = target_hint["x"]
        target_y = target_hint["y"]
        target_side = target_hint.get("side") or "target"
        target_spawn_scans = [
            {
                **scan_value(exe, sections, owner_ranges, f"target spawn {label}", value),
                "targetSpawn": {
                    "target": target_hint.get("target"),
                    "side": target_side,
                    "x": target_x,
                    "y": target_y,
                    "standable": target_hint.get("standable"),
                    "autoTrigger": target_hint.get("autoTrigger"),
                },
            }
            for label, value in variant_values(target_x, target_y, target_side)
        ]
    target_spawn_promotable_hit_count = sum(
        row["promotableHitCount"] for row in target_spawn_scans
    )
    target_spawn_interesting_promotable_hit_count = sum(
        row["interestingPromotableHitCount"] for row in target_spawn_scans
    )
    return {
        "side": side,
        "tile": {"x": x, "y": y},
        "span": candidate.get("span") or {},
        "standable": candidate.get("standable"),
        "targetSpawn": {
            "target": target_hint.get("target"),
            "side": target_hint.get("side"),
            "x": target_hint.get("x"),
            "y": target_hint.get("y"),
            "standable": target_hint.get("standable"),
            "autoTrigger": target_hint.get("autoTrigger"),
            "projectionDelta": target_hint.get("projectionDelta"),
        } if target_hint else {},
        "reviewUrl": candidate.get("reviewUrl"),
        "trialUrl": candidate.get("trialUrl"),
        "variantScans": scans,
        "targetSpawnVariantScans": target_spawn_scans,
        "spanBoundScans": span_scans,
        "spanSequenceScans": span_sequences,
        "interestingHitCount": sum(row["interestingHitCount"] for row in scans),
        "currentRootHitCount": sum(row["currentRootHitCount"] for row in scans),
        "characterDescriptorHitCount": sum(row["characterDescriptorHitCount"] for row in scans),
        "targetSpawnVariantScanCount": len(target_spawn_scans),
        "targetSpawnHitCount": sum(row["hitCount"] for row in target_spawn_scans),
        "targetSpawnInterestingHitCount": sum(row["interestingHitCount"] for row in target_spawn_scans),
        "targetSpawnCurrentRootHitCount": sum(row["currentRootHitCount"] for row in target_spawn_scans),
        "targetSpawnCharacterDescriptorHitCount": sum(row["characterDescriptorHitCount"] for row in target_spawn_scans),
        "targetSpawnClassificationCounts": merge_count_maps(target_spawn_scans, "classificationCounts"),
        "targetSpawnOwnerSelectorCounts": merge_count_maps(target_spawn_scans, "ownerSelectorCounts"),
        "targetSpawnInterestingClassificationCounts": merge_count_maps(
            target_spawn_scans,
            "interestingClassificationCounts",
        ),
        "targetSpawnCurrentRootClassificationCounts": merge_count_maps(
            target_spawn_scans,
            "currentRootClassificationCounts",
        ),
        "targetSpawnCharacterDescriptorClassificationCounts": merge_count_maps(
            target_spawn_scans,
            "characterDescriptorClassificationCounts",
        ),
        "targetSpawnPromotableHitCount": target_spawn_promotable_hit_count,
        "targetSpawnInterestingPromotableHitCount": target_spawn_interesting_promotable_hit_count,
        "targetSpawnAllHitsNonPromotable": target_spawn_promotable_hit_count == 0,
        "targetSpawnAllInterestingHitsNonPromotable": target_spawn_interesting_promotable_hit_count == 0,
        "spanBoundHitCount": sum(row["hitCount"] for row in span_scans),
        "spanBoundCurrentRootHitCount": sum(row["currentRootHitCount"] for row in span_scans),
        "spanBoundCharacterDescriptorHitCount": sum(row["characterDescriptorHitCount"] for row in span_scans),
        "spanSequenceHitCount": sum(row["hitCount"] for row in span_sequences),
        "exactXyHitCount": exact_xy["hitCount"],
        "exactXyCharacterDescriptorHitCount": len(exact_xy_character_hits),
        "exactYxHitCount": exact_yx["hitCount"],
        "exactYxCurrentRootHitCount": len(current_root_yx_hits),
        "currentRootYxHits": current_root_yx_hits,
        "neighborhoodCurrentRootYxHits": neighborhood_current_root_hits(exe, sections, owner_ranges, x, y),
        "xyRowSequenceHitCount": len(xy_axis_sequence),
        "xyRowSequenceHits": xy_axis_sequence[:12],
        "yxAxisSequenceHitCount": len(yx_axis_sequence),
        "yxAxisSequenceHits": yx_axis_sequence[:12],
        "yxOpcodeSequenceHitCount": len(yx_opcode_sequence),
        "yxOpcodeSequenceHits": yx_opcode_sequence[:12],
        "targetSpawnStrictCoordinateEvidenceFound": False,
        "strictCoordinateEvidenceFound": False,
    }


def build_summary(
    exe: bytes,
    selectors: list[dict] | None = None,
    map_exit_candidates: list[dict] | None = None,
) -> dict:
    sections = read_sections(exe)
    selectors = selectors if selectors is not None else load_json(OUT / "save_scene_selectors.json", [])
    map_exit_candidates = map_exit_candidates if map_exit_candidates is not None else load_json(OUT / "map_exit_candidates.json", [])
    owner_ranges = selector_owner_ranges(selectors)
    candidates = exit_candidates_from_map_exits(map_exit_candidates)
    candidate_summaries = [
        summarize_candidate(exe, sections, owner_ranges, candidate)
        for candidate in candidates
    ]
    candidate = candidate_from_map_exits(map_exit_candidates)
    right_summary = next(
        (
            row for row in candidate_summaries
            if row["side"] == RIGHT_EXIT["side"]
            and row["tile"]["x"] == RIGHT_EXIT["x"]
            and row["tile"]["y"] == RIGHT_EXIT["y"]
        ),
        candidate_summaries[-1],
    )
    scans = [
        {**scan, "candidateSide": row["side"], "candidateTile": row["tile"]}
        for row in candidate_summaries
        for scan in row["variantScans"]
    ]
    span_scans = [
        {**scan, "candidateSide": row["side"], "candidateTile": row["tile"]}
        for row in candidate_summaries
        for scan in row["spanBoundScans"]
    ]
    span_sequence_scans = [
        {**scan, "candidateSide": row["side"], "candidateTile": row["tile"]}
        for row in candidate_summaries
        for scan in row["spanSequenceScans"]
    ]
    target_spawn_scans = [
        {
            **scan,
            "candidateSide": row["side"],
            "candidateTile": row["tile"],
            "targetSpawn": row.get("targetSpawn") or {},
        }
        for row in candidate_summaries
        for scan in row.get("targetSpawnVariantScans") or []
    ]
    target_spawn_promotable_hit_count = sum(
        row["promotableHitCount"] for row in target_spawn_scans
    )
    target_spawn_interesting_promotable_hit_count = sum(
        row["interestingPromotableHitCount"] for row in target_spawn_scans
    )
    target_spawn_classification_counts = merge_count_maps(target_spawn_scans, "classificationCounts")
    target_spawn_owner_selector_counts = merge_count_maps(target_spawn_scans, "ownerSelectorCounts")
    target_spawn_interesting_classification_counts = merge_count_maps(
        target_spawn_scans,
        "interestingClassificationCounts",
    )
    target_spawn_current_root_classification_counts = merge_count_maps(
        target_spawn_scans,
        "currentRootClassificationCounts",
    )
    target_spawn_character_descriptor_classification_counts = merge_count_maps(
        target_spawn_scans,
        "characterDescriptorClassificationCounts",
    )
    span_current_root_hits = [
        {
            "candidateSide": scan.get("candidateSide"),
            "candidateTile": scan.get("candidateTile"),
            "label": scan.get("label"),
            "point": scan.get("point"),
            "valueHex": scan.get("valueHex"),
            **hit,
        }
        for scan in span_scans
        for hit in scan.get("hits") or []
        if hit.get("ownerSelector") == "2:0"
    ]
    right_yx_selection_hits = [
        row for row in right_summary["currentRootYxHits"]
        if row.get("classification") == "save-selector-selection-opcode"
    ]
    span_selector_script_hits = [
        row for row in span_current_root_hits
        if row.get("classification") == "selector-script-word"
    ]
    false_positive_summary = {
        "candidateCount": len(candidate_summaries),
        "variantScanCount": len(scans),
        "spanBoundScanCount": len(span_scans),
        "spanSequenceScanCount": len(span_sequence_scans),
        "legacyRightExactXyHitCount": right_summary["exactXyHitCount"],
        "legacyRightExactXyCharacterDescriptorHitCount": right_summary["exactXyCharacterDescriptorHitCount"],
        "legacyRightExactYxCurrentRootHitCount": right_summary["exactYxCurrentRootHitCount"],
        "legacyRightExactYxSelectionOpcodeHitCount": len(right_yx_selection_hits),
        "spanCurrentRootHitCount": len(span_current_root_hits),
        "spanCurrentRootSelectorScriptHitCount": len(span_selector_script_hits),
        "spanSequenceHitCount": sum(row["hitCount"] for row in span_sequence_scans),
        "targetSpawnVariantScanCount": len(target_spawn_scans),
        "targetSpawnHitCount": sum(row["hitCount"] for row in target_spawn_scans),
        "targetSpawnInterestingHitCount": sum(row["interestingHitCount"] for row in target_spawn_scans),
        "targetSpawnCurrentRootHitCount": sum(row["currentRootHitCount"] for row in target_spawn_scans),
        "targetSpawnCharacterDescriptorHitCount": sum(row["characterDescriptorHitCount"] for row in target_spawn_scans),
        "targetSpawnClassificationCounts": target_spawn_classification_counts,
        "targetSpawnOwnerSelectorCounts": target_spawn_owner_selector_counts,
        "targetSpawnInterestingClassificationCounts": target_spawn_interesting_classification_counts,
        "targetSpawnCurrentRootClassificationCounts": target_spawn_current_root_classification_counts,
        "targetSpawnCharacterDescriptorClassificationCounts": (
            target_spawn_character_descriptor_classification_counts
        ),
        "targetSpawnPromotableHitCount": target_spawn_promotable_hit_count,
        "targetSpawnInterestingPromotableHitCount": target_spawn_interesting_promotable_hit_count,
        "targetSpawnAllHitsNonPromotable": target_spawn_promotable_hit_count == 0,
        "targetSpawnAllInterestingHitsNonPromotable": (
            target_spawn_interesting_promotable_hit_count == 0
        ),
        "targetSpawnStrictCoordinateEvidenceFound": False,
        "strictCoordinateEvidenceFound": False,
        "classification": "all current-root/descriptor hits are VM script words, not strict coordinate tables",
    }
    candidate_labels = ", ".join(
        f"{row['side']} {row['tile']['x']},{row['tile']['y']}"
        for row in candidate_summaries
    )
    conclusion = (
        f"A broader scan of all {len(candidate_summaries)} map1_01a -> map2_02d exit candidates "
        f"({candidate_labels}) still does not produce strict source hotspot evidence. The scan covers tile, "
        "one-based tile, outside tile, tile*16 pixel, runtime pixel origin, runtime pixel center, screen-bias "
        "projection, target-spawn coordinate variants, and outside "
        "pixel encodings, plus span corner/bounds encodings. The legacy right-exit exact x,y packed value appears only inside the cara_at1 descriptor script, not a map1_01a event table. "
        "The legacy right-exit y,x packed value has hits in the current selector root, but every current-root hit "
        "decodes as a save-selector selection opcode row with low opcode 0x13 and selection offset 0x22. "
        "Span-corner scalar values do appear in the current selector root, but no min/max span sequence is present "
        "and the current-root hits classify as selector script words rather than a strict coordinate table. "
        "Target-spawn variants also resolve their current-root hits to selector script words and their descriptor hits "
        "to cara_at1 character script words, with no promotable coordinate hit. "
        "That is VM control data, not a coordinate table. Keep map1_01a -> map2_02d blocked until a strict source "
        "hotspot, runtime trace, or equivalent trigger proof is found."
    )
    return {
        "source": SOURCE,
        "target": TARGET,
        "candidateCount": len(candidate_summaries),
        "candidates": candidates,
        "candidate": candidate,
        "legacyRightExitCandidate": candidate,
        "candidateSummaries": candidate_summaries,
        "bestCandidate": max(candidate_summaries, key=lambda row: (row["currentRootHitCount"], row["interestingHitCount"])),
        "variantScans": scans,
        "targetSpawnVariantScans": target_spawn_scans,
        "targetSpawnVariantScanCount": len(target_spawn_scans),
        "targetSpawnHitCount": sum(row["hitCount"] for row in target_spawn_scans),
        "targetSpawnInterestingHitCount": sum(row["interestingHitCount"] for row in target_spawn_scans),
        "targetSpawnCurrentRootHitCount": sum(row["currentRootHitCount"] for row in target_spawn_scans),
        "targetSpawnCharacterDescriptorHitCount": sum(row["characterDescriptorHitCount"] for row in target_spawn_scans),
        "targetSpawnClassificationCounts": target_spawn_classification_counts,
        "targetSpawnOwnerSelectorCounts": target_spawn_owner_selector_counts,
        "targetSpawnInterestingClassificationCounts": target_spawn_interesting_classification_counts,
        "targetSpawnCurrentRootClassificationCounts": target_spawn_current_root_classification_counts,
        "targetSpawnCharacterDescriptorClassificationCounts": (
            target_spawn_character_descriptor_classification_counts
        ),
        "targetSpawnPromotableHitCount": target_spawn_promotable_hit_count,
        "targetSpawnInterestingPromotableHitCount": target_spawn_interesting_promotable_hit_count,
        "targetSpawnAllHitsNonPromotable": target_spawn_promotable_hit_count == 0,
        "targetSpawnAllInterestingHitsNonPromotable": (
            target_spawn_interesting_promotable_hit_count == 0
        ),
        "targetSpawnStrictCoordinateEvidenceFound": False,
        "spanBoundScans": span_scans,
        "spanSequenceScans": span_sequence_scans,
        "spanCurrentRootHits": span_current_root_hits,
        "spanBoundScanCount": len(span_scans),
        "spanSequenceScanCount": len(span_sequence_scans),
        "spanBoundCurrentRootHitCount": sum(row["currentRootHitCount"] for row in span_scans),
        "spanCurrentRootHitCount": len(span_current_root_hits),
        "spanSequenceHitCount": sum(row["hitCount"] for row in span_sequence_scans),
        "spanBoundStrictCoordinateEvidenceFound": False,
        "exactXyHitCount": right_summary["exactXyHitCount"],
        "exactXyCharacterDescriptorHitCount": right_summary["exactXyCharacterDescriptorHitCount"],
        "exactYxHitCount": right_summary["exactYxHitCount"],
        "exactYxCurrentRootHitCount": right_summary["exactYxCurrentRootHitCount"],
        "currentRootYxHits": right_summary["currentRootYxHits"],
        "neighborhoodCurrentRootYxHits": right_summary["neighborhoodCurrentRootYxHits"],
        "falsePositiveSummary": false_positive_summary,
        "xyRowSequenceHitCount": right_summary["xyRowSequenceHitCount"],
        "xyRowSequenceHits": right_summary["xyRowSequenceHits"],
        "yxOpcodeSequenceHitCount": right_summary["yxOpcodeSequenceHitCount"],
        "yxOpcodeSequenceHits": right_summary["yxOpcodeSequenceHits"],
        "strictCoordinateEvidenceFound": False,
        "controlPathProofStatus": "blocked",
        "promotionStatus": "blocked",
        "proofFound": False,
        "exitCoordinateVariantProofFound": False,
        "failedExitCoordinateVariantGateIds": FAILED_EXIT_COORDINATE_VARIANT_GATE_IDS,
        "missingEvidence": EXIT_COORDINATE_VARIANT_MISSING_EVIDENCE,
        "evidenceRefs": EXIT_COORDINATE_VARIANT_EVIDENCE_REFS,
        "evidenceRefCount": len(EXIT_COORDINATE_VARIANT_EVIDENCE_REFS),
        "conclusion": conclusion,
    }


def html_page(summary: dict) -> str:
    candidate = summary["candidate"]
    tile = candidate["tile"]
    candidates = summary.get("candidateSummaries") or []
    false_positive = summary.get("falsePositiveSummary") or {}
    false_positive_rows = []
    for key in [
        "candidateCount",
        "variantScanCount",
        "spanBoundScanCount",
        "spanSequenceScanCount",
        "legacyRightExactXyHitCount",
        "legacyRightExactXyCharacterDescriptorHitCount",
        "legacyRightExactYxCurrentRootHitCount",
        "legacyRightExactYxSelectionOpcodeHitCount",
        "spanCurrentRootHitCount",
        "spanCurrentRootSelectorScriptHitCount",
        "spanSequenceHitCount",
        "targetSpawnVariantScanCount",
        "targetSpawnHitCount",
        "targetSpawnInterestingHitCount",
        "targetSpawnCurrentRootHitCount",
        "targetSpawnCharacterDescriptorHitCount",
        "targetSpawnCurrentRootClassificationCounts",
        "targetSpawnCharacterDescriptorClassificationCounts",
        "targetSpawnPromotableHitCount",
        "targetSpawnInterestingPromotableHitCount",
        "targetSpawnAllInterestingHitsNonPromotable",
        "targetSpawnStrictCoordinateEvidenceFound",
        "strictCoordinateEvidenceFound",
        "classification",
    ]:
        false_positive_rows.append(
            "<tr>"
            f"<td>{html.escape(key)}</td>"
            f"<td>{html.escape(str(false_positive.get(key)))}</td>"
            "</tr>"
        )
    candidate_rows = []
    for row in candidates:
        row_tile = row["tile"]
        target_spawn = row.get("targetSpawn") or {}
        target_spawn_text = (
            f"{target_spawn.get('side')} {target_spawn.get('x')},{target_spawn.get('y')}"
            if target_spawn else "-"
        )
        candidate_rows.append(
            "<tr>"
            f"<td>{html.escape(row['side'])}</td>"
            f"<td><code>{row_tile['x']},{row_tile['y']}</code></td>"
            f"<td><code>{html.escape(target_spawn_text)}</code></td>"
            f"<td>{row['interestingHitCount']}</td>"
            f"<td>{row['currentRootHitCount']}</td>"
            f"<td>{row['characterDescriptorHitCount']}</td>"
            f"<td>{row.get('targetSpawnHitCount')}/{row.get('targetSpawnCurrentRootHitCount')}</td>"
            f"<td>{row['spanBoundHitCount']}</td>"
            f"<td>{row['spanBoundCurrentRootHitCount']}</td>"
            f"<td>{row['spanSequenceHitCount']}</td>"
            f"<td>{row['exactXyHitCount']}</td>"
            f"<td>{row['exactYxHitCount']}</td>"
            f"<td>{row['xyRowSequenceHitCount']}</td>"
            f"<td>{row['yxAxisSequenceHitCount']}</td>"
            f"<td>{row['yxOpcodeSequenceHitCount']}</td>"
            "</tr>"
        )
    target_spawn_rows = []
    for row in summary["targetSpawnVariantScans"]:
        candidate_tile = row.get("candidateTile") or {}
        target_spawn = row.get("targetSpawn") or {}
        candidate_label = f"{row.get('candidateSide')} {candidate_tile.get('x')},{candidate_tile.get('y')}"
        target_label = f"{target_spawn.get('side')} {target_spawn.get('x')},{target_spawn.get('y')}"
        sections = ", ".join(f"{key}={value}" for key, value in row["sectionCounts"].items()) or "-"
        current_classes = json.dumps(row.get("currentRootClassificationCounts") or {}, sort_keys=True)
        character_classes = json.dumps(
            row.get("characterDescriptorClassificationCounts") or {},
            sort_keys=True,
        )
        target_spawn_rows.append(
            "<tr>"
            f"<td>{html.escape(candidate_label)}</td>"
            f"<td><code>{html.escape(target_label)}</code></td>"
            f"<td>{html.escape(row['label'])}</td>"
            f"<td><code>{html.escape(row['valueHex'])}</code></td>"
            f"<td>{row['hitCount']}</td>"
            f"<td>{row['currentRootHitCount']}</td>"
            f"<td>{row['characterDescriptorHitCount']}</td>"
            f"<td><code>{html.escape(current_classes)}</code></td>"
            f"<td><code>{html.escape(character_classes)}</code></td>"
            f"<td>{row.get('promotableHitCount')}</td>"
            f"<td>{html.escape(sections)}</td>"
            "</tr>"
        )
    variant_rows = []
    for row in summary["variantScans"]:
        candidate_tile = row.get("candidateTile") or {}
        candidate_label = f"{row.get('candidateSide')} {candidate_tile.get('x')},{candidate_tile.get('y')}"
        sections = ", ".join(f"{key}={value}" for key, value in row["sectionCounts"].items()) or "-"
        variant_rows.append(
            "<tr>"
            f"<td>{html.escape(candidate_label)}</td>"
            f"<td>{html.escape(row['label'])}</td>"
            f"<td><code>{html.escape(row['valueHex'])}</code></td>"
            f"<td>{row['hitCount']}</td>"
            f"<td>{row['currentRootHitCount']}</td>"
            f"<td>{row['characterDescriptorHitCount']}</td>"
            f"<td>{html.escape(sections)}</td>"
            "</tr>"
        )
    span_rows = []
    for row in summary["spanBoundScans"]:
        candidate_tile = row.get("candidateTile") or {}
        point = row.get("point") or {}
        candidate_label = f"{row.get('candidateSide')} {candidate_tile.get('x')},{candidate_tile.get('y')}"
        sections = ", ".join(f"{key}={value}" for key, value in row["sectionCounts"].items()) or "-"
        span_rows.append(
            "<tr>"
            f"<td>{html.escape(candidate_label)}</td>"
            f"<td>{html.escape(row['label'])}</td>"
            f"<td><code>{point.get('x')},{point.get('y')} / {html.escape(row['valueHex'])}</code></td>"
            f"<td>{row['hitCount']}</td>"
            f"<td>{row['currentRootHitCount']}</td>"
            f"<td>{row['characterDescriptorHitCount']}</td>"
            f"<td>{html.escape(sections)}</td>"
            "</tr>"
        )
    span_sequence_rows = []
    for row in summary["spanSequenceScans"]:
        candidate_tile = row.get("candidateTile") or {}
        candidate_label = f"{row.get('candidateSide')} {candidate_tile.get('x')},{candidate_tile.get('y')}"
        span_sequence_rows.append(
            "<tr>"
            f"<td>{html.escape(candidate_label)}</td>"
            f"<td>{html.escape(row['label'])}</td>"
            f"<td><code>{html.escape(', '.join(row['valuesHex']))}</code></td>"
            f"<td>{row['hitCount']}</td>"
            "</tr>"
        )
    span_current_hit_rows = []
    for row in summary["spanCurrentRootHits"]:
        candidate_tile = row.get("candidateTile") or {}
        candidate_label = f"{row.get('candidateSide')} {candidate_tile.get('x')},{candidate_tile.get('y')}"
        span_current_hit_rows.append(
            "<tr>"
            f"<td>{html.escape(candidate_label)}</td>"
            f"<td>{html.escape(str(row.get('label')))}</td>"
            f"<td><code>{html.escape(str(row.get('vaHex')))}</code></td>"
            f"<td><code>{html.escape(str(row.get('valueHex')))}</code></td>"
            f"<td><code>{html.escape(str(row.get('lowOpcodeHex')))}</code></td>"
            f"<td>{html.escape(str(row.get('classification')))}</td>"
            "</tr>"
        )
    hit_rows = []
    for row in summary["currentRootYxHits"]:
        hit_rows.append(
            "<tr>"
            f"<td><code>{html.escape(row['vaHex'])}</code></td>"
            f"<td><code>{html.escape(row['valueHex'])}</code></td>"
            f"<td><code>{html.escape(row['lowOpcodeHex'])}</code></td>"
            f"<td>{html.escape(row.get('selectionOperation', '-'))}</td>"
            f"<td><code>{html.escape(row.get('selectionBufferOffsetHex', '-'))}</code></td>"
            f"<td>{html.escape(row['classification'])}</td>"
            "</tr>"
        )
    return "\n".join([
        "<!doctype html>",
        '<html lang="en">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>map1_01a Exit Coordinate Variant Scan</title>",
        "  <style>",
        "    :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #101010; color: #eee; }",
        "    body { margin: 0; padding: 24px; }",
        "    h1 { margin: 0 0 8px; font-size: 24px; }",
        "    h2 { margin: 24px 0 8px; font-size: 18px; }",
        "    p { max-width: 1120px; color: #bbb; line-height: 1.45; }",
        "    table { width: 100%; border-collapse: collapse; margin: 12px 0 20px; font-size: 13px; }",
        "    th, td { border-bottom: 1px solid #303030; padding: 7px 8px; text-align: left; vertical-align: top; }",
        "    th { position: sticky; top: 0; background: #181818; color: #ddd; }",
        "    code { color: #d7f0ff; }",
        "  </style>",
        "</head>",
        "<body>",
        "  <h1>map1_01a Exit Coordinate Variant Scan</h1>",
        f"  <p>route {html.escape(summary['source'])} -&gt; {html.escape(summary['target'])}; candidate count: {summary.get('candidateCount', len(candidates))}; legacy right candidate {html.escape(candidate['side'])} <code>{tile['x']},{tile['y']}</code>; exact x,y hits {summary['exactXyHitCount']}; exact y,x current root hits {summary['exactYxCurrentRootHitCount']}; promotion status <code>{html.escape(summary['promotionStatus'])}</code>.</p>",
        f"  <p>proof found <code>{summary['proofFound']}</code>; failed exit coordinate variant gates <code>{html.escape(', '.join(summary['failedExitCoordinateVariantGateIds']))}</code>; missing evidence count <code>{len(summary['missingEvidence'])}</code>; evidence refs <code>{summary.get('evidenceRefCount')}</code>.</p>",
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        "  <h2>Missing Evidence</h2>",
        "  <ul>",
        *(f"    <li>{html.escape(item)}</li>" for item in summary["missingEvidence"]),
        "  </ul>",
        "  <h2>False-Positive Summary</h2>",
        "  <table><thead><tr><th>check</th><th>result</th></tr></thead><tbody>",
        *false_positive_rows,
        "  </tbody></table>",
        "  <h2>Candidate Summary</h2>",
        "  <table><thead><tr><th>side</th><th>tile</th><th>target spawn</th><th>interesting hits</th><th>current root</th><th>character script</th><th>target spawn hits/current</th><th>span hits</th><th>span current root</th><th>span sequence</th><th>exact x,y</th><th>exact y,x</th><th>x,y sequence</th><th>y,x sequence</th><th>opcode sequence</th></tr></thead><tbody>",
        *candidate_rows,
        "  </tbody></table>",
        "  <h2>Variant Scans</h2>",
        "  <table><thead><tr><th>candidate</th><th>variant</th><th>value</th><th>hits</th><th>current root</th><th>character script</th><th>sections</th></tr></thead><tbody>",
        *variant_rows,
        "  </tbody></table>",
        "  <h2>Target Spawn Variant Scans</h2>",
        "  <table><thead><tr><th>candidate</th><th>target spawn</th><th>variant</th><th>value</th><th>hits</th><th>current root</th><th>character script</th><th>current classes</th><th>character classes</th><th>promotable</th><th>sections</th></tr></thead><tbody>",
        *(target_spawn_rows or ['<tr><td colspan="11">No target-spawn scans.</td></tr>']),
        "  </tbody></table>",
        "  <h2>Span/Bounds Scans</h2>",
        "  <table><thead><tr><th>candidate</th><th>span variant</th><th>point/value</th><th>hits</th><th>current root</th><th>character script</th><th>sections</th></tr></thead><tbody>",
        *(span_rows or ['<tr><td colspan="7">No span/bounds scans.</td></tr>']),
        "  </tbody></table>",
        "  <h2>Span Sequence Scans</h2>",
        "  <table><thead><tr><th>candidate</th><th>sequence</th><th>values</th><th>hits</th></tr></thead><tbody>",
        *(span_sequence_rows or ['<tr><td colspan="4">No span sequence scans.</td></tr>']),
        "  </tbody></table>",
        "  <h2>Span Current Root Hits</h2>",
        "  <table><thead><tr><th>candidate</th><th>span variant</th><th>va</th><th>value</th><th>low opcode</th><th>classification</th></tr></thead><tbody>",
        *(span_current_hit_rows or ['<tr><td colspan="6">No span current-root hits.</td></tr>']),
        "  </tbody></table>",
        "  <h2>Legacy Right Current Root y,x Hits</h2>",
        "  <table><thead><tr><th>va</th><th>value</th><th>low opcode</th><th>operation</th><th>offset</th><th>classification</th></tr></thead><tbody>",
        *hit_rows,
        "  </tbody></table>",
        "</body>",
        "</html>",
        "",
    ])


def write_outputs(summary: dict, out_dir: Path = OUT) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "map1_01a_exit_coordinate_variant_scan.json").write_text(
        json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--selectors", type=Path, default=OUT / "save_scene_selectors.json")
    parser.add_argument("--map-exits", type=Path, default=OUT / "map_exit_candidates.json")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(
        args.exe.read_bytes(),
        load_json(args.selectors, []),
        load_json(args.map_exits, []),
    )
    write_outputs(summary, args.out_dir)
    print(f"wrote map1_01a exit coordinate variant scan -> {args.out_dir / 'map1_01a_exit_coordinate_variant_scan.json'}")


if __name__ == "__main__":
    main()
