#!/usr/bin/env python3
"""Scan decoded map1_01a CNS tilemap payload for exit-coordinate encodings."""
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 decode_cns import decompress_cns


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
SOURCE = "map1_01a"
TARGET = "map2_02d"
SOURCE_CNS = ROOT / "extract_fld" / f"{SOURCE}.cns"
TARGET_CNS = ROOT / "extract_fld" / f"{TARGET}.cns"
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 = 16
FAILED_EXIT_CNS_PAYLOAD_GATE_IDS = [
    "separate-cns-coordinate-payload",
    "outside-structured-tilemap-hit",
    "strict-source-target-cns-owner",
    "runtime-trigger-proof",
]
EXIT_CNS_PAYLOAD_MISSING_EVIDENCE = [
    "separate CNS coordinate payload outside the 37x48 two-layer tilemap",
    "coordinate-like hit outside the structured tilemap body",
    "strict map1_01a -> map2_02d owner for a CNS coordinate match",
    "runtime trigger proving a CNS payload match selects map2_02d",
]
EXIT_CNS_PAYLOAD_EVIDENCE_REFS = [
    {"path": "extract_fld/map1_01a.cns", "fields": ["decoded tilemap payload", "coordinate scans"]},
    {"path": "extract_fld/map2_02d.cns", "fields": ["target CNS payload summary"]},
    {"path": "out/map_exit_candidates.json", "fields": ["exitCandidates", "blockedTargetCandidates"]},
]


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


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


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


def parse_tilemap(decoded: bytes) -> dict:
    if len(decoded) < 4:
        raise ValueError("decoded CNS payload is too short for a tilemap header")
    width = int.from_bytes(decoded[0:2], "little")
    height = int.from_bytes(decoded[2:4], "little")
    tile_count = width * height
    layer0_offset = 4
    layer1_offset = layer0_offset + tile_count * 2
    expected_size = layer1_offset + tile_count * 2
    if width <= 0 or height <= 0 or expected_size != len(decoded):
        raise ValueError(
            f"decoded CNS payload is not a 2-layer u16 tilemap: "
            f"{width}x{height}, expected {expected_size}, got {len(decoded)}"
        )
    return {
        "width": width,
        "height": height,
        "tileCount": tile_count,
        "decodedSize": len(decoded),
        "headerOffset": 0,
        "headerSize": 4,
        "layer0Offset": layer0_offset,
        "layer1Offset": layer1_offset,
        "layerByteSize": tile_count * 2,
        "structuredPayloadEnd": expected_size,
    }


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 []),
            ]
            x = sample.get("x")
            y = sample.get("y")
            if TARGET in targets and isinstance(x, int) and isinstance(y, int):
                rows.append(candidate)
    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 word_point(x: int, y: int, order: str) -> bytes | None:
    if not (0 <= x <= 0xFFFF and 0 <= y <= 0xFFFF):
        return None
    if order == "xy":
        return struct.pack("<HH", x, y)
    return struct.pack("<HH", y, x)


def packed_u32_point(x: int, y: int, order: str) -> bytes | None:
    if not (0 <= x <= 0xFFFF and 0 <= y <= 0xFFFF):
        return None
    value = (y << 16) | x if order == "xy" else (x << 16) | y
    return struct.pack("<I", value)


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 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 classify_offset(tilemap: dict, offset: int, length: int) -> dict:
    header_end = tilemap["headerSize"]
    layer0_start = tilemap["layer0Offset"]
    layer1_start = tilemap["layer1Offset"]
    structured_end = tilemap["structuredPayloadEnd"]
    end = offset + length
    if offset < 0 or offset >= structured_end:
        return {
            "section": "outside-structured-map-payload",
            "classification": "outside-structured-map-payload",
            "crossesSection": False,
        }
    if offset < header_end:
        section = "header"
        section_end = header_end
        classification = "tilemap-header-byte-collision"
    elif offset < layer1_start:
        section = "layer0"
        section_end = layer1_start
        aligned = (offset - layer0_start) % 2 == 0
        classification = (
            "layer0-aligned-tile-index-collision"
            if aligned
            else "layer0-byte-overlap-collision"
        )
    else:
        section = "layer1"
        section_end = structured_end
        aligned = (offset - layer1_start) % 2 == 0
        classification = (
            "layer1-aligned-tile-index-collision"
            if aligned
            else "layer1-byte-overlap-collision"
        )
    if end > section_end:
        classification = f"{section}-boundary-crossing-collision"
    return {
        "section": section,
        "classification": classification,
        "crossesSection": end > section_end,
    }


def tile_context(decoded: bytes, tilemap: dict, offset: int) -> dict:
    layer0_start = tilemap["layer0Offset"]
    layer1_start = tilemap["layer1Offset"]
    structured_end = tilemap["structuredPayloadEnd"]
    if layer0_start <= offset < layer1_start:
        layer = 0
        rel = offset - layer0_start
    elif layer1_start <= offset < structured_end:
        layer = 1
        rel = offset - layer1_start
    else:
        return {}
    tile_index = rel // 2
    aligned = rel % 2 == 0
    tile_x = tile_index % tilemap["width"]
    tile_y = tile_index // tilemap["width"]
    word_offset = (layer0_start if layer == 0 else layer1_start) + tile_index * 2
    value = None
    if word_offset + 2 <= len(decoded):
        value = int.from_bytes(decoded[word_offset:word_offset + 2], "little")
    return {
        "layer": layer,
        "alignedU16": aligned,
        "tileIndex": tile_index,
        "tile": {"x": tile_x, "y": tile_y},
        "tileValue": value,
        "tileValueHex": hex_word(value),
    }


def hit_priority(row: dict) -> tuple[int, int]:
    priority = {
        "outside-structured-map-payload": 0,
        "tilemap-header-byte-collision": 1,
        "layer0-aligned-tile-index-collision": 2,
        "layer1-aligned-tile-index-collision": 3,
        "layer0-byte-overlap-collision": 4,
        "layer1-byte-overlap-collision": 5,
    }.get(str(row.get("classification")), 6)
    return priority, int(row.get("offset") or 0)


def scan_pattern(decoded: bytes, tilemap: dict, label: str, encoding: str, pattern: bytes) -> dict:
    section_counts: dict[str, int] = {}
    classification_counts: dict[str, int] = {}
    aligned_u16_count = 0
    aligned_u32_count = 0
    hit_count = 0
    samples: list[dict] = []
    search = 0
    while True:
        hit = decoded.find(pattern, search)
        if hit < 0:
            break
        search = hit + 1
        classified = classify_offset(tilemap, hit, len(pattern))
        section = classified["section"]
        classification = classified["classification"]
        section_counts[section] = section_counts.get(section, 0) + 1
        classification_counts[classification] = classification_counts.get(classification, 0) + 1
        if hit % 2 == 0:
            aligned_u16_count += 1
        if hit % 4 == 0:
            aligned_u32_count += 1
        hit_count += 1
        context = {
            "offset": hit,
            "offsetHex": f"0x{hit:04x}",
            "section": section,
            "classification": classification,
            "crossesSection": classified["crossesSection"],
            "alignedU16Offset": hit % 2 == 0,
            "alignedU32Offset": hit % 4 == 0,
            "tileContext": tile_context(decoded, tilemap, hit),
            "contextHex": hex_bytes(decoded[max(0, hit - 8): min(len(decoded), hit + len(pattern) + 8)]),
        }
        if len(samples) < SAMPLE_LIMIT:
            samples.append(context)
        elif hit_priority(context) < max(hit_priority(item) for item in samples):
            worst = max(range(len(samples)), key=lambda index: hit_priority(samples[index]))
            samples[worst] = context
    return {
        "label": label,
        "encoding": encoding,
        "patternHex": hex_bytes(pattern),
        "hitCount": hit_count,
        "alignedU16HitCount": aligned_u16_count,
        "alignedU32HitCount": aligned_u32_count,
        "sectionCounts": dict(sorted(section_counts.items())),
        "classificationCounts": dict(sorted(classification_counts.items())),
        "outsideStructuredHitCount": classification_counts.get("outside-structured-map-payload", 0),
        "headerHitCount": section_counts.get("header", 0),
        "layerHitCount": section_counts.get("layer0", 0) + section_counts.get("layer1", 0),
        "samples": sorted(samples, key=hit_priority),
    }


def skipped_scan(label: str, encoding: str) -> dict:
    return {
        "label": label,
        "encoding": encoding,
        "patternHex": "-",
        "hitCount": 0,
        "alignedU16HitCount": 0,
        "alignedU32HitCount": 0,
        "sectionCounts": {},
        "classificationCounts": {},
        "outsideStructuredHitCount": 0,
        "headerHitCount": 0,
        "layerHitCount": 0,
        "samples": [],
        "skippedOutOfRange": True,
    }


def coordinate_variants(candidate: dict) -> list[tuple[str, int, int, str]]:
    sample = candidate.get("sample") or {}
    side = str(candidate.get("side") or "")
    x = int(sample["x"])
    y = int(sample["y"])
    dx, dy = SIDE_VECTOR.get(side, (0, 0))
    outside_x = x + dx
    outside_y = y + dy
    return [
        ("tile", x, y, "xy"),
        ("tile", x, y, "yx"),
        ("one-based", x + 1, y + 1, "xy"),
        ("one-based", x + 1, y + 1, "yx"),
        ("outside", outside_x, outside_y, "xy"),
        ("outside", outside_x, outside_y, "yx"),
    ]


def scan_coordinate_patterns(decoded: bytes, tilemap: dict, candidate: dict) -> tuple[list[dict], list[dict], list[dict]]:
    byte_scans = []
    word_scans = []
    packed_scans = []
    for kind, x, y, order in coordinate_variants(candidate):
        byte_pattern = byte_point(x, y) if order == "xy" else byte_point(y, x)
        byte_label = f"{kind} byte {order}"
        if byte_pattern is None:
            byte_scans.append(skipped_scan(byte_label, "byte-pair"))
        else:
            row = scan_pattern(decoded, tilemap, byte_label, "byte-pair", byte_pattern)
            row.update({"point": {"x": x, "y": y}, "packedOrder": order})
            byte_scans.append(row)

        word_pattern = word_point(x, y, order)
        word_label = f"{kind} word-pair {order}"
        if word_pattern is None:
            word_scans.append(skipped_scan(word_label, "u16-pair"))
        else:
            row = scan_pattern(decoded, tilemap, word_label, "u16-pair", word_pattern)
            row.update({"point": {"x": x, "y": y}, "packedOrder": order})
            word_scans.append(row)

        packed_pattern = packed_u32_point(x, y, order)
        packed_label = f"{kind} packed-u32 {order}"
        if packed_pattern is None:
            packed_scans.append(skipped_scan(packed_label, "packed-u32"))
        else:
            row = scan_pattern(decoded, tilemap, packed_label, "packed-u32", packed_pattern)
            row.update({"point": {"x": x, "y": y}, "packedOrder": order})
            packed_scans.append(row)
    return byte_scans, word_scans, packed_scans


def scan_sequence_patterns(decoded: bytes, tilemap: dict, candidate: dict) -> tuple[list[dict], list[dict]]:
    sample = candidate.get("sample") or {}
    side = str(candidate.get("side") or "")
    x = int(sample["x"])
    y = int(sample["y"])
    byte_sequences = []
    word_sequences = []
    sequence_specs = [
        ("axis 3-point", [(None, px, py) for px, py in side_axis_points(side, x, y)]),
        ("span corner", span_corner_points(candidate)),
    ]
    for label_prefix, points in sequence_specs:
        if not points:
            for order in ("xy", "yx"):
                byte_sequences.append(skipped_scan(f"{label_prefix} byte sequence {order}", "byte-sequence"))
                word_sequences.append(skipped_scan(f"{label_prefix} word sequence {order}", "u16-sequence"))
            continue
        for order in ("xy", "yx"):
            byte_parts = []
            word_parts = []
            for _label, px, py in points:
                byte_part = byte_point(px, py) if order == "xy" else byte_point(py, px)
                word_part = word_point(px, py, order)
                if byte_part is None:
                    byte_parts = []
                if word_part is None:
                    word_parts = []
                if byte_parts is not None and byte_part is not None:
                    byte_parts.append(byte_part)
                if word_parts is not None and word_part is not None:
                    word_parts.append(word_part)
            if byte_parts:
                byte_sequences.append(
                    scan_pattern(
                        decoded,
                        tilemap,
                        f"{label_prefix} byte sequence {order}",
                        "byte-sequence",
                        b"".join(byte_parts),
                    )
                )
            else:
                byte_sequences.append(skipped_scan(f"{label_prefix} byte sequence {order}", "byte-sequence"))
            if word_parts:
                word_sequences.append(
                    scan_pattern(
                        decoded,
                        tilemap,
                        f"{label_prefix} word sequence {order}",
                        "u16-sequence",
                        b"".join(word_parts),
                    )
                )
            else:
                word_sequences.append(skipped_scan(f"{label_prefix} word sequence {order}", "u16-sequence"))
    return byte_sequences, word_sequences


def sum_key(rows: list[dict], key: str) -> int:
    return sum(int(row.get(key) or 0) for row in rows)


def merge_counts(rows: list[dict], key: str) -> dict[str, int]:
    counts: dict[str, int] = {}
    for row in rows:
        for label, count in (row.get(key) or {}).items():
            counts[label] = counts.get(label, 0) + int(count or 0)
    return dict(sorted(counts.items()))


def scan_candidate(decoded: bytes, tilemap: dict, candidate: dict) -> dict:
    sample = candidate.get("sample") or {}
    side = candidate.get("side")
    x = int(sample["x"])
    y = int(sample["y"])
    byte_scans, word_scans, packed_scans = scan_coordinate_patterns(decoded, tilemap, candidate)
    byte_sequences, word_sequences = scan_sequence_patterns(decoded, tilemap, candidate)
    all_scans = [*byte_scans, *word_scans, *packed_scans, *byte_sequences, *word_sequences]
    outside_hit_count = sum_key(all_scans, "outsideStructuredHitCount")
    return {
        "side": side,
        "tile": {"x": x, "y": y},
        "span": candidate.get("span") or {},
        "bytePairScans": byte_scans,
        "wordPairScans": word_scans,
        "packedU32Scans": packed_scans,
        "byteSequenceScans": byte_sequences,
        "wordSequenceScans": word_sequences,
        "bytePairHitCount": sum_key(byte_scans, "hitCount"),
        "wordPairHitCount": sum_key(word_scans, "hitCount"),
        "packedU32HitCount": sum_key(packed_scans, "hitCount"),
        "byteSequenceHitCount": sum_key(byte_sequences, "hitCount"),
        "wordSequenceHitCount": sum_key(word_sequences, "hitCount"),
        "outsideStructuredHitCount": outside_hit_count,
        "headerHitCount": sum_key(all_scans, "headerHitCount"),
        "layerHitCount": sum_key(all_scans, "layerHitCount"),
        "classificationCounts": merge_counts(all_scans, "classificationCounts"),
        "strictCnsCoordinateEvidenceFound": outside_hit_count > 0,
    }


def cns_payload_summary(path: Path) -> dict:
    decoded = decompress_cns(path.read_bytes())
    tilemap = parse_tilemap(decoded)
    return {
        "path": str(path.relative_to(ROOT)),
        "decodedSize": tilemap["decodedSize"],
        "width": tilemap["width"],
        "height": tilemap["height"],
        "tileCount": tilemap["tileCount"],
        "headerSize": tilemap["headerSize"],
        "layer0Offset": tilemap["layer0Offset"],
        "layer1Offset": tilemap["layer1Offset"],
        "layerByteSize": tilemap["layerByteSize"],
        "structuredPayloadEnd": tilemap["structuredPayloadEnd"],
    }


def build_summary(
    source_cns_path: Path = SOURCE_CNS,
    target_cns_path: Path = TARGET_CNS,
    map_exit_candidates: list[dict] | None = None,
) -> dict:
    source_decoded = decompress_cns(source_cns_path.read_bytes())
    source_tilemap = parse_tilemap(source_decoded)
    map_exit_candidates = map_exit_candidates if map_exit_candidates is not None else load_json(
        OUT / "map_exit_candidates.json",
        [],
    )
    candidates = exit_candidates(map_exit_candidates)
    candidate_summaries = [
        scan_candidate(source_decoded, source_tilemap, candidate)
        for candidate in candidates
    ]
    all_candidate_scans = []
    for row in candidate_summaries:
        all_candidate_scans.extend(row.get("bytePairScans") or [])
        all_candidate_scans.extend(row.get("wordPairScans") or [])
        all_candidate_scans.extend(row.get("packedU32Scans") or [])
        all_candidate_scans.extend(row.get("byteSequenceScans") or [])
        all_candidate_scans.extend(row.get("wordSequenceScans") or [])
    byte_pair_scan_count = sum(len(row.get("bytePairScans") or []) for row in candidate_summaries)
    word_pair_scan_count = sum(len(row.get("wordPairScans") or []) for row in candidate_summaries)
    packed_u32_scan_count = sum(len(row.get("packedU32Scans") or []) for row in candidate_summaries)
    byte_sequence_scan_count = sum(len(row.get("byteSequenceScans") or []) for row in candidate_summaries)
    word_sequence_scan_count = sum(len(row.get("wordSequenceScans") or []) for row in candidate_summaries)
    outside_hit_count = sum(row.get("outsideStructuredHitCount", 0) for row in candidate_summaries)
    header_hit_count = sum(row.get("headerHitCount", 0) for row in candidate_summaries)
    layer_hit_count = sum(row.get("layerHitCount", 0) for row in candidate_summaries)
    strict_found = outside_hit_count > 0
    false_positive_summary = {
        "candidateCount": len(candidate_summaries),
        "bytePairScanCount": byte_pair_scan_count,
        "wordPairScanCount": word_pair_scan_count,
        "packedU32ScanCount": packed_u32_scan_count,
        "byteSequenceScanCount": byte_sequence_scan_count,
        "wordSequenceScanCount": word_sequence_scan_count,
        "bytePairHitCount": sum(row.get("bytePairHitCount", 0) for row in candidate_summaries),
        "wordPairHitCount": sum(row.get("wordPairHitCount", 0) for row in candidate_summaries),
        "packedU32HitCount": sum(row.get("packedU32HitCount", 0) for row in candidate_summaries),
        "byteSequenceHitCount": sum(row.get("byteSequenceHitCount", 0) for row in candidate_summaries),
        "wordSequenceHitCount": sum(row.get("wordSequenceHitCount", 0) for row in candidate_summaries),
        "headerHitCount": header_hit_count,
        "layerHitCount": layer_hit_count,
        "outsideStructuredHitCount": outside_hit_count,
        "classificationCounts": merge_counts(all_candidate_scans, "classificationCounts"),
        "strictCnsCoordinateEvidenceFound": strict_found,
    }
    conclusion = (
        "Decoded map1_01a.cns does not contain a separate strict exit-coordinate payload for map1_01a -> map2_02d. "
        f"The file is exactly a {source_tilemap['width']}x{source_tilemap['height']} two-layer u16 tilemap "
        f"({source_tilemap['decodedSize']} bytes), so coordinate-like hits are confined to the header or layer tile-index bytes. "
        f"Across {len(candidate_summaries)} route candidates the scan found {outside_hit_count} hit(s) outside the structured "
        "tilemap payload. CNS payload matches remain tile-index collisions, not promotable hotspot evidence."
    )
    return {
        "source": SOURCE,
        "target": TARGET,
        "sourceCns": cns_payload_summary(source_cns_path),
        "targetCns": cns_payload_summary(target_cns_path),
        "candidateCount": len(candidate_summaries),
        "bytePairScanCount": byte_pair_scan_count,
        "wordPairScanCount": word_pair_scan_count,
        "packedU32ScanCount": packed_u32_scan_count,
        "byteSequenceScanCount": byte_sequence_scan_count,
        "wordSequenceScanCount": word_sequence_scan_count,
        "bytePairHitCount": false_positive_summary["bytePairHitCount"],
        "wordPairHitCount": false_positive_summary["wordPairHitCount"],
        "packedU32HitCount": false_positive_summary["packedU32HitCount"],
        "byteSequenceHitCount": false_positive_summary["byteSequenceHitCount"],
        "wordSequenceHitCount": false_positive_summary["wordSequenceHitCount"],
        "headerHitCount": header_hit_count,
        "layerHitCount": layer_hit_count,
        "outsideStructuredHitCount": outside_hit_count,
        "classificationCounts": false_positive_summary["classificationCounts"],
        "strictCnsCoordinateEvidenceFound": strict_found,
        "promotionStatus": "ready-for-review" if strict_found else "blocked",
        "proofFound": strict_found,
        "exitCnsPayloadCoordinateProofFound": strict_found,
        "failedExitCnsPayloadGateIds": [] if strict_found else FAILED_EXIT_CNS_PAYLOAD_GATE_IDS,
        "missingEvidence": [] if strict_found else EXIT_CNS_PAYLOAD_MISSING_EVIDENCE,
        "evidenceRefs": EXIT_CNS_PAYLOAD_EVIDENCE_REFS,
        "evidenceRefCount": len(EXIT_CNS_PAYLOAD_EVIDENCE_REFS),
        "candidateSummaries": candidate_summaries,
        "falsePositiveSummary": false_positive_summary,
        "conclusion": conclusion,
    }


def html_page(summary: dict) -> str:
    candidate_rows = "\n".join(
        "<tr>"
        f"<td>{html.escape(str(row.get('side')))}</td>"
        f"<td><code>{html.escape(str((row.get('tile') or {}).get('x')))},"
        f"{html.escape(str((row.get('tile') or {}).get('y')))}</code></td>"
        f"<td>{html.escape(str(row.get('bytePairHitCount')))}</td>"
        f"<td>{html.escape(str(row.get('wordPairHitCount')))}</td>"
        f"<td>{html.escape(str(row.get('packedU32HitCount')))}</td>"
        f"<td>{html.escape(str(row.get('byteSequenceHitCount')))}</td>"
        f"<td>{html.escape(str(row.get('wordSequenceHitCount')))}</td>"
        f"<td>{html.escape(str(row.get('headerHitCount')))}</td>"
        f"<td>{html.escape(str(row.get('layerHitCount')))}</td>"
        f"<td>{html.escape(str(row.get('outsideStructuredHitCount')))}</td>"
        f"<td>{html.escape(str(row.get('strictCnsCoordinateEvidenceFound')))}</td>"
        "</tr>"
        for row in summary.get("candidateSummaries") or []
    )
    classification_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(str(key))}</code></td>"
        f"<td>{html.escape(str(value))}</td>"
        "</tr>"
        for key, value in (summary.get("classificationCounts") or {}).items()
    )
    source_cns = summary.get("sourceCns") or {}
    return "\n".join([
        "<!doctype html>",
        '<html lang="en">',
        "<head>",
        '  <meta charset="utf-8">',
        "  <title>map1_01a Exit CNS Payload Scan</title>",
        "  <style>body{font-family:system-ui,sans-serif;margin:24px;line-height:1.45;max-width:1200px}table{border-collapse:collapse;width:100%;margin:16px 0}td,th{border:1px solid #ddd;padding:6px 8px;text-align:left;vertical-align:top}th{background:#f5f5f5}code{white-space:nowrap}</style>",
        "</head>",
        "<body>",
        "  <h1>map1_01a Exit CNS Payload Scan</h1>",
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        (
            "  <p><b>Route:</b> "
            f"<code>{html.escape(str(summary['source']))}</code> -&gt; "
            f"<code>{html.escape(str(summary['target']))}</code>; "
            f"source CNS <code>{html.escape(str(source_cns.get('path')))}</code> "
            f"{html.escape(str(source_cns.get('width')))}x{html.escape(str(source_cns.get('height')))}; "
            f"promotion status <code>{html.escape(str(summary['promotionStatus']))}</code>.</p>"
        ),
        (
            "  <p><b>Scan:</b> byte/word/packed scans "
            f"{html.escape(str(summary['bytePairScanCount']))}/"
            f"{html.escape(str(summary['wordPairScanCount']))}/"
            f"{html.escape(str(summary['packedU32ScanCount']))}; "
            "byte/word sequence scans "
            f"{html.escape(str(summary['byteSequenceScanCount']))}/"
            f"{html.escape(str(summary['wordSequenceScanCount']))}; "
            "header/layer/outside hits "
            f"{html.escape(str(summary['headerHitCount']))}/"
            f"{html.escape(str(summary['layerHitCount']))}/"
            f"{html.escape(str(summary['outsideStructuredHitCount']))}; "
            "strict CNS coordinate evidence "
            f"{html.escape(str(summary['strictCnsCoordinateEvidenceFound']))}.</p>"
        ),
        (
            "  <p><b>proof found:</b> "
            f"{html.escape(str(summary.get('proofFound')))}; "
            "<b>failed exit CNS payload gates:</b> "
            f"<code>{html.escape(','.join(summary.get('failedExitCnsPayloadGateIds') or []) or '-')}</code>; "
            f"<b>evidence refs:</b> {html.escape(str(summary.get('evidenceRefCount')))}.</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>",
        "  <h2>Candidates</h2>",
        (
            "  <table><thead><tr><th>side</th><th>tile</th><th>byte hits</th>"
            "<th>word hits</th><th>packed hits</th><th>byte seq</th><th>word seq</th>"
            "<th>header</th><th>layer</th><th>outside</th><th>strict evidence</th></tr></thead>"
            f"<tbody>{candidate_rows}</tbody></table>"
        ),
        "  <h2>Classification Counts</h2>",
        f"  <table><thead><tr><th>classification</th><th>hits</th></tr></thead><tbody>{classification_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_cns_payload_scan.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--source-cns", type=Path, default=SOURCE_CNS)
    parser.add_argument("--target-cns", type=Path, default=TARGET_CNS)
    args = parser.parse_args()
    summary = build_summary(
        source_cns_path=args.source_cns,
        target_cns_path=args.target_cns,
        map_exit_candidates=load_json(args.out_dir / "map_exit_candidates.json", []),
    )
    write_outputs(summary, args.out_dir)
    print(
        "wrote map1_01a exit CNS payload scan -> "
        f"{args.out_dir / 'map1_01a_exit_cns_payload_scan.json'}"
    )


if __name__ == "__main__":
    main()
