#!/usr/bin/env python3
"""Scan byte-sized coordinate encodings for map1_01a -> map2_02d exits."""
from __future__ import annotations

import argparse
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


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
EXE = ROOT / "Hwanse2.exe"
SOURCE = "map1_01a"
TARGET = "map2_02d"
SCAN_SECTIONS = {".text", ".rdata", ".data"}
SIDE_ORDER = {"top": 0, "bottom": 1, "left": 2, "right": 3}
SIDE_VECTOR = {
    "top": (0, -1),
    "bottom": (0, 1),
    "left": (-1, 0),
    "right": (1, 0),
}
SAMPLE_LIMIT = 20
FAILED_EXIT_BYTE_COORDINATE_GATE_IDS = [
    "strict-source-target-byte-owner",
    "target-spawn-strict-byte-owner",
    "axis-or-span-sequence-owner",
    "runtime-trigger-proof",
]
EXIT_BYTE_COORDINATE_MISSING_EVIDENCE = [
    "byte coordinate hit owned by a strict map1_01a -> map2_02d event/control record",
    "reciprocal target-spawn byte hit with strict source-target ownership",
    "axis/span byte sequence tied to a source exit table instead of selector/text collisions",
    "runtime trigger proving a byte-coordinate match selects the target map",
]
EXIT_BYTE_COORDINATE_EVIDENCE_REFS = [
    {"path": "Hwanse2.exe", "fields": ["byte coordinate scans", "selector owners", "text/code collisions"]},
    {"path": "out/map_exit_candidates.json", "fields": ["exitCandidates", "blockedTargetCandidates"]},
    {"path": "out/save_scene_selectors.json", "fields": ["selectedPointer", "fieldMaps", "linkedCns"]},
    {"path": "out/scene_events.json", "fields": ["recordVa", "pointTableVa", "pointTableEndVa"]},
    {"path": "out/event_transitions.json", "fields": ["recordVa", "targets"]},
]


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


def hex_bytes(data: bytes) -> str:
    return data.hex(" ")


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 = int(section["raw"])
        end = start + int(section["raw_size"])
        if start <= offset < end:
            return section
    return None


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


def selector_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 transition_targets_by_record(event_transitions: list[dict]) -> dict[int, list[str]]:
    rows: dict[int, list[str]] = {}
    for row in event_transitions:
        record_va = row.get("recordVa")
        if isinstance(record_va, int):
            rows[record_va] = row.get("targets") or []
    return rows


def strict_event_owner_for_va(
    scene_events: list[dict],
    event_targets: dict[int, list[str]],
    va: int,
) -> dict | None:
    for event in scene_events:
        record_va = event.get("recordVa")
        point_end_va = event.get("pointTableEndVa")
        if not isinstance(record_va, int) or not isinstance(point_end_va, int):
            continue
        point_start_va = event.get("pointTableVa")
        start_va = point_start_va if isinstance(point_start_va, int) else record_va
        if start_va <= va < point_end_va:
            return {
                "map": event.get("map"),
                "recordVa": record_va,
                "recordVaHex": hex32(record_va),
                "pointTableVaHex": hex32(point_start_va),
                "pointTableEndVaHex": hex32(point_end_va),
                "targets": event_targets.get(record_va, []),
            }
    return None


def nearest_manifest_record(scene_manifest: list[dict], va: int) -> dict | None:
    previous = None
    for row in sorted(
        [item for item in scene_manifest if isinstance(item.get("recordVa"), int)],
        key=lambda item: int(item["recordVa"]),
    ):
        if int(row["recordVa"]) <= va:
            previous = row
            continue
        break
    if not previous:
        return None
    if va - int(previous["recordVa"]) > 0x300:
        return None
    return {
        "map": previous.get("map"),
        "recordVaHex": hex32(previous.get("recordVa")),
        "sceneIdHex": previous.get("sceneIdHex"),
        "tilesets": previous.get("tilesets") or [],
    }


def classify_hit(
    section_name: str,
    va: int,
    selector_owner: dict | None,
    strict_event_owner: dict | None,
    manifest_owner: dict | None,
) -> str:
    if strict_event_owner:
        if (
            strict_event_owner.get("map") == SOURCE
            and TARGET in (strict_event_owner.get("targets") or [])
        ):
            return "strict-source-target-event-byte-coordinate"
        return "strict-event-other-byte-coordinate"
    if section_name == ".text":
        return "text-code-byte-collision"
    if selector_owner:
        if selector_owner.get("selector") == "2:0":
            return "current-selector-root-byte-collision"
        return "selector-root-byte-collision"
    if manifest_owner:
        if manifest_owner.get("map") == SOURCE:
            return "source-scene-manifest-byte-collision"
        return "scene-manifest-byte-collision"
    return "unowned-data-byte-collision"


def hit_priority(row: dict) -> tuple[int, str]:
    classification = row.get("classification")
    priority = {
        "strict-source-target-event-byte-coordinate": 0,
        "strict-event-other-byte-coordinate": 1,
        "current-selector-root-byte-collision": 2,
        "source-scene-manifest-byte-collision": 3,
        "selector-root-byte-collision": 4,
        "scene-manifest-byte-collision": 5,
        "unowned-data-byte-collision": 6,
        "text-code-byte-collision": 7,
    }.get(str(classification), 8)
    return priority, str(row.get("vaHex"))


def scan_pattern(
    exe: bytes,
    sections: list[dict],
    selector_ranges: list[dict],
    scene_events: list[dict],
    event_targets: dict[int, list[str]],
    scene_manifest: list[dict],
    label: str,
    pattern: bytes,
) -> dict:
    section_counts: dict[str, int] = {}
    classification_counts: dict[str, int] = {}
    word_aligned_count = 0
    dword_aligned_count = 0
    hit_count = 0
    samples: list[dict] = []
    search = 0
    while True:
        hit = exe.find(pattern, search)
        if hit < 0:
            break
        search = hit + 1
        section = section_for_offset(sections, hit)
        if section is None or section.get("name") not in SCAN_SECTIONS:
            continue
        section_name = str(section["name"])
        va = offset_to_va(sections, hit)
        if va is None:
            continue
        hit_count += 1
        section_counts[section_name] = section_counts.get(section_name, 0) + 1
        if (hit - int(section["raw"])) % 2 == 0:
            word_aligned_count += 1
        if (hit - int(section["raw"])) % 4 == 0:
            dword_aligned_count += 1
        selector_owner = selector_owner_for_va(selector_ranges, va)
        strict_event_owner = strict_event_owner_for_va(scene_events, event_targets, va)
        manifest_owner = nearest_manifest_record(scene_manifest, va)
        classification = classify_hit(
            section_name,
            va,
            selector_owner,
            strict_event_owner,
            manifest_owner,
        )
        classification_counts[classification] = classification_counts.get(classification, 0) + 1
        row = {
            "va": va,
            "vaHex": hex32(va),
            "section": section_name,
            "wordAligned": (hit - int(section["raw"])) % 2 == 0,
            "dwordAligned": (hit - int(section["raw"])) % 4 == 0,
            "classification": classification,
            "selectorOwner": (selector_owner or {}).get("selector"),
            "selectorOwnerRootHex": (selector_owner or {}).get("rootVaHex"),
            "selectorOwnerFieldMaps": (selector_owner or {}).get("fieldMaps") or [],
            "strictEventOwner": strict_event_owner,
            "manifestOwner": manifest_owner,
            "contextHex": hex_bytes(exe[max(0, hit - 8): min(len(exe), hit + len(pattern) + 8)]),
        }
        if len(samples) < SAMPLE_LIMIT:
            samples.append(row)
        elif hit_priority(row) < max(hit_priority(item) for item in samples):
            worst = max(range(len(samples)), key=lambda index: hit_priority(samples[index]))
            samples[worst] = row
    samples = sorted(samples, key=hit_priority)
    return {
        "label": label,
        "patternHex": hex_bytes(pattern),
        "hitCount": hit_count,
        "wordAlignedHitCount": word_aligned_count,
        "dwordAlignedHitCount": dword_aligned_count,
        "sectionCounts": dict(sorted(section_counts.items())),
        "classificationCounts": dict(sorted(classification_counts.items())),
        "strictSourceTargetHitCount": classification_counts.get(
            "strict-source-target-event-byte-coordinate",
            0,
        ),
        "strictEventOtherHitCount": classification_counts.get("strict-event-other-byte-coordinate", 0),
        "currentSelectorRootHitCount": classification_counts.get(
            "current-selector-root-byte-collision",
            0,
        ),
        "textCodeHitCount": classification_counts.get("text-code-byte-collision", 0),
        "samples": samples,
    }


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


def exit_candidates(map_exit_candidates: list[dict]) -> list[dict]:
    rows = []
    for map_row in map_exit_candidates:
        if map_row.get("map") != SOURCE:
            continue
        for candidate in map_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,
            )
            x = sample.get("x")
            y = sample.get("y")
            if TARGET in targets and isinstance(x, int) and isinstance(y, int):
                rows.append({**candidate, "targetHint": target_hint})
    unique = {}
    for row in rows:
        sample = row.get("sample") or {}
        unique[(row.get("side"), sample.get("x"), sample.get("y"))] = row
    return sorted(unique.values(), key=candidate_sort_key)


def byte_point(x: int, y: int) -> bytes | None:
    if not (0 <= x <= 255 and 0 <= y <= 255):
        return None
    return bytes([x, y])


def side_axis_points(side: str, x: int, y: int) -> list[tuple[int, int]]:
    dx, dy = SIDE_VECTOR.get(side, (0, 0))
    if dx:
        return [(x - 1, y), (x, y), (x + 1, y)]
    if dy:
        return [(x, y - 1), (x, y), (x, y + 1)]
    return [(x, y)]


def byte_pair_variants(side: str, x: int, y: int) -> list[tuple[str, int, int, str]]:
    variants = [
        ("tile byte x,y", x, y, "xy"),
        ("tile byte y,x", x, y, "yx"),
        ("one-based byte x,y", x + 1, y + 1, "xy"),
        ("one-based byte y,x", x + 1, y + 1, "yx"),
    ]
    dx, dy = SIDE_VECTOR.get(str(side), (0, 0))
    outside_x = x + dx
    outside_y = y + dy
    variants.extend([
        ("outside byte x,y", outside_x, outside_y, "xy"),
        ("outside byte y,x", outside_x, outside_y, "yx"),
    ])
    return variants


def scan_byte_pair_variants(
    exe: bytes,
    sections: list[dict],
    selector_ranges: list[dict],
    scene_events: list[dict],
    event_targets: dict[int, list[str]],
    scene_manifest: list[dict],
    side: str,
    x: int,
    y: int,
    label_prefix: str = "",
) -> list[dict]:
    scans = []
    for label, px, py, order in byte_pair_variants(side, x, y):
        pattern = byte_point(px, py) if order == "xy" else byte_point(py, px)
        full_label = f"{label_prefix}{label}"
        if pattern is None:
            scans.append({
                "label": full_label,
                "point": {"x": px, "y": py},
                "packedOrder": order,
                "patternHex": "-",
                "hitCount": 0,
                "skippedOutOfByteRange": True,
            })
            continue
        row = scan_pattern(
            exe,
            sections,
            selector_ranges,
            scene_events,
            event_targets,
            scene_manifest,
            full_label,
            pattern,
        )
        row.update({
            "point": {"x": px, "y": py},
            "packedOrder": order,
            "skippedOutOfByteRange": False,
        })
        scans.append(row)
    return scans


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")
    p_start = span.get("perpendicularFrom")
    p_end = span.get("perpendicularTo")
    if not all(isinstance(value, int) for value in (start, end, p_start, p_end)):
        return []
    if axis == "x":
        points = [
            ("span min/min", start, p_start),
            ("span max/min", end, p_start),
            ("span min/max", start, p_end),
            ("span max/max", end, p_end),
        ]
    elif axis == "y":
        points = [
            ("span min/min", p_start, start),
            ("span max/min", p_end, start),
            ("span min/max", p_start, end),
            ("span max/max", p_end, end),
        ]
    else:
        return []
    seen = set()
    rows = []
    for label, x, y in points:
        if (x, y) in seen:
            continue
        seen.add((x, y))
        rows.append((label, x, y))
    return rows


def scan_candidate(
    exe: bytes,
    sections: list[dict],
    selector_ranges: list[dict],
    scene_events: list[dict],
    event_targets: dict[int, list[str]],
    scene_manifest: list[dict],
    candidate: dict,
) -> dict:
    sample = candidate.get("sample") or {}
    side = candidate.get("side")
    x = int(sample["x"])
    y = int(sample["y"])
    scans = scan_byte_pair_variants(
        exe,
        sections,
        selector_ranges,
        scene_events,
        event_targets,
        scene_manifest,
        str(side),
        x,
        y,
    )
    target_hint = candidate.get("targetHint") or {}
    target_spawn_scans = []
    if isinstance(target_hint.get("x"), int) and isinstance(target_hint.get("y"), int):
        target_spawn_scans = scan_byte_pair_variants(
            exe,
            sections,
            selector_ranges,
            scene_events,
            event_targets,
            scene_manifest,
            str(target_hint.get("side") or "target"),
            int(target_hint["x"]),
            int(target_hint["y"]),
            "target spawn ",
        )
        for row in target_spawn_scans:
            row["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"),
            }

    axis_sequences = []
    axis_points = side_axis_points(str(side), x, y)
    for order in ("xy", "yx"):
        parts = []
        for px, py in axis_points:
            pair = byte_point(px, py) if order == "xy" else byte_point(py, px)
            if pair is None:
                parts = []
                break
            parts.append(pair)
        pattern = b"".join(parts)
        if pattern:
            axis_sequences.append(
                scan_pattern(
                    exe,
                    sections,
                    selector_ranges,
                    scene_events,
                    event_targets,
                    scene_manifest,
                    f"axis 3-point byte sequence {order}",
                    pattern,
                )
            )
        else:
            axis_sequences.append({
                "label": f"axis 3-point byte sequence {order}",
                "patternHex": "-",
                "hitCount": 0,
                "skippedOutOfByteRange": True,
                "samples": [],
            })

    span_sequences = []
    corners = span_corner_points(candidate)
    if len(corners) >= 2:
        for order in ("xy", "yx"):
            parts = []
            for _label, px, py in corners:
                pair = byte_point(px, py) if order == "xy" else byte_point(py, px)
                if pair is None:
                    parts = []
                    break
                parts.append(pair)
            pattern = b"".join(parts)
            if pattern:
                span_sequences.append(
                    scan_pattern(
                        exe,
                        sections,
                        selector_ranges,
                        scene_events,
                        event_targets,
                        scene_manifest,
                        f"span corner byte sequence {order}",
                        pattern,
                    )
                )
            else:
                span_sequences.append({
                    "label": f"span corner byte sequence {order}",
                    "patternHex": "-",
                    "hitCount": 0,
                    "skippedOutOfByteRange": True,
                    "samples": [],
                })

    all_scans = [*scans, *axis_sequences, *span_sequences]
    strict_source_target_hits = sum(row.get("strictSourceTargetHitCount", 0) for row in all_scans)
    current_selector_hits = sum(row.get("currentSelectorRootHitCount", 0) for row in all_scans)
    strict_event_other_hits = sum(row.get("strictEventOtherHitCount", 0) for row in all_scans)
    text_code_hits = sum(row.get("textCodeHitCount", 0) for row in all_scans)
    sequence_hit_count = sum(row.get("hitCount", 0) for row in [*axis_sequences, *span_sequences])
    return {
        "side": side,
        "tile": {"x": x, "y": y},
        "span": candidate.get("span") or {},
        "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"),
        } if target_hint else {},
        "bytePairScans": scans,
        "targetSpawnBytePairScans": target_spawn_scans,
        "axisSequenceScans": axis_sequences,
        "spanSequenceScans": span_sequences,
        "bytePairHitCount": sum(row.get("hitCount", 0) for row in scans),
        "targetSpawnBytePairScanCount": len(target_spawn_scans),
        "targetSpawnBytePairHitCount": sum(row.get("hitCount", 0) for row in target_spawn_scans),
        "targetSpawnStrictSourceTargetByteHitCount": sum(
            row.get("strictSourceTargetHitCount", 0) for row in target_spawn_scans
        ),
        "targetSpawnStrictEventOtherByteHitCount": sum(
            row.get("strictEventOtherHitCount", 0) for row in target_spawn_scans
        ),
        "targetSpawnCurrentSelectorRootByteHitCount": sum(
            row.get("currentSelectorRootHitCount", 0) for row in target_spawn_scans
        ),
        "targetSpawnTextCodeByteHitCount": sum(row.get("textCodeHitCount", 0) for row in target_spawn_scans),
        "targetSpawnStrictByteCoordinateEvidenceFound": any(
            row.get("strictSourceTargetHitCount", 0) > 0 for row in target_spawn_scans
        ),
        "sequenceHitCount": sequence_hit_count,
        "strictSourceTargetByteHitCount": strict_source_target_hits,
        "strictEventOtherByteHitCount": strict_event_other_hits,
        "currentSelectorRootByteHitCount": current_selector_hits,
        "textCodeByteHitCount": text_code_hits,
        "strictByteCoordinateEvidenceFound": strict_source_target_hits > 0,
    }


def build_summary(
    exe_path: Path = EXE,
    save_scene_selectors: list[dict] | None = None,
    scene_events: list[dict] | None = None,
    event_transitions: list[dict] | None = None,
    scene_manifest: list[dict] | None = None,
    map_exit_candidates: list[dict] | None = None,
) -> dict:
    exe = exe_path.read_bytes()
    sections = read_sections(exe)
    save_scene_selectors = save_scene_selectors if save_scene_selectors is not None else load_json(
        OUT / "save_scene_selectors.json",
        [],
    )
    scene_events = scene_events if scene_events is not None else load_json(OUT / "scene_events.json", [])
    event_transitions = event_transitions if event_transitions is not None else load_json(
        OUT / "event_transitions.json",
        [],
    )
    scene_manifest = scene_manifest if scene_manifest is not None else load_json(OUT / "scene_manifest.json", [])
    map_exit_candidates = map_exit_candidates if map_exit_candidates is not None else load_json(
        OUT / "map_exit_candidates.json",
        [],
    )
    selector_ranges = selector_owner_ranges(save_scene_selectors)
    event_targets = transition_targets_by_record(event_transitions)
    candidates = exit_candidates(map_exit_candidates)
    candidate_summaries = [
        scan_candidate(
            exe,
            sections,
            selector_ranges,
            scene_events,
            event_targets,
            scene_manifest,
            candidate,
        )
        for candidate in candidates
    ]
    byte_pair_scan_count = sum(len(row["bytePairScans"]) for row in candidate_summaries)
    axis_sequence_scan_count = sum(len(row["axisSequenceScans"]) for row in candidate_summaries)
    span_sequence_scan_count = sum(len(row["spanSequenceScans"]) for row in candidate_summaries)
    strict_source_target_hit_count = sum(
        row["strictSourceTargetByteHitCount"] for row in candidate_summaries
    )
    current_selector_hit_count = sum(row["currentSelectorRootByteHitCount"] for row in candidate_summaries)
    sequence_hit_count = sum(row["sequenceHitCount"] for row in candidate_summaries)
    strict_event_other_hit_count = sum(row["strictEventOtherByteHitCount"] for row in candidate_summaries)
    text_code_hit_count = sum(row["textCodeByteHitCount"] for row in candidate_summaries)
    byte_pair_hit_count = sum(row["bytePairHitCount"] for row in candidate_summaries)
    target_spawn_byte_pair_scan_count = sum(row["targetSpawnBytePairScanCount"] for row in candidate_summaries)
    target_spawn_byte_pair_hit_count = sum(row["targetSpawnBytePairHitCount"] for row in candidate_summaries)
    target_spawn_strict_source_target_hit_count = sum(
        row["targetSpawnStrictSourceTargetByteHitCount"] for row in candidate_summaries
    )
    target_spawn_strict_event_other_hit_count = sum(
        row["targetSpawnStrictEventOtherByteHitCount"] for row in candidate_summaries
    )
    target_spawn_current_selector_hit_count = sum(
        row["targetSpawnCurrentSelectorRootByteHitCount"] for row in candidate_summaries
    )
    target_spawn_text_code_hit_count = sum(
        row["targetSpawnTextCodeByteHitCount"] for row in candidate_summaries
    )
    strict_found = strict_source_target_hit_count > 0
    target_spawn_strict_found = target_spawn_strict_source_target_hit_count > 0
    false_positive_summary = {
        "candidateCount": len(candidate_summaries),
        "bytePairScanCount": byte_pair_scan_count,
        "axisSequenceScanCount": axis_sequence_scan_count,
        "spanSequenceScanCount": span_sequence_scan_count,
        "bytePairHitCount": byte_pair_hit_count,
        "sequenceHitCount": sequence_hit_count,
        "strictSourceTargetByteHitCount": strict_source_target_hit_count,
        "strictEventOtherByteHitCount": strict_event_other_hit_count,
        "currentSelectorRootByteHitCount": current_selector_hit_count,
        "textCodeByteHitCount": text_code_hit_count,
        "strictByteCoordinateEvidenceFound": strict_found,
        "targetSpawnBytePairScanCount": target_spawn_byte_pair_scan_count,
        "targetSpawnBytePairHitCount": target_spawn_byte_pair_hit_count,
        "targetSpawnStrictSourceTargetByteHitCount": target_spawn_strict_source_target_hit_count,
        "targetSpawnStrictEventOtherByteHitCount": target_spawn_strict_event_other_hit_count,
        "targetSpawnCurrentSelectorRootByteHitCount": target_spawn_current_selector_hit_count,
        "targetSpawnTextCodeByteHitCount": target_spawn_text_code_hit_count,
        "targetSpawnStrictByteCoordinateEvidenceFound": target_spawn_strict_found,
    }
    conclusion = (
        "Byte-sized coordinate encodings do not provide a strict map1_01a -> map2_02d hotspot. "
        f"The scan covers {len(candidate_summaries)} exit candidates with {byte_pair_scan_count} exact byte-pair "
        f"patterns, {target_spawn_byte_pair_scan_count} reciprocal target-spawn byte-pair patterns, "
        f"plus {axis_sequence_scan_count + span_sequence_scan_count} byte-sequence patterns. "
        f"It found {strict_source_target_hit_count} strict source-target event-owned hit(s), "
        f"{target_spawn_strict_source_target_hit_count} target-spawn strict source-target hit(s), "
        f"{current_selector_hit_count} source current selector-root byte collision(s), "
        f"{target_spawn_current_selector_hit_count} target-spawn current selector-root byte collision(s), and "
        f"{sequence_hit_count} axis/span sequence hit(s). "
        "Without a strict source-target event owner or runtime trigger, byte-pair matches remain collision-prone "
        "diagnostics and cannot promote the route."
    )
    return {
        "source": SOURCE,
        "target": TARGET,
        "candidateCount": len(candidate_summaries),
        "bytePairScanCount": byte_pair_scan_count,
        "axisSequenceScanCount": axis_sequence_scan_count,
        "spanSequenceScanCount": span_sequence_scan_count,
        "bytePairHitCount": byte_pair_hit_count,
        "sequenceHitCount": sequence_hit_count,
        "strictSourceTargetByteHitCount": strict_source_target_hit_count,
        "strictEventOtherByteHitCount": strict_event_other_hit_count,
        "currentSelectorRootByteHitCount": current_selector_hit_count,
        "textCodeByteHitCount": text_code_hit_count,
        "strictByteCoordinateEvidenceFound": strict_found,
        "targetSpawnBytePairScanCount": target_spawn_byte_pair_scan_count,
        "targetSpawnBytePairHitCount": target_spawn_byte_pair_hit_count,
        "targetSpawnStrictSourceTargetByteHitCount": target_spawn_strict_source_target_hit_count,
        "targetSpawnStrictEventOtherByteHitCount": target_spawn_strict_event_other_hit_count,
        "targetSpawnCurrentSelectorRootByteHitCount": target_spawn_current_selector_hit_count,
        "targetSpawnTextCodeByteHitCount": target_spawn_text_code_hit_count,
        "targetSpawnStrictByteCoordinateEvidenceFound": target_spawn_strict_found,
        "promotionStatus": "ready-for-review" if strict_found else "blocked",
        "proofFound": strict_found,
        "exitByteCoordinateProofFound": strict_found,
        "failedExitByteCoordinateGateIds": [] if strict_found else FAILED_EXIT_BYTE_COORDINATE_GATE_IDS,
        "missingEvidence": [] if strict_found else EXIT_BYTE_COORDINATE_MISSING_EVIDENCE,
        "evidenceRefs": EXIT_BYTE_COORDINATE_EVIDENCE_REFS,
        "evidenceRefCount": len(EXIT_BYTE_COORDINATE_EVIDENCE_REFS),
        "candidateSummaries": candidate_summaries,
        "falsePositiveSummary": false_positive_summary,
        "conclusion": conclusion,
    }


def html_page(summary: dict) -> str:
    candidate_rows = []
    for row in summary["candidateSummaries"]:
        tile = row["tile"]
        target_spawn = row.get("targetSpawn") or {}
        target_spawn_label = (
            f"{target_spawn.get('target')} {target_spawn.get('side')} "
            f"{target_spawn.get('x')},{target_spawn.get('y')}"
            if target_spawn
            else "-"
        )
        candidate_rows.append(
            "<tr>"
            f"<td>{html.escape(str(row['side']))}</td>"
            f"<td><code>{tile['x']},{tile['y']}</code></td>"
            f"<td><code>{html.escape(target_spawn_label)}</code></td>"
            f"<td>{row['bytePairHitCount']}</td>"
            f"<td>{row['targetSpawnBytePairHitCount']}</td>"
            f"<td>{row['sequenceHitCount']}</td>"
            f"<td>{row['strictSourceTargetByteHitCount']}</td>"
            f"<td>{row['targetSpawnStrictSourceTargetByteHitCount']}</td>"
            f"<td>{row['currentSelectorRootByteHitCount']}</td>"
            f"<td>{row['targetSpawnCurrentSelectorRootByteHitCount']}</td>"
            f"<td>{row['strictByteCoordinateEvidenceFound']}</td>"
            f"<td>{row['targetSpawnStrictByteCoordinateEvidenceFound']}</td>"
            "</tr>"
        )
    scan_rows = []
    for candidate in summary["candidateSummaries"]:
        tile = candidate["tile"]
        label = f"{candidate['side']} {tile['x']},{tile['y']}"
        target_spawn = candidate.get("targetSpawn") or {}
        target_label = (
            f"target {target_spawn.get('target')} {target_spawn.get('side')} "
            f"{target_spawn.get('x')},{target_spawn.get('y')}"
            if target_spawn
            else "target -"
        )
        for row in [
            *candidate["bytePairScans"],
            *candidate["axisSequenceScans"],
            *candidate["spanSequenceScans"],
        ]:
            scan_rows.append(
                "<tr>"
                f"<td>{html.escape(label)}</td>"
                f"<td>{html.escape(str(row.get('label')))}</td>"
                f"<td><code>{html.escape(str(row.get('patternHex')))}</code></td>"
                f"<td>{row.get('hitCount')}</td>"
                f"<td>{row.get('strictSourceTargetHitCount', 0)}</td>"
                f"<td>{row.get('strictEventOtherHitCount', 0)}</td>"
                f"<td>{row.get('currentSelectorRootHitCount', 0)}</td>"
                f"<td>{row.get('textCodeHitCount', 0)}</td>"
                "</tr>"
            )
        for row in candidate["targetSpawnBytePairScans"]:
            scan_rows.append(
                "<tr>"
                f"<td>{html.escape(label)}</td>"
                f"<td>{html.escape(target_label + ' ' + str(row.get('label')))}</td>"
                f"<td><code>{html.escape(str(row.get('patternHex')))}</code></td>"
                f"<td>{row.get('hitCount')}</td>"
                f"<td>{row.get('strictSourceTargetHitCount', 0)}</td>"
                f"<td>{row.get('strictEventOtherHitCount', 0)}</td>"
                f"<td>{row.get('currentSelectorRootHitCount', 0)}</td>"
                f"<td>{row.get('textCodeHitCount', 0)}</td>"
                "</tr>"
            )
    return "\n".join([
        "<!doctype html>",
        '<meta charset="utf-8">',
        "<title>map1_01a Exit Byte Coordinate Scan</title>",
        "<style>body{font-family:system-ui,sans-serif;background:#111;color:#ddd;margin:24px}table{border-collapse:collapse;width:100%;margin:16px 0}td,th{border:1px solid #333;padding:6px 8px;text-align:left;vertical-align:top}th{background:#1f1f1f}code{color:#ffd27a}</style>",
        "<h1>map1_01a Exit Byte Coordinate Scan</h1>",
        f"<p>Route: <code>{html.escape(summary['source'])} -&gt; {html.escape(summary['target'])}</code></p>",
        "<ul>",
        f"<li>byte-pair scans: <code>{summary['bytePairScanCount']}</code></li>",
        f"<li>target-spawn byte-pair scans: <code>{summary['targetSpawnBytePairScanCount']}</code></li>",
        f"<li>sequence hits: <code>{summary['sequenceHitCount']}</code></li>",
        f"<li>strict source-target byte hits: <code>{summary['strictSourceTargetByteHitCount']}</code></li>",
        f"<li>target-spawn strict source-target byte hits: <code>{summary['targetSpawnStrictSourceTargetByteHitCount']}</code></li>",
        f"<li>current selector-root byte hits: <code>{summary['currentSelectorRootByteHitCount']}</code></li>",
        f"<li>target-spawn current selector-root byte hits: <code>{summary['targetSpawnCurrentSelectorRootByteHitCount']}</code></li>",
        f"<li>strict byte coordinate evidence found: <code>{summary['strictByteCoordinateEvidenceFound']}</code></li>",
        f"<li>target-spawn strict byte coordinate evidence found: <code>{summary['targetSpawnStrictByteCoordinateEvidenceFound']}</code></li>",
        f"<li>promotion status: <code>{html.escape(str(summary['promotionStatus']))}</code></li>",
        f"<li>proof found: <code>{html.escape(str(summary.get('proofFound')))}</code></li>",
        f"<li>failed exit byte-coordinate gates: <code>{html.escape(','.join(summary.get('failedExitByteCoordinateGateIds') or []) or '-')}</code></li>",
        f"<li>evidence refs: <code>{html.escape(str(summary.get('evidenceRefCount')))}</code></li>",
        "</ul>",
        f"<p>{html.escape(summary['conclusion'])}</p>",
        "<h2>Missing Evidence</h2>",
        "<ul>",
        "\n".join(f"<li>{html.escape(item)}</li>" for item in summary.get("missingEvidence") or []),
        "</ul>",
        "<h2>Evidence Refs</h2>",
        "<ul>",
        "\n".join(
            f"<li><code>{html.escape(str(ref.get('path')))}</code>: "
            f"{html.escape(', '.join(ref.get('fields') or []))}</li>"
            for ref in summary.get("evidenceRefs") or []
        ),
        "</ul>",
        "<table><thead><tr><th>side</th><th>tile</th><th>target spawn</th><th>byte hits</th><th>target byte hits</th><th>sequence hits</th><th>strict source-target</th><th>target strict source-target</th><th>current root</th><th>target current root</th><th>strict evidence</th><th>target strict evidence</th></tr></thead><tbody>",
        "\n".join(candidate_rows),
        "</tbody></table>",
        "<h2>Scan Rows</h2>",
        "<table><thead><tr><th>candidate</th><th>pattern</th><th>bytes</th><th>hits</th><th>strict source-target</th><th>strict other</th><th>current root</th><th>text code</th></tr></thead><tbody>",
        "\n".join(scan_rows),
        "</tbody></table>",
    ])


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


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()

    summary = build_summary(args.exe)
    write_outputs(summary, args.out_dir)
    print(
        "wrote byte-coordinate scan: "
        f"{summary['candidateCount']} candidates, "
        f"{summary['strictSourceTargetByteHitCount']} strict source-target hits -> "
        f"{args.out_dir / 'map1_01a_exit_byte_coordinate_scan.json'}"
    )


if __name__ == "__main__":
    main()
