#!/usr/bin/env python3
"""Scan local real savedata files for selector 2:0 route evidence."""
from __future__ import annotations

import argparse
import hashlib
import html
import json
import zipfile
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
SOURCE = "map1_01a"
TARGET = "map2_02d"
CURRENT_SELECTOR = "2:0"
EXPECTED_SAVE_SIZE = 1274
MAX_ARCHIVE_CANDIDATE_SIZE = 2 * 1024 * 1024
WORKSPACE_SURVEY_SKIP_DIRS = {".git", "out", "extract_fld", "extract_wlk", "extract_mlk", "__pycache__"}


def read_u16(data: bytes, offset: int) -> int | None:
    if offset + 2 > len(data):
        return None
    return data[offset] | (data[offset + 1] << 8)


def selector_key(group: int | None, slot: int | None) -> str:
    return f"{group}:{slot}"


def selector_map(selectors: list[dict]) -> dict[str, dict]:
    return {
        selector_key(row.get("group"), row.get("slot")): row
        for row in selectors
    }


def relative(path: Path) -> str:
    try:
        return str(path.relative_to(ROOT))
    except ValueError:
        return str(path)


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


def sha256_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def savedat_candidate_name(name: str) -> bool:
    lower = Path(name).name.lower()
    return lower.endswith(".dat")


def disk_candidate_source(path: Path) -> dict:
    data = path.read_bytes()
    return {
        "path": relative(path),
        "fileName": path.name,
        "diskPath": path,
        "archive": False,
        "sha256": sha256_bytes(data),
        "data": data,
    }


def zip_member_candidates(path: Path) -> tuple[list[dict], list[dict]]:
    sources: list[dict] = []
    skipped: list[dict] = []
    try:
        with zipfile.ZipFile(path) as archive:
            for info in archive.infolist():
                if info.is_dir() or not savedat_candidate_name(info.filename):
                    continue
                label = f"{relative(path)}::{info.filename}"
                if info.file_size > MAX_ARCHIVE_CANDIDATE_SIZE:
                    skipped.append({
                        "path": label,
                        "archivePath": relative(path),
                        "archiveMember": info.filename,
                        "size": info.file_size,
                        "reason": "archive member too large",
                    })
                    continue
                data = archive.read(info)
                sources.append({
                    "path": label,
                    "fileName": Path(info.filename).name,
                    "diskPath": path,
                    "archivePath": relative(path),
                    "archiveMember": info.filename,
                    "archive": True,
                    "sha256": sha256_bytes(data),
                    "data": data,
                })
    except zipfile.BadZipFile:
        skipped.append({
            "path": relative(path),
            "archivePath": relative(path),
            "reason": "bad zip file",
        })
    return sources, skipped


def candidate_sources(search_roots: list[Path]) -> tuple[list[dict], list[dict]]:
    disk_paths: list[Path] = []
    archive_paths: list[Path] = []
    skipped: list[dict] = []
    for root in search_roots:
        if not root.exists():
            continue
        if root.is_file():
            if savedat_candidate_name(root.name):
                disk_paths.append(root)
            if root.suffix.lower() == ".zip":
                archive_paths.append(root)
            continue
        if root == ROOT:
            disk_paths.extend(path for path in root.glob("savedat*.dat") if path.is_file())
            disk_paths.extend(path for path in root.glob("SAVEDAT*.DAT") if path.is_file())
            archive_paths.extend(path for path in root.glob("*.zip") if path.is_file())
            continue
        for path in root.rglob("*"):
            if not path.is_file():
                continue
            if savedat_candidate_name(path.name):
                disk_paths.append(path)
            if path.suffix.lower() == ".zip":
                archive_paths.append(path)

    sources = [
        disk_candidate_source(path)
        for path in sorted(set(disk_paths), key=lambda item: str(item))
    ]
    for path in sorted(set(archive_paths), key=lambda item: str(item)):
        zip_sources, zip_skipped = zip_member_candidates(path)
        sources.extend(zip_sources)
        skipped.extend(zip_skipped)
    sources.sort(key=lambda item: item["path"])
    return sources, skipped


def workspace_savedata_survey(root: Path = ROOT) -> dict:
    """Find savedata-like files outside generated/extracted output directories."""
    dat_files: list[dict] = []
    zip_members: list[dict] = []
    for path in sorted(root.rglob("*"), key=lambda item: str(item)):
        try:
            relative_path = path.relative_to(root)
        except ValueError:
            continue
        if any(part in WORKSPACE_SURVEY_SKIP_DIRS for part in relative_path.parts):
            continue
        if not path.is_file():
            continue
        lower_name = path.name.lower()
        if lower_name.endswith(".dat"):
            size = path.stat().st_size
            dat_files.append({
                "path": relative(path),
                "size": size,
                "sha256": sha256_bytes(path.read_bytes()) if size <= MAX_ARCHIVE_CANDIDATE_SIZE else None,
                "expectedSize": size == EXPECTED_SAVE_SIZE,
                "savedatName": lower_name.startswith("savedat"),
            })
            continue
        if lower_name.endswith(".zip"):
            try:
                with zipfile.ZipFile(path) as archive:
                    for info in archive.infolist():
                        if info.is_dir():
                            continue
                        member_name = Path(info.filename).name.lower()
                        if not (member_name.endswith(".dat") or member_name.startswith("savedat")):
                            continue
                        data = archive.read(info) if info.file_size <= MAX_ARCHIVE_CANDIDATE_SIZE else b""
                        zip_members.append({
                            "archivePath": relative(path),
                            "archiveMember": info.filename,
                            "size": info.file_size,
                            "sha256": sha256_bytes(data) if data else None,
                            "expectedSize": info.file_size == EXPECTED_SAVE_SIZE,
                            "savedatName": member_name.startswith("savedat"),
                        })
            except zipfile.BadZipFile:
                zip_members.append({
                    "archivePath": relative(path),
                    "archiveMember": "<bad-zip>",
                    "size": None,
                    "expectedSize": False,
                    "savedatName": False,
                })
    return {
        "skipDirs": sorted(WORKSPACE_SURVEY_SKIP_DIRS),
        "datFileCount": len(dat_files),
        "expectedSizeDatFileCount": sum(1 for row in dat_files if row.get("expectedSize")),
        "savedatNameDatFileCount": sum(1 for row in dat_files if row.get("savedatName")),
        "zipDatMemberCount": len(zip_members),
        "expectedSizeZipDatMemberCount": sum(1 for row in zip_members if row.get("expectedSize")),
        "savedatNameZipDatMemberCount": sum(1 for row in zip_members if row.get("savedatName")),
        "datFiles": dat_files,
        "zipDatMembers": zip_members,
    }


def is_synthetic(source: dict) -> bool:
    text = str(source.get("path") or "").lower()
    disk_path = source.get("diskPath")
    under_out = isinstance(disk_path, Path) and disk_path.is_relative_to(OUT)
    return "synthetic" in text or under_out


def parse_candidate(source: dict, selectors_by_key: dict[str, dict]) -> dict:
    data = source["data"]
    row = {
        "path": source["path"],
        "fileName": source["fileName"],
        "size": len(data),
        "sha256": source.get("sha256") or sha256_bytes(data),
        "expectedSize": EXPECTED_SAVE_SIZE,
        "sizeMatches": len(data) == EXPECTED_SAVE_SIZE,
        "synthetic": is_synthetic(source),
        "archive": bool(source.get("archive")),
    }
    if source.get("archive"):
        row["archivePath"] = source.get("archivePath")
        row["archiveMember"] = source.get("archiveMember")
    if len(data) < 8:
        row["status"] = "too-short"
        row["usableForRoutePromotion"] = False
        return row
    group = data[0x0002]
    slot = data[0x0003]
    selector = selector_key(group, slot)
    selector_row = selectors_by_key.get(selector) or {}
    field_maps = selector_row.get("fieldMaps") or []
    active_count = data[0x0010] if len(data) > 0x0010 else None
    active_order = []
    if isinstance(active_count, int):
        active_order = [
            data[0x0011 + index]
            for index in range(min(active_count, 12))
            if 0x0011 + index < len(data)
        ]
    route_pair = SOURCE in field_maps and TARGET in field_maps
    current_selector = selector == CURRENT_SELECTOR
    selected_pointer_hex = selector_row.get("selectedPointerHex")
    selected_pointer_matches = selected_pointer_hex == "0x00540714"
    size_matches = row["sizeMatches"]
    synthetic = row["synthetic"]
    route_status = "route-selector-evidence" if (
        not synthetic and size_matches and current_selector and route_pair and selected_pointer_matches
    ) else (
        "synthetic-diagnostic" if synthetic else
        "size-mismatch" if not size_matches else
        "not-current-selector" if not current_selector else
        "missing-route-pair" if not route_pair else
        "selected-pointer-mismatch"
    )
    row.update({
        "status": "parsed",
        "selectorGroup": group,
        "selectorSlot": slot,
        "selector": selector,
        "tileX": read_u16(data, 0x0004),
        "tileY": read_u16(data, 0x0006),
        "activeSlotCount": active_count,
        "activeOrder": active_order,
        "activeFlagByte": data[0x006c] if len(data) > 0x006c else None,
        "gateBytes": {
            "0x00e2": f"0x{data[0x00e2]:02x}" if len(data) > 0x00e2 else None,
            "0x00e4": f"0x{data[0x00e4]:02x}" if len(data) > 0x00e4 else None,
            "0x00e8": f"0x{data[0x00e8]:02x}" if len(data) > 0x00e8 else None,
            "0x00ea": f"0x{data[0x00ea]:02x}" if len(data) > 0x00ea else None,
        },
        "selectedPointerHex": selected_pointer_hex,
        "selectedPointerMatchesCurrentRoot": selected_pointer_matches,
        "fieldMaps": field_maps,
        "coversCurrentSelector": current_selector,
        "coversSourceMap": SOURCE in field_maps,
        "coversTargetMap": TARGET in field_maps,
        "coversRoutePair": route_pair,
        "realCapturedCandidate": not row["synthetic"],
        "routeEvidenceStatus": route_status,
        "usableForRoutePromotion": (
            not synthetic
            and size_matches
            and current_selector
            and route_pair
            and selected_pointer_matches
        ),
    })
    return row


def public_source_label(row: dict) -> str:
    path = str(row.get("path") or "")
    if "HandyHwanseEditor" in path:
        return "HDNua/HandyHwanseEditor"
    if "data/public_savedata/flack3r_zip/" in path:
        return "flack3r attached zip"
    if "data/public_savedata/flack3r/" in path:
        return "flack3r direct savedat2.dat"
    if path.startswith("SAVEDATA/"):
        return "local SAVEDATA intake"
    if path.startswith("SaveData/"):
        return "local SaveData intake"
    if path.startswith("data/public_savedata/"):
        return "other public_savedata"
    if row.get("archivePath"):
        return f"archive:{row['archivePath']}"
    return "other local savedat"


VALID_REAL_CANDIDATE_BLOCK_REASON_ORDER = [
    "synthetic diagnostic excluded",
    "size mismatch",
    "selector byte pair is not 2:0",
    "selected pointer is not 0x00540714",
    "selector does not cover map1_01a",
    "selector does not cover map2_02d",
    "selector lacks map1_01a/map2_02d route pair",
]


def list_text(values: list[Any] | None) -> str:
    return ",".join(str(value) for value in (values or [])) or "-"


def byte_hex(value: int | None) -> str | None:
    if not isinstance(value, int):
        return None
    return f"0x{value:02x}"


def selector_byte_pair_hex(row: dict) -> str | None:
    group_hex = byte_hex(row.get("selectorGroup"))
    slot_hex = byte_hex(row.get("selectorSlot"))
    if group_hex is None or slot_hex is None:
        return None
    return f"{group_hex}/{slot_hex}"


def savedata_route_block_reasons(row: dict) -> list[str]:
    reasons = []
    if row.get("synthetic"):
        reasons.append("synthetic diagnostic excluded")
    if row.get("sizeMatches") is not True:
        reasons.append("size mismatch")
    if row.get("coversCurrentSelector") is not True:
        reasons.append("selector byte pair is not 2:0")
    if row.get("selectedPointerMatchesCurrentRoot") is not True:
        reasons.append("selected pointer is not 0x00540714")
    if row.get("coversSourceMap") is not True:
        reasons.append("selector does not cover map1_01a")
    if row.get("coversTargetMap") is not True:
        reasons.append("selector does not cover map2_02d")
    if row.get("coversRoutePair") is not True:
        reasons.append("selector lacks map1_01a/map2_02d route pair")
    return reasons


def compact_valid_real_candidate(row: dict) -> dict:
    block_reasons = savedata_route_block_reasons(row)
    return {
        "path": row.get("path"),
        "publicSource": public_source_label(row),
        "sha256": row.get("sha256"),
        "selector": row.get("selector"),
        "selectorGroup": row.get("selectorGroup"),
        "selectorSlot": row.get("selectorSlot"),
        "selectorGroupHex": byte_hex(row.get("selectorGroup")),
        "selectorSlotHex": byte_hex(row.get("selectorSlot")),
        "selectorBytePairHex": selector_byte_pair_hex(row),
        "expectedSelector": CURRENT_SELECTOR,
        "expectedGroupByteHex": "0x02",
        "expectedSlotByteHex": "0x00",
        "selectorBytePairMatches": row.get("coversCurrentSelector") is True,
        "selectedPointerHex": row.get("selectedPointerHex"),
        "selectedPointerMatchesCurrentRoot": row.get("selectedPointerMatchesCurrentRoot"),
        "coversSourceMap": row.get("coversSourceMap"),
        "coversTargetMap": row.get("coversTargetMap"),
        "coversRoutePair": row.get("coversRoutePair"),
        "usableForRoutePromotion": row.get("usableForRoutePromotion"),
        "routeEvidenceStatus": row.get("routeEvidenceStatus"),
        "tileX": row.get("tileX"),
        "tileY": row.get("tileY"),
        "activeSlotCount": row.get("activeSlotCount"),
        "activeOrder": row.get("activeOrder") or [],
        "gateBytes": row.get("gateBytes") or {},
        "fieldMaps": row.get("fieldMaps") or [],
        "blockReasons": block_reasons,
        "blockReasonSummary": "; ".join(block_reasons) if block_reasons else "passes route evidence gates",
    }


def block_reason_counts(rows: list[dict]) -> dict[str, int]:
    counts = {reason: 0 for reason in VALID_REAL_CANDIDATE_BLOCK_REASON_ORDER}
    for row in rows:
        for reason in row.get("blockReasons") or []:
            counts[reason] = counts.get(reason, 0) + 1
    return {reason: count for reason, count in counts.items() if count}


def public_search_notes(readme: Path = ROOT / "data" / "public_savedata" / "README.md") -> list[str]:
    """Return manually recorded public-source search notes, if present."""
    if not readme.exists():
        return []
    notes: list[str] = []
    in_section = False
    for line in readme.read_text(encoding="utf-8").splitlines():
        text = line.strip()
        if (
            text.startswith("Additional public search notes")
            or text.startswith("Additional public source recheck")
        ):
            in_section = True
            continue
        if not in_section:
            continue
        if text.startswith("#"):
            break
        if text.startswith("- "):
            notes.append(text[2:].strip())
        elif notes and text:
            notes[-1] = f"{notes[-1]} {text}"
    return notes


def public_search_source_refs(readme: Path = ROOT / "data" / "public_savedata" / "README.md") -> list[dict]:
    """Return source URLs recorded for the latest public web recheck."""
    if not readme.exists():
        return []
    refs: list[dict] = []
    current: dict | None = None
    in_section = False
    for line in readme.read_text(encoding="utf-8").splitlines():
        text = line.strip()
        if text == "## 2026-06-01 second follow-up source URLs":
            in_section = True
            continue
        if in_section and text.startswith("## "):
            break
        if not in_section or not text:
            continue
        if text.startswith("- "):
            label = text[2:].strip().rstrip(":")
            current = {"label": label, "url": ""}
            refs.append(current)
        elif current is not None and text.startswith("http"):
            current["url"] = text
    return [row for row in refs if row.get("label") and row.get("url")]


def source_coverage(rows: list[dict]) -> list[dict]:
    grouped: dict[str, list[dict]] = {}
    for row in rows:
        grouped.setdefault(public_source_label(row), []).append(row)
    coverage = []
    for label, items in sorted(grouped.items()):
        valid_items = [row for row in items if row.get("sizeMatches")]
        coverage.append({
            "source": label,
            "candidateCount": len(items),
            "validCount": len(valid_items),
            "uniqueSha256Count": len({row.get("sha256") for row in valid_items if row.get("sha256")}),
            "selectors": sorted({row.get("selector") or "-" for row in valid_items}),
            "currentSelectorCount": sum(1 for row in valid_items if row.get("coversCurrentSelector")),
            "selectedPointerCount": sum(1 for row in valid_items if row.get("selectedPointerMatchesCurrentRoot")),
            "routePairCount": sum(1 for row in valid_items if row.get("coversRoutePair")),
            "routePromotionCount": sum(1 for row in valid_items if row.get("usableForRoutePromotion")),
            "paths": [row.get("path") for row in items],
        })
    return coverage


def selector_distribution(rows: list[dict]) -> list[dict]:
    grouped: dict[str, list[dict]] = {}
    for row in rows:
        if not row.get("sizeMatches"):
            continue
        grouped.setdefault(row.get("selector") or "-", []).append(row)
    output = []
    for selector, items in sorted(grouped.items()):
        output.append({
            "selector": selector,
            "count": len(items),
            "uniqueSha256Count": len({row.get("sha256") for row in items if row.get("sha256")}),
            "selectedPointerHexes": sorted({row.get("selectedPointerHex") or "-" for row in items}),
            "sourceMapCount": sum(1 for row in items if row.get("coversSourceMap")),
            "targetMapCount": sum(1 for row in items if row.get("coversTargetMap")),
            "routePairCount": sum(1 for row in items if row.get("coversRoutePair")),
            "routePromotionCount": sum(1 for row in items if row.get("usableForRoutePromotion")),
            "tileSamples": sorted({
                f"{row.get('tileX')},{row.get('tileY')}"
                for row in items
                if row.get("tileX") is not None and row.get("tileY") is not None
            }),
            "paths": [row.get("path") for row in items],
        })
    return output


def duplicate_groups(rows: list[dict]) -> list[dict]:
    grouped: dict[str, list[dict]] = {}
    for row in rows:
        sha256 = row.get("sha256")
        if not sha256:
            continue
        grouped.setdefault(sha256, []).append(row)
    duplicates = []
    for sha256, items in sorted(grouped.items()):
        if len(items) < 2:
            continue
        duplicates.append({
            "sha256": sha256,
            "count": len(items),
            "selectors": sorted({row.get("selector") or "-" for row in items}),
            "paths": [row.get("path") for row in items],
        })
    return duplicates


def count_rows_by(rows: list[dict], value_fn) -> list[dict]:
    counts: dict[Any, int] = {}
    paths: dict[Any, list[str]] = {}
    for row in rows:
        value = value_fn(row)
        counts[value] = counts.get(value, 0) + 1
        paths.setdefault(value, []).append(row.get("path"))
    output = []
    for value, count in sorted(counts.items(), key=lambda item: str(item[0])):
        if isinstance(value, int):
            value_hex = f"0x{value:02x}"
            label = str(value)
        else:
            value_hex = None
            label = str(value)
        output.append({
            "value": value,
            "valueHex": value_hex,
            "label": label,
            "count": count,
            "paths": paths.get(value) or [],
        })
    return output


def required_byte_coverage(valid_real: list[dict]) -> dict:
    expected_group = 2
    expected_slot = 0
    matching_group = [row for row in valid_real if row.get("selectorGroup") == expected_group]
    matching_slot = [row for row in valid_real if row.get("selectorSlot") == expected_slot]
    matching_selector = [
        row for row in valid_real
        if row.get("selectorGroup") == expected_group and row.get("selectorSlot") == expected_slot
    ]
    return {
        "expectedSelector": CURRENT_SELECTOR,
        "expectedGroupByteHex": "0x02",
        "expectedSlotByteHex": "0x00",
        "validRealSampleCount": len(valid_real),
        "groupByteDistribution": count_rows_by(valid_real, lambda row: row.get("selectorGroup")),
        "slotByteDistribution": count_rows_by(valid_real, lambda row: row.get("selectorSlot")),
        "selectorPairDistribution": count_rows_by(valid_real, lambda row: row.get("selector")),
        "requiredGroupByteRealSaveCount": len(matching_group),
        "requiredSlotByteRealSaveCount": len(matching_slot),
        "requiredSelectorBytePairRealSaveCount": len(matching_selector),
        "requiredSelectorBytePairPaths": [row.get("path") for row in matching_selector],
        "conclusion": (
            "The required slot byte 0x00 is common in the current real saves, but the required group byte "
            "0x02 never appears, so no real save has the selector byte pair 0x02/0x00."
        ),
    }


def captured_route_pair_gap(valid_real: list[dict]) -> dict:
    source_only = [
        row for row in valid_real
        if row.get("coversSourceMap") and not row.get("coversTargetMap")
    ]
    target_only = [
        row for row in valid_real
        if row.get("coversTargetMap") and not row.get("coversSourceMap")
    ]
    route_pair = [row for row in valid_real if row.get("coversRoutePair")]
    current = [row for row in valid_real if row.get("coversCurrentSelector")]
    return {
        "sourceOnlyCount": len(source_only),
        "targetOnlyCount": len(target_only),
        "routePairCount": len(route_pair),
        "currentSelectorCount": len(current),
        "sourceOnlySelectors": sorted({row.get("selector") for row in source_only}),
        "targetOnlySelectors": sorted({row.get("selector") for row in target_only}),
        "routePairSelectors": sorted({row.get("selector") for row in route_pair}),
        "currentSelectorPaths": [row.get("path") for row in current],
        "conclusion": (
            "The current real captures split the route across source-side and target-side selectors; "
            "none is a real selector 2:0/current-root route-pair save."
        ),
    }


def route_evidence_rejection(
    valid_real: list[dict],
    current_selector_real: list[dict],
    selected_pointer_real: list[dict],
    route_pair_real: list[dict],
    route_real: list[dict],
    synthetic_diagnostics: list[dict],
    valid_real_unique_sha256_count: int,
    valid_real_duplicate_groups: list[dict],
    required_bytes: dict,
    workspace_hidden_expected_paths: list[str],
    slot_scan_status: str,
    slot_scan_found: int,
    slot_scan_valid: int,
    slot_scan_current: int,
    slot_scan_route: int,
) -> dict:
    proof_found = bool(route_real)
    if proof_found:
        classification = "real-savedata-route-evidence-present"
        reason = "a non-synthetic savedat passes current selector, selected pointer, and route-pair gates"
    elif valid_real and not current_selector_real and synthetic_diagnostics:
        classification = "real-saves-present-current-selector-absent-synthetic-excluded"
        reason = (
            "real captured saves parse cleanly, but none has selector 2:0/current root; "
            "the only selector 2:0 savedat is the constructed diagnostic under out/"
        )
    elif valid_real and not current_selector_real:
        classification = "real-saves-present-current-selector-absent"
        reason = "real captured saves parse cleanly, but none has selector 2:0/current root"
    elif current_selector_real and not selected_pointer_real:
        classification = "current-selector-save-selected-pointer-absent"
        reason = "at least one real save has selector 2:0, but none resolves to selected pointer 0x00540714"
    elif selected_pointer_real and not route_pair_real:
        classification = "current-selector-save-route-pair-absent"
        reason = "at least one real save reaches the selected pointer, but none covers the route map pair"
    else:
        classification = "no-real-savedata-route-candidate"
        reason = "no non-synthetic savedat currently passes the route evidence gates"
    return {
        "classification": classification,
        "proofFound": proof_found,
        "reason": reason,
        "validRealCandidateCount": len(valid_real),
        "validRealUniqueSha256Count": valid_real_unique_sha256_count,
        "validRealDuplicateGroupCount": len(valid_real_duplicate_groups),
        "currentSelectorRealSaveCount": len(current_selector_real),
        "selectedPointerRealSaveCount": len(selected_pointer_real),
        "routePairRealSaveCount": len(route_pair_real),
        "routePromotionRealSaveCount": len(route_real),
        "requiredGroupByteRealSaveCount": required_bytes.get("requiredGroupByteRealSaveCount"),
        "requiredSlotByteRealSaveCount": required_bytes.get("requiredSlotByteRealSaveCount"),
        "requiredSelectorBytePairRealSaveCount": required_bytes.get("requiredSelectorBytePairRealSaveCount"),
        "syntheticDiagnosticExcluded": bool(synthetic_diagnostics),
        "workspaceHiddenExpectedSizeDatFileCount": len(workspace_hidden_expected_paths),
        "localSavedataSlotScanStatus": slot_scan_status,
        "localSavedataSlotScanFoundCount": slot_scan_found,
        "localSavedataSlotScanValidCount": slot_scan_valid,
        "localSavedataSlotScanCurrentSelectorCandidateCount": slot_scan_current,
        "localSavedataSlotScanRealRouteEvidenceCandidateCount": slot_scan_route,
    }


def required_capture_checklist() -> dict:
    return {
        "acceptedPaths": [
            "SAVEDATA/savedat1.dat .. SAVEDATA/savedat9.dat",
            "SaveData/savedat1.dat .. SaveData/savedat9.dat",
            "SAVEDATA/savedat1.zip .. SAVEDATA/savedat9.zip",
            "SaveData/savedat1.zip .. SaveData/savedat9.zip",
            "custom --search-root file/dir/zip passed to this summarizer",
        ],
        "requiredBytes": [
            {
                "offsetHex": "0x0002",
                "requiredHex": "0x02",
                "meaning": "selector group byte for current route selector 2:0",
            },
            {
                "offsetHex": "0x0003",
                "requiredHex": "0x00",
                "meaning": "selector slot byte for current route selector 2:0",
            },
        ],
        "expectedSize": EXPECTED_SAVE_SIZE,
        "expectedSelector": CURRENT_SELECTOR,
        "expectedSelectedPointerHex": "0x00540714",
        "expectedRouteMaps": [SOURCE, TARGET],
        "scanCommands": [
            "python3 tools/summarize_save_selector_real_savedata_evidence_gap.py --search-root <file-or-dir-or-zip>",
            "python3 tools/scan_savedata_slots.py --search-root <file-or-dir-or-zip>",
        ],
        "browserChecks": [
            "../web/game.html?savedatUrl=../SAVEDATA/savedat2.dat",
            "../web/game.html?savedatScan=1",
        ],
        "stillRequiredAfterMatch": [
            "strict map1_01a source hotspot or equivalent runtime trigger",
            "selected-root execution/control-flow evidence on a non-diagnostic path",
        ],
    }


def build_summary(
    selectors: list[dict],
    sample_coverage: dict | None = None,
    synthetic_probe: dict | None = None,
    search_roots: list[Path] | None = None,
) -> dict:
    search_roots = search_roots or [ROOT / "SAVEDATA", ROOT / "SaveData", ROOT / "data", ROOT]
    selectors_by_key = selector_map(selectors)
    sources, skipped_archives = candidate_sources(search_roots)
    parsed = [parse_candidate(source, selectors_by_key) for source in sources]
    workspace_survey = workspace_savedata_survey()
    parsed_paths = {str(row.get("path")) for row in parsed}
    workspace_expected_paths = {
        str(row.get("path"))
        for row in workspace_survey["datFiles"]
        if row.get("expectedSize")
    }
    workspace_hidden_expected_paths = sorted(workspace_expected_paths - parsed_paths)
    real_candidates = [row for row in parsed if not row.get("synthetic")]
    valid_real = [row for row in real_candidates if row.get("sizeMatches")]
    route_real = [row for row in valid_real if row.get("usableForRoutePromotion")]
    current_selector_real = [row for row in valid_real if row.get("coversCurrentSelector")]
    selected_pointer_real = [row for row in valid_real if row.get("selectedPointerMatchesCurrentRoot")]
    route_pair_real = [row for row in valid_real if row.get("coversRoutePair")]
    valid_real_duplicate_groups = duplicate_groups(valid_real)
    valid_real_unique_sha256_count = len({row.get("sha256") for row in valid_real if row.get("sha256")})
    valid_real_candidate_rows = [compact_valid_real_candidate(row) for row in valid_real]
    valid_real_candidate_block_reason_counts = block_reason_counts(valid_real_candidate_rows)
    required_bytes = required_byte_coverage(valid_real)
    slot_scan = load_json(OUT / "savedata_slot_scan.json", {})
    slot_scan_status = slot_scan.get("status", "missing")
    slot_scan_found = slot_scan.get("foundCount", 0)
    slot_scan_valid = slot_scan.get("validCount", 0)
    slot_scan_current = (
        slot_scan.get("currentSelectorCandidateCount")
        or len(slot_scan.get("currentSelectorCandidates") or [])
    )
    slot_scan_route = (
        slot_scan.get("realRouteEvidenceCandidateCount")
        or len(slot_scan.get("realRouteEvidenceCandidates") or [])
    )
    synthetic_diagnostics = []
    synthetic_path = OUT / "synthetic_savedat_selector_2_0.dat"
    if synthetic_path.exists():
        synthetic_diagnostics.append({
            "path": relative(synthetic_path),
            "size": synthetic_path.stat().st_size,
            "excludedReason": "constructed diagnostic, not captured gameplay save",
            "selector": (synthetic_probe or {}).get("selector"),
            "selectedPointerHex": (synthetic_probe or {}).get("selectedPointerHex"),
        })
    slot_scan_diagnostic_policy = slot_scan.get("diagnosticExclusionPolicy") or {}
    diagnostic_exclusion_policy = {
        "generatedOutArtifactsExcluded": slot_scan_diagnostic_policy.get("generatedOutArtifactsExcluded", True),
        "broadSearchRootSafe": slot_scan_diagnostic_policy.get("broadSearchRootSafe", True),
        "pathMarkers": slot_scan_diagnostic_policy.get("pathMarkers") or [
            "synthetic_savedat_selector_2_0",
            "runtime_patched_public_savedat_selector_2_0",
            "runtime_patched_public_selector_2_0",
        ],
        "excludedReasonValues": slot_scan_diagnostic_policy.get("excludedReasonValues") or [
            "known synthetic selector 2:0 diagnostic hash",
            "generated out/ diagnostic artifact",
            "synthetic savedata diagnostic filename",
            "patched selector 2:0 diagnostic filename",
        ],
        "note": (
            slot_scan_diagnostic_policy.get("note")
            or "Generated out/ selector 2:0 vectors and patched public savedat diagnostics are parser/runtime probes, not captured gameplay saves."
        ),
    }
    route_rejection = route_evidence_rejection(
        valid_real,
        current_selector_real,
        selected_pointer_real,
        route_pair_real,
        route_real,
        synthetic_diagnostics,
        valid_real_unique_sha256_count,
        valid_real_duplicate_groups,
        required_bytes,
        workspace_hidden_expected_paths,
        slot_scan_status,
        slot_scan_found,
        slot_scan_valid,
        slot_scan_current,
        slot_scan_route,
    )
    public_selectors = (sample_coverage or {}).get("sampleSelectors") or []
    if route_real:
        conclusion = (
            "At least one real savedata file covers selector 2:0 and the map1_01a/map2_02d route pair. "
            "This is selector coverage evidence only; route promotion still requires a strict map1_01a hotspot "
            "or equivalent runtime trigger."
        )
        promotion_status = "selector-evidence-only"
    elif valid_real:
        covered = ", ".join(sorted({row.get("selector") or "-" for row in valid_real}))
        conclusion = (
            f"Local real savedata files are available and parse cleanly, but they cover selectors {covered}, "
            "not the current selector 2:0 for map1_01a -> map2_02d. The constructed selector 2:0 diagnostic "
            "under out/ is still excluded from promotion. The repo-facing SAVEDATA/SaveData slot scan is "
            f"{slot_scan_status} ({slot_scan_found}/{slot_scan_valid} found/valid; "
            f"selector2:0={slot_scan_current}; realRoute={slot_scan_route}), so no newly captured local slot "
            "currently changes the gate. SHA-256 deduplication leaves "
            f"{valid_real_unique_sha256_count} unique valid real saves, and the required save byte pair "
            f"0x0002=0x02/0x0003=0x00 appears in {required_bytes['requiredSelectorBytePairRealSaveCount']} of them. "
            "The workspace survey finds no additional expected-size "
            "dat files outside the parsed candidates. A captured gameplay savedat with selector 2:0, or an "
            "equivalent runtime trace, is still required."
        )
        promotion_status = "blocked"
    else:
        conclusion = (
            "No local real savedata file currently proves selector 2:0 for map1_01a -> map2_02d. "
            "The default SAVEDATA/SaveData intake directories are empty, data/ only contains derived JSON samples, "
            "the workspace survey finds no hidden expected-size dat files outside the parsed candidates, and the "
            "only selector 2:0 savedat file is the constructed diagnostic vector under out/, which is explicitly "
            "excluded from route promotion. A captured gameplay savedat with selector 2:0, or an equivalent "
            "runtime trace, is still required."
        )
        promotion_status = "blocked"
    promotion_gate_checklist = [
        {
            "gate": "real-captured-savedata",
            "passed": bool(valid_real),
            "matchingCount": len(valid_real),
            "required": "at least one non-synthetic savedat*.dat with expected size 1274",
            "evidence": f"validRealCandidateCount={len(valid_real)}; uniqueSha256={valid_real_unique_sha256_count}",
        },
        {
            "gate": "current-selector-2:0",
            "passed": bool(current_selector_real),
            "matchingCount": len(current_selector_real),
            "required": "save bytes 0x0002=0x02 and 0x0003=0x00",
            "evidence": (
                f"currentSelectorRealSaveCount={len(current_selector_real)}; "
                f"requiredBytePairCount={required_bytes['requiredSelectorBytePairRealSaveCount']}"
            ),
        },
        {
            "gate": "selected-pointer-0x00540714",
            "passed": bool(selected_pointer_real),
            "matchingCount": len(selected_pointer_real),
            "required": "selector table resolves to selected pointer 0x00540714",
            "evidence": f"selectedPointerRealSaveCount={len(selected_pointer_real)}",
        },
        {
            "gate": "route-pair-map1_01a-map2_02d",
            "passed": bool(route_pair_real),
            "matchingCount": len(route_pair_real),
            "required": "selector-linked field maps include map1_01a and map2_02d",
            "evidence": f"routePairRealSaveCount={len(route_pair_real)}",
        },
        {
            "gate": "selector-route-evidence",
            "passed": bool(route_real),
            "matchingCount": len(route_real),
            "required": "one real save passes current selector, selected pointer, and route-pair gates",
            "evidence": f"routePromotionRealSaveCount={len(route_real)}",
        },
        {
            "gate": "strict-hotspot-or-runtime-trigger",
            "passed": False,
            "matchingCount": 0,
            "required": "strict map1_01a source hotspot or runtime trigger proof after selector evidence",
            "evidence": "still tracked outside savedata; current route remains blocked without this proof",
        },
    ]
    failed_savedata_gate_ids = [
        row["gate"]
        for row in promotion_gate_checklist
        if row.get("passed") is not True
    ]
    missing_by_gate = {
        "current-selector-2:0": "real savedata selector bytes 0x0002=0x02 and 0x0003=0x00",
        "selected-pointer-0x00540714": "selector table selected pointer 0x00540714",
        "route-pair-map1_01a-map2_02d": "selector-linked field maps include map1_01a and map2_02d",
        "selector-route-evidence": "one real save passing current selector, selected pointer, and route-pair gates",
        "strict-hotspot-or-runtime-trigger": "strict map1_01a source hotspot or equivalent runtime trigger after selector evidence",
    }
    missing_evidence = [
        missing_by_gate[gate]
        for gate in failed_savedata_gate_ids
        if gate in missing_by_gate
    ]
    captured_gap = captured_route_pair_gap(valid_real)
    required_selector_byte_pair_count = required_bytes.get("requiredSelectorBytePairRealSaveCount")
    real_selector20_save_found = bool(route_real or slot_scan_current)
    evidence_refs = [
        {
            "path": "out/savedata_slot_scan.json",
            "fields": [
                "status",
                "foundCount",
                "validCount",
                "currentSelectorCandidateCount",
                "realRouteEvidenceCandidateCount",
                "syntheticDiagnosticCount",
                "rows",
            ],
        },
        {
            "path": "out/savedata_sample_coverage.json",
            "fields": [
                "sampleSelectors",
                "currentFrontierSampleCovered",
                "routePairSampleCovered",
                "samples",
            ],
        },
        {
            "path": "out/synthetic_savedata_selector_probe.json",
            "fields": [
                "selector",
                "selectedPointerHex",
                "containsRoutePair",
                "notCapturedSave",
                "notRoutePromotionProof",
                "routePromotionStatus",
                "promotionStatus",
            ],
        },
        {
            "path": "out/save_scene_selectors.json",
            "fields": [
                "group",
                "slot",
                "selectorBytes",
                "selectedPointerHex",
                "fieldMaps",
            ],
        },
    ]
    return {
        "source": SOURCE,
        "target": TARGET,
        "currentSelector": CURRENT_SELECTOR,
        "searchRoots": [relative(path) for path in search_roots],
        "expectedSaveSize": EXPECTED_SAVE_SIZE,
        "candidateCount": len(parsed),
        "archiveCandidateCount": sum(1 for row in parsed if row.get("archive")),
        "archiveSkippedCount": len(skipped_archives),
        "skippedArchiveCandidates": skipped_archives,
        "realCandidateCount": len(real_candidates),
        "validRealCandidateCount": len(valid_real),
        "validRealUniqueSha256Count": valid_real_unique_sha256_count,
        "validRealDuplicateGroupCount": len(valid_real_duplicate_groups),
        "validRealDuplicateGroups": valid_real_duplicate_groups,
        "validRealCandidateRows": valid_real_candidate_rows,
        "validRealCandidateBlockReasonCounts": valid_real_candidate_block_reason_counts,
        "validRealCandidatesAllBlocked": (
            bool(valid_real_candidate_rows)
            and all(bool(row.get("blockReasons")) for row in valid_real_candidate_rows)
        ),
        "requiredByteCoverage": required_bytes,
        "requiredSelectorBytePairRealSaveCount": required_selector_byte_pair_count,
        "currentSelectorRealSaveCount": sum(1 for row in valid_real if row.get("coversCurrentSelector")),
        "selectedPointerRealSaveCount": len(selected_pointer_real),
        "routePairRealSaveCount": len(route_pair_real),
        "routePromotionRealSaveCount": len(route_real),
        "proofFound": route_rejection["proofFound"],
        "routeEvidenceProofFound": route_rejection["proofFound"],
        "routeEvidenceRejectionClassification": route_rejection["classification"],
        "realSavedataRouteEvidenceRejection": route_rejection,
        "realSelector20SaveFound": real_selector20_save_found,
        "realSelector20CapturedCurrentSelectorSaveCount": captured_gap.get("currentSelectorCount"),
        "realSelector20CapturedSourceOnlySaveCount": captured_gap.get("sourceOnlyCount"),
        "realSelector20CapturedTargetOnlySaveCount": captured_gap.get("targetOnlyCount"),
        "realSelector20CapturedRoutePairSaveCount": captured_gap.get("routePairCount"),
        "promotionGateChecklist": promotion_gate_checklist,
        "failedSavedataGateIds": failed_savedata_gate_ids,
        "missingEvidence": missing_evidence,
        "publicSourceCoverage": source_coverage(real_candidates),
        "realSelectorDistribution": selector_distribution(real_candidates),
        "capturedRoutePairGap": captured_gap,
        "requiredCaptureChecklist": required_capture_checklist(),
        "workspaceSavedataSurvey": workspace_survey,
        "workspaceDatFileCount": workspace_survey["datFileCount"],
        "workspaceExpectedSizeDatFileCount": workspace_survey["expectedSizeDatFileCount"],
        "workspaceZipDatMemberCount": workspace_survey["zipDatMemberCount"],
        "workspaceExpectedSizeZipDatMemberCount": workspace_survey["expectedSizeZipDatMemberCount"],
        "workspaceHiddenExpectedSizeDatFileCount": len(workspace_hidden_expected_paths),
        "workspaceHiddenExpectedSizeDatFiles": workspace_hidden_expected_paths,
        "localSavedataSlotScan": {
            "source": "out/savedata_slot_scan.json",
            "scope": slot_scan.get("scope"),
            "archiveScope": slot_scan.get("archiveScope"),
            "status": slot_scan_status,
            "foundCount": slot_scan_found,
            "validCount": slot_scan_valid,
            "currentSelectorCandidateCount": slot_scan_current,
            "realRouteEvidenceCandidateCount": slot_scan_route,
            "syntheticDiagnosticCount": slot_scan.get("syntheticDiagnosticCount", 0),
            "diagnosticExclusionPolicy": slot_scan_diagnostic_policy,
            "selectedCandidateFileName": (slot_scan.get("selectedCandidate") or {}).get("fileName"),
        },
        "localSavedataSlotScanStatus": slot_scan_status,
        "localSavedataSlotScanFoundCount": slot_scan_found,
        "localSavedataSlotScanValidCount": slot_scan_valid,
        "localSavedataSlotScanCurrentSelectorCandidateCount": slot_scan_current,
        "localSavedataSlotScanRealRouteEvidenceCandidateCount": slot_scan_route,
        "browserScanPatterns": [
            "SAVEDATA/savedat1.dat through SAVEDATA/savedat9.dat",
            "SAVEDATA/savedat1.zip through SAVEDATA/savedat9.zip",
            "SaveData/savedat1.dat through SaveData/savedat9.dat",
            "SaveData/savedat1.zip through SaveData/savedat9.zip",
        ],
        "webScanUrl": "../web/game.html?savedatScan=1",
        "terminalScanCommand": "python3 tools/scan_savedata_slots.py",
        "candidates": parsed,
        "syntheticDiagnosticExcluded": bool(synthetic_diagnostics),
        "syntheticDiagnostics": synthetic_diagnostics,
        "diagnosticExclusionPolicy": diagnostic_exclusion_policy,
        "publicSampleSelectors": public_selectors,
        "publicCurrentFrontierCovered": (sample_coverage or {}).get("currentFrontierSampleCovered"),
        "publicRoutePairCovered": (sample_coverage or {}).get("routePairSampleCovered"),
        "publicSearchNotes": public_search_notes(),
        "publicSearchSourceRefs": public_search_source_refs(),
        "evidenceRefs": evidence_refs,
        "evidenceRefCount": len(evidence_refs),
        "promotionStatus": promotion_status,
        "remainingProofs": [
            "add or capture a real savedat*.dat with selector bytes 0x0002=2 and 0x0003=0",
            "verify that captured save maps to selected pointer 0x00540714 and includes map1_01a/map2_02d",
            "still prove strict map1_01a hotspot or equivalent runtime trigger after selector coverage appears",
        ],
        "conclusion": conclusion,
    }


def markdown(summary: dict) -> str:
    lines = [
        "# Save Selector Real Savedata Evidence Gap",
        "",
        f"- route: {summary['source']} -> {summary['target']}",
        f"- current selector: `{summary['currentSelector']}`",
        f"- search roots: {', '.join(summary['searchRoots'])}",
        f"- expected save size: {summary['expectedSaveSize']}",
        f"- real candidates: {summary['realCandidateCount']}",
        f"- valid real candidates: {summary['validRealCandidateCount']}",
        f"- unique valid real SHA-256: {summary['validRealUniqueSha256Count']}",
        f"- duplicate valid real SHA-256 groups: {summary['validRealDuplicateGroupCount']}",
        f"- valid real candidates all blocked: {summary['validRealCandidatesAllBlocked']}",
        f"- workspace dat files: {summary['workspaceDatFileCount']}",
        f"- workspace expected-size dat files: {summary['workspaceExpectedSizeDatFileCount']}",
        f"- workspace zip dat members: {summary['workspaceZipDatMemberCount']}",
        f"- workspace hidden expected-size dat files: {summary['workspaceHiddenExpectedSizeDatFileCount']}",
        f"- archive candidates: {summary['archiveCandidateCount']}",
        f"- archive candidates skipped: {summary['archiveSkippedCount']}",
        f"- current selector real saves: {summary['currentSelectorRealSaveCount']}",
        f"- selected pointer real saves: {summary['selectedPointerRealSaveCount']}",
        f"- route-pair real saves: {summary['routePairRealSaveCount']}",
        f"- route-promotion real saves: {summary['routePromotionRealSaveCount']}",
        f"- proof found: {summary['proofFound']}",
        f"- route evidence proof found: {summary['routeEvidenceProofFound']}",
        f"- route evidence rejection classification: `{summary['routeEvidenceRejectionClassification']}`",
        f"- real selector 2:0 save found: {summary['realSelector20SaveFound']}",
        f"- required selector byte pair matches: {summary.get('requiredSelectorBytePairRealSaveCount')}",
        f"- browser scan patterns: {'; '.join(summary['browserScanPatterns'])}",
        f"- web scan URL: `{summary['webScanUrl']}`",
        f"- terminal scan command: `{summary['terminalScanCommand']}`",
        f"- local SAVEDATA/SaveData slot scan: `{summary['localSavedataSlotScanStatus']}` "
        f"({summary['localSavedataSlotScanFoundCount']}/{summary['localSavedataSlotScanValidCount']} found/valid; "
        f"selector2:0={summary['localSavedataSlotScanCurrentSelectorCandidateCount']}; "
        f"realRoute={summary['localSavedataSlotScanRealRouteEvidenceCandidateCount']})",
        f"- public sample selectors: {', '.join(summary['publicSampleSelectors']) or '-'}",
        f"- public current frontier covered: {summary['publicCurrentFrontierCovered']}",
        f"- captured source-only / target-only / route-pair saves: "
        f"{summary['capturedRoutePairGap']['sourceOnlyCount']} / "
        f"{summary['capturedRoutePairGap']['targetOnlyCount']} / "
        f"{summary['capturedRoutePairGap']['routePairCount']}",
        f"- synthetic diagnostic excluded: {summary['syntheticDiagnosticExcluded']}",
        f"- diagnostic exclusion policy: {(summary.get('diagnosticExclusionPolicy') or {}).get('note')}",
        f"- diagnostic path markers: {', '.join((summary.get('diagnosticExclusionPolicy') or {}).get('pathMarkers') or [])}",
        f"- evidence refs: {summary['evidenceRefCount']}",
        f"- promotion status: {summary['promotionStatus']}",
        "",
        summary["conclusion"],
        "",
        "## Missing Evidence",
        "",
    ]
    lines.extend(f"- {item}" for item in summary.get("missingEvidence") or [])
    lines.extend([
        "",
        "## Evidence Refs",
        "",
        "| path | fields |",
        "| --- | --- |",
    ])
    for ref in summary.get("evidenceRefs") or []:
        lines.append(
            f"| `{ref.get('path')}` | `{list_text(ref.get('fields'))}` |"
        )
    lines.extend([
        "",
        "## Promotion Gate Checklist",
        "",
        "| gate | passed | matches | required | evidence |",
        "| --- | --- | ---: | --- | --- |",
    ])
    for gate in summary["promotionGateChecklist"]:
        lines.append(
            f"| `{gate['gate']}` | {gate['passed']} | {gate['matchingCount']} | "
            f"{gate['required']} | {gate['evidence']} |"
        )
    lines.extend([
        "",
        "## Required Byte Coverage",
        "",
        f"- expected group byte: `{(summary.get('requiredByteCoverage') or {}).get('expectedGroupByteHex')}`",
        f"- expected slot byte: `{(summary.get('requiredByteCoverage') or {}).get('expectedSlotByteHex')}`",
        f"- required group byte real saves: {(summary.get('requiredByteCoverage') or {}).get('requiredGroupByteRealSaveCount')}",
        f"- required slot byte real saves: {(summary.get('requiredByteCoverage') or {}).get('requiredSlotByteRealSaveCount')}",
        f"- required selector byte pair real saves: {(summary.get('requiredByteCoverage') or {}).get('requiredSelectorBytePairRealSaveCount')}",
        "",
        "| selector byte pair | count |",
        "| --- | ---: |",
    ])
    for row in (summary.get("requiredByteCoverage") or {}).get("selectorPairDistribution") or []:
        lines.append(f"| `{row.get('label')}` | {row.get('count')} |")
    if not (summary.get("requiredByteCoverage") or {}).get("selectorPairDistribution"):
        lines.append("| - | 0 |")
    duplicate_groups = summary.get("validRealDuplicateGroups") or []
    lines.extend([
        "",
        "## Valid Real Duplicate Groups",
        "",
        "| sha256 | count | selectors | paths |",
        "| --- | ---: | --- | --- |",
    ])
    for row in duplicate_groups:
        duplicate_paths = ", ".join(f"`{path}`" for path in row.get("paths") or [])
        lines.append(
            f"| `{row.get('sha256')}` | {row.get('count')} | {', '.join(row.get('selectors') or []) or '-'} | "
            f"{duplicate_paths} |"
        )
    if not duplicate_groups:
        lines.append("| - | 0 | - | - |")
    lines.extend([
        "",
        "## Public Source Coverage",
        "",
        "| source | candidates | valid | unique sha256 | selectors | selector 2:0 | selected pointer | route pair | route proof |",
        "| --- | ---: | ---: | ---: | --- | ---: | ---: | ---: | ---: |",
    ])
    for row in summary["publicSourceCoverage"]:
        lines.append(
            f"| {row['source']} | {row['candidateCount']} | {row['validCount']} | "
            f"{row.get('uniqueSha256Count')} | {', '.join(row['selectors']) or '-'} | {row['currentSelectorCount']} | "
            f"{row['selectedPointerCount']} | {row['routePairCount']} | {row['routePromotionCount']} |"
        )
    if not summary["publicSourceCoverage"]:
        lines.append("| - | 0 | 0 | 0 | - | 0 | 0 | 0 | 0 |")
    lines.extend([
        "",
        "## Real Selector Distribution",
        "",
        "| selector | count | unique sha256 | selected pointers | source | target | route pair | route proof | tiles |",
        "| --- | ---: | ---: | --- | ---: | ---: | ---: | ---: | --- |",
    ])
    for row in summary.get("realSelectorDistribution") or []:
        lines.append(
            f"| `{row['selector']}` | {row['count']} | {row.get('uniqueSha256Count')} | "
            f"{', '.join(row['selectedPointerHexes']) or '-'} | "
            f"{row['sourceMapCount']} | {row['targetMapCount']} | {row['routePairCount']} | "
            f"{row['routePromotionCount']} | {', '.join(row['tileSamples']) or '-'} |"
        )
    if not summary.get("realSelectorDistribution"):
        lines.append("| - | 0 | 0 | - | 0 | 0 | 0 | 0 | - |")
    lines.extend([
        "",
        "## Valid Real Candidate Blockers",
        "",
        "| file | source | selector bytes | selected pointer | source | target | route pair | route proof | block reasons |",
        "| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
    ])
    for row in summary.get("validRealCandidateRows") or []:
        lines.append(
            f"| `{row.get('path')}` | {row.get('publicSource') or '-'} | "
            f"`{row.get('selectorBytePairHex') or '-'}` (`{row.get('selector') or '-'}`) | "
            f"`{row.get('selectedPointerHex') or '-'}` | "
            f"{row.get('coversSourceMap')} | {row.get('coversTargetMap')} | {row.get('coversRoutePair')} | "
            f"{row.get('usableForRoutePromotion')} | {row.get('blockReasonSummary') or '-'} |"
        )
    if not summary.get("validRealCandidateRows"):
        lines.append("| - | - | - | - | False | False | False | False | no valid real candidates |")
    capture = summary.get("requiredCaptureChecklist") or {}
    lines.extend([
        "",
        "## Required Captured Save Checklist",
        "",
        f"- expected size: {capture.get('expectedSize')}",
        f"- expected selector: `{capture.get('expectedSelector')}`",
        f"- expected selected pointer: `{capture.get('expectedSelectedPointerHex')}`",
        f"- expected route maps: {', '.join(capture.get('expectedRouteMaps') or [])}",
        "",
        "| offset | required | meaning |",
        "| --- | --- | --- |",
    ])
    for row in capture.get("requiredBytes") or []:
        lines.append(
            f"| `{row['offsetHex']}` | `{row['requiredHex']}` | {row['meaning']} |"
        )
    lines.extend(["", "Accepted intake paths:"])
    lines.extend(f"- `{item}`" for item in capture.get("acceptedPaths") or [])
    lines.extend(["", "Scan commands:"])
    lines.extend(f"- `{item}`" for item in capture.get("scanCommands") or [])
    lines.extend(["", "Browser checks:"])
    lines.extend(f"- `{item}`" for item in capture.get("browserChecks") or [])
    survey = summary.get("workspaceSavedataSurvey") or {}
    lines.extend([
        "",
        "## Workspace Savedata Survey",
        "",
        f"- skipped directories: {', '.join(survey.get('skipDirs') or [])}",
        f"- dat files: {survey.get('datFileCount')}",
        f"- expected-size dat files: {survey.get('expectedSizeDatFileCount')}",
        f"- zip dat members: {survey.get('zipDatMemberCount')}",
        f"- expected-size zip dat members: {survey.get('expectedSizeZipDatMemberCount')}",
        f"- hidden expected-size dat files outside parsed candidates: {summary.get('workspaceHiddenExpectedSizeDatFileCount')}",
        "",
        "| file | size | expected size | savedat name | sha256 |",
        "| --- | ---: | --- | --- | --- |",
    ])
    for row in survey.get("datFiles") or []:
        lines.append(
            f"| `{row.get('path')}` | {row.get('size')} | {row.get('expectedSize')} | "
            f"{row.get('savedatName')} | `{row.get('sha256') or '-'}` |"
        )
    if not survey.get("datFiles"):
        lines.append("| - | 0 | False | False | - |")
    lines.extend([
        "",
        "| archive member | size | expected size | savedat name | sha256 |",
        "| --- | ---: | --- | --- | --- |",
    ])
    for row in survey.get("zipDatMembers") or []:
        lines.append(
            f"| `{row.get('archivePath')}::{row.get('archiveMember')}` | {row.get('size')} | "
            f"{row.get('expectedSize')} | {row.get('savedatName')} | `{row.get('sha256') or '-'}` |"
        )
    if not survey.get("zipDatMembers"):
        lines.append("| - | 0 | False | False | - |")
    lines.extend(["", "## Public Search Notes", ""])
    public_notes = summary.get("publicSearchNotes") or []
    if public_notes:
        lines.extend(f"- {item}" for item in public_notes)
    else:
        lines.append("- No additional public search notes recorded.")
    lines.extend(["", "## Public Search Source Refs", ""])
    public_refs = summary.get("publicSearchSourceRefs") or []
    if public_refs:
        lines.extend(f"- {row.get('label')}: {row.get('url')}" for row in public_refs)
    else:
        lines.append("- No public search source refs recorded.")
    lines.extend([
        "",
        "## Real Candidates",
        "",
        "| file | size | sha256 | selector | selected pointer | tile | maps | route proof | status |",
        "| --- | ---: | --- | --- | --- | --- | --- | --- | --- |",
    ])
    real_rows = [row for row in summary["candidates"] if not row.get("synthetic")]
    for row in real_rows:
        maps = ", ".join(row.get("fieldMaps") or []) or "-"
        selector = row.get("selector") or "-"
        tile = f"{row.get('tileX')},{row.get('tileY')}" if row.get("tileX") is not None else "-"
        route_proof = "yes" if row.get("usableForRoutePromotion") else "no"
        lines.append(
            f"| `{row['path']}` | {row['size']} | `{row.get('sha256') or '-'}` | `{selector}` | "
            f"`{row.get('selectedPointerHex') or '-'}` | "
            f"{tile} | {maps} | {route_proof} | {row.get('routeEvidenceStatus') or row.get('status')} |"
        )
    if not real_rows:
        lines.append("| - | 0 | - | - | - | - | - | no | no local real savedat files found |")
    lines.extend(["", "## Excluded Synthetic Diagnostics", "", "| file | size | selector | reason |", "| --- | ---: | --- | --- |"])
    for row in summary["syntheticDiagnostics"]:
        lines.append(
            f"| `{row['path']}` | {row['size']} | `{row.get('selector') or '-'}` | {row['excludedReason']} |"
        )
    if not summary["syntheticDiagnostics"]:
        lines.append("| - | 0 | - | - |")
    lines.extend(["", "## Remaining Proofs", ""])
    lines.extend(f"- {item}" for item in summary["remainingProofs"])
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    def esc(value: Any) -> str:
        return html.escape(str(value))

    real_rows = []
    for row in [item for item in summary["candidates"] if not item.get("synthetic")]:
        maps = ", ".join(row.get("fieldMaps") or []) or "-"
        selector = row.get("selector") or "-"
        tile = f"{row.get('tileX')},{row.get('tileY')}" if row.get("tileX") is not None else "-"
        real_rows.append(
            "<tr>"
            f"<td><code>{esc(row['path'])}</code></td>"
            f"<td>{esc(row['size'])}</td>"
            f"<td><code>{esc((row.get('sha256') or '-')[:12])}</code></td>"
            f"<td><code>{esc(selector)}</code></td>"
            f"<td><code>{esc(row.get('selectedPointerHex') or '-')}</code></td>"
            f"<td>{esc(tile)}</td>"
            f"<td>{esc(maps)}</td>"
            f"<td>{'yes' if row.get('usableForRoutePromotion') else 'no'}</td>"
            f"<td>{esc(row.get('routeEvidenceStatus') or row.get('status'))}</td>"
            "</tr>"
        )
    if not real_rows:
        real_rows.append('<tr><td colspan="9">No local real savedat files found.</td></tr>')
    synthetic_rows = []
    for row in summary["syntheticDiagnostics"]:
        synthetic_rows.append(
            "<tr>"
            f"<td><code>{esc(row['path'])}</code></td>"
            f"<td>{esc(row['size'])}</td>"
            f"<td><code>{esc(row.get('selector') or '-')}</code></td>"
            f"<td>{esc(row['excludedReason'])}</td>"
            "</tr>"
        )
    if not synthetic_rows:
        synthetic_rows.append('<tr><td colspan="4">No synthetic diagnostics found.</td></tr>')
    evidence_ref_rows = []
    for ref in summary.get("evidenceRefs") or []:
        evidence_ref_rows.append(
            "<tr>"
            f"<td><code>{esc(ref.get('path'))}</code></td>"
            f"<td><code>{esc(list_text(ref.get('fields')))}</code></td>"
            "</tr>"
        )
    if not evidence_ref_rows:
        evidence_ref_rows.append('<tr><td colspan="2">No evidence refs recorded.</td></tr>')
    gate_rows = []
    for gate in summary["promotionGateChecklist"]:
        gate_rows.append(
            "<tr>"
            f"<td><code>{esc(gate['gate'])}</code></td>"
            f"<td>{esc(gate['passed'])}</td>"
            f"<td>{esc(gate['matchingCount'])}</td>"
            f"<td>{esc(gate['required'])}</td>"
            f"<td>{esc(gate['evidence'])}</td>"
            "</tr>"
        )
    source_rows = []
    for row in summary["publicSourceCoverage"]:
        source_rows.append(
            "<tr>"
            f"<td>{esc(row['source'])}</td>"
            f"<td>{esc(row['candidateCount'])}</td>"
            f"<td>{esc(row['validCount'])}</td>"
            f"<td>{esc(row.get('uniqueSha256Count'))}</td>"
            f"<td>{esc(', '.join(row['selectors']) or '-')}</td>"
            f"<td>{esc(row['currentSelectorCount'])}</td>"
            f"<td>{esc(row['selectedPointerCount'])}</td>"
            f"<td>{esc(row['routePairCount'])}</td>"
            f"<td>{esc(row['routePromotionCount'])}</td>"
            "</tr>"
        )
    if not source_rows:
        source_rows.append('<tr><td colspan="9">No public source savedata coverage.</td></tr>')
    selector_rows = []
    for row in summary.get("realSelectorDistribution") or []:
        selector_rows.append(
            "<tr>"
            f"<td><code>{esc(row['selector'])}</code></td>"
            f"<td>{esc(row['count'])}</td>"
            f"<td>{esc(row.get('uniqueSha256Count'))}</td>"
            f"<td><code>{esc(', '.join(row['selectedPointerHexes']) or '-')}</code></td>"
            f"<td>{esc(row['sourceMapCount'])}</td>"
            f"<td>{esc(row['targetMapCount'])}</td>"
            f"<td>{esc(row['routePairCount'])}</td>"
            f"<td>{esc(row['routePromotionCount'])}</td>"
            f"<td>{esc(', '.join(row['tileSamples']) or '-')}</td>"
            "</tr>"
        )
    if not selector_rows:
        selector_rows.append('<tr><td colspan="9">No real selector distribution.</td></tr>')
    candidate_blocker_rows = []
    for row in summary.get("validRealCandidateRows") or []:
        candidate_blocker_rows.append(
            "<tr>"
            f"<td><code>{esc(row.get('path'))}</code></td>"
            f"<td>{esc(row.get('publicSource') or '-')}</td>"
            f"<td><code>{esc(row.get('selectorBytePairHex') or '-')}</code> "
            f"(<code>{esc(row.get('selector') or '-')}</code>)</td>"
            f"<td><code>{esc(row.get('selectedPointerHex') or '-')}</code></td>"
            f"<td>{esc(row.get('coversSourceMap'))}</td>"
            f"<td>{esc(row.get('coversTargetMap'))}</td>"
            f"<td>{esc(row.get('coversRoutePair'))}</td>"
            f"<td>{esc(row.get('usableForRoutePromotion'))}</td>"
            f"<td>{esc(row.get('blockReasonSummary') or '-')}</td>"
            "</tr>"
        )
    if not candidate_blocker_rows:
        candidate_blocker_rows.append('<tr><td colspan="9">No valid real candidates.</td></tr>')
    capture = summary.get("requiredCaptureChecklist") or {}
    capture_byte_rows = []
    for row in capture.get("requiredBytes") or []:
        capture_byte_rows.append(
            "<tr>"
            f"<td><code>{esc(row['offsetHex'])}</code></td>"
            f"<td><code>{esc(row['requiredHex'])}</code></td>"
            f"<td>{esc(row['meaning'])}</td>"
            "</tr>"
        )
    required_coverage = summary.get("requiredByteCoverage") or {}
    required_pair_rows = []
    for row in required_coverage.get("selectorPairDistribution") or []:
        required_pair_rows.append(
            "<tr>"
            f"<td><code>{esc(row.get('label'))}</code></td>"
            f"<td>{esc(row.get('count'))}</td>"
            "</tr>"
        )
    if not required_pair_rows:
        required_pair_rows.append('<tr><td colspan="2">No valid real selector bytes.</td></tr>')
    duplicate_rows = []
    for row in summary.get("validRealDuplicateGroups") or []:
        duplicate_rows.append(
            "<tr>"
            f"<td><code>{esc(row.get('sha256'))}</code></td>"
            f"<td>{esc(row.get('count'))}</td>"
            f"<td>{esc(', '.join(row.get('selectors') or []) or '-')}</td>"
            f"<td>{esc(', '.join(row.get('paths') or []))}</td>"
            "</tr>"
        )
    if not duplicate_rows:
        duplicate_rows.append('<tr><td colspan="4">No duplicate valid real SHA-256 groups.</td></tr>')
    accepted_paths = "".join(
        f"<li><code>{esc(item)}</code></li>" for item in capture.get("acceptedPaths") or []
    )
    scan_commands = "".join(
        f"<li><code>{esc(item)}</code></li>" for item in capture.get("scanCommands") or []
    )
    browser_checks = "".join(
        f"<li><code>{esc(item)}</code></li>" for item in capture.get("browserChecks") or []
    )
    still_required = "".join(
        f"<li>{esc(item)}</li>" for item in capture.get("stillRequiredAfterMatch") or []
    )
    survey = summary.get("workspaceSavedataSurvey") or {}
    workspace_rows = []
    for row in survey.get("datFiles") or []:
        workspace_rows.append(
            "<tr>"
            f"<td><code>{esc(row.get('path'))}</code></td>"
            f"<td>{esc(row.get('size'))}</td>"
            f"<td>{esc(row.get('expectedSize'))}</td>"
            f"<td>{esc(row.get('savedatName'))}</td>"
            f"<td><code>{esc((row.get('sha256') or '-')[:12])}</code></td>"
            "</tr>"
        )
    if not workspace_rows:
        workspace_rows.append('<tr><td colspan="5">No workspace dat files found.</td></tr>')
    workspace_zip_rows = []
    for row in survey.get("zipDatMembers") or []:
        workspace_zip_rows.append(
            "<tr>"
            f"<td><code>{esc(row.get('archivePath'))}::{esc(row.get('archiveMember'))}</code></td>"
            f"<td>{esc(row.get('size'))}</td>"
            f"<td>{esc(row.get('expectedSize'))}</td>"
            f"<td>{esc(row.get('savedatName'))}</td>"
            f"<td><code>{esc((row.get('sha256') or '-')[:12])}</code></td>"
            "</tr>"
        )
    if not workspace_zip_rows:
        workspace_zip_rows.append('<tr><td colspan="5">No workspace zip dat members found.</td></tr>')
    public_notes = "".join(f"<li>{esc(item)}</li>" for item in (summary.get("publicSearchNotes") or []))
    if not public_notes:
        public_notes = "<li>No additional public search notes recorded.</li>"
    public_refs = []
    for row in summary.get("publicSearchSourceRefs") or []:
        label = esc(row.get("label"))
        url = esc(row.get("url"))
        public_refs.append(f'<li>{label}: <a href="{url}">{url}</a></li>')
    if not public_refs:
        public_refs.append("<li>No public search source refs recorded.</li>")
    proofs = "".join(f"<li>{esc(item)}</li>" for item in summary["remainingProofs"])
    return "\n".join([
        "<!doctype html>",
        '<html lang="en">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>Save Selector Real Savedata Evidence Gap</title>",
        "  <style>body{margin:24px;background:#101010;color:#eee;font:14px system-ui,sans-serif}table{border-collapse:collapse;width:100%;margin:16px 0 28px}th,td{border:1px solid #333;padding:6px 8px;vertical-align:top}th{background:#1d1d1d}code{color:#f5d76e}</style>",
        "</head>",
        "<body>",
        "  <h1>Save Selector Real Savedata Evidence Gap</h1>",
        f"  <p>route <code>{esc(summary['source'])}</code> -&gt; <code>{esc(summary['target'])}</code>; "
        f"current selector <code>{esc(summary['currentSelector'])}</code>; promotion status: {esc(summary['promotionStatus'])}.</p>",
        f"  <p>real candidates: {esc(summary['realCandidateCount'])}; valid real candidates: {esc(summary['validRealCandidateCount'])}; "
        f"unique valid real SHA-256: {esc(summary['validRealUniqueSha256Count'])}; "
        f"duplicate valid real SHA-256 groups: {esc(summary['validRealDuplicateGroupCount'])}; "
        f"valid real candidates all blocked: {esc(summary['validRealCandidatesAllBlocked'])}; "
        f"workspace dat files: {esc(summary['workspaceDatFileCount'])}; "
        f"workspace expected-size dat files: {esc(summary['workspaceExpectedSizeDatFileCount'])}; "
        f"workspace zip dat members: {esc(summary['workspaceZipDatMemberCount'])}; "
        f"workspace hidden expected-size dat files: {esc(summary['workspaceHiddenExpectedSizeDatFileCount'])}; "
        f"local SAVEDATA/SaveData slot scan: <code>{esc(summary['localSavedataSlotScanStatus'])}</code> "
        f"({esc(summary['localSavedataSlotScanFoundCount'])}/{esc(summary['localSavedataSlotScanValidCount'])} found/valid; "
        f"selector2:0={esc(summary['localSavedataSlotScanCurrentSelectorCandidateCount'])}; "
        f"realRoute={esc(summary['localSavedataSlotScanRealRouteEvidenceCandidateCount'])}); "
        f"archive candidates: {esc(summary['archiveCandidateCount'])}; archive candidates skipped: {esc(summary['archiveSkippedCount'])}; "
        f"current selector real saves: {esc(summary['currentSelectorRealSaveCount'])}; "
        f"selected pointer real saves: {esc(summary['selectedPointerRealSaveCount'])}; "
        f"route-pair real saves: {esc(summary['routePairRealSaveCount'])}; "
        f"route-promotion real saves: {esc(summary['routePromotionRealSaveCount'])}; "
        f"proof found: {esc(summary['proofFound'])}; "
        f"route evidence proof found: {esc(summary['routeEvidenceProofFound'])}; "
        f"real selector 2:0 save found: {esc(summary['realSelector20SaveFound'])}; "
        f"required selector byte pair matches: {esc(summary.get('requiredSelectorBytePairRealSaveCount'))}; "
        f"route evidence rejection: <code>{esc(summary['routeEvidenceRejectionClassification'])}</code>; "
        f"synthetic diagnostic excluded: {esc(summary['syntheticDiagnosticExcluded'])}.</p>",
        f"  <p>captured source-only / target-only / route-pair saves: "
        f"{esc((summary.get('capturedRoutePairGap') or {}).get('sourceOnlyCount'))} / "
        f"{esc((summary.get('capturedRoutePairGap') or {}).get('targetOnlyCount'))} / "
        f"{esc((summary.get('capturedRoutePairGap') or {}).get('routePairCount'))}. "
        f"{esc((summary.get('capturedRoutePairGap') or {}).get('conclusion'))}</p>",
        f"  <p>diagnostic exclusion policy: {esc((summary.get('diagnosticExclusionPolicy') or {}).get('note'))}; "
        f"path markers: <code>{esc(', '.join((summary.get('diagnosticExclusionPolicy') or {}).get('pathMarkers') or []))}</code>.</p>",
        f"  <p>evidence refs: {esc(summary.get('evidenceRefCount'))}.</p>",
        f"  <p>browser scan patterns: <code>{esc('; '.join(summary['browserScanPatterns']))}</code>; "
        f"web scan URL: <code>{esc(summary['webScanUrl'])}</code>; terminal scan command: <code>{esc(summary['terminalScanCommand'])}</code>.</p>",
        f"  <p>{esc(summary['conclusion'])}</p>",
        f"  <h2>Missing Evidence</h2>",
        f"  <ul>{''.join(f'<li>{esc(item)}</li>' for item in summary.get('missingEvidence') or [])}</ul>",
        "  <h2>Evidence Refs</h2>",
        "  <table><thead><tr><th>path</th><th>fields</th></tr></thead>",
        f"  <tbody>{''.join(evidence_ref_rows)}</tbody></table>",
        "  <h2>Promotion Gate Checklist</h2>",
        "  <table><thead><tr><th>gate</th><th>passed</th><th>matches</th><th>required</th><th>evidence</th></tr></thead>",
        f"  <tbody>{''.join(gate_rows)}</tbody></table>",
        "  <h2>Public Source Coverage</h2>",
        "  <table><thead><tr><th>source</th><th>candidates</th><th>valid</th><th>unique sha256</th><th>selectors</th><th>selector 2:0</th><th>selected pointer</th><th>route pair</th><th>route proof</th></tr></thead>",
        f"  <tbody>{''.join(source_rows)}</tbody></table>",
        "  <h2>Real Selector Distribution</h2>",
        "  <table><thead><tr><th>selector</th><th>count</th><th>unique sha256</th><th>selected pointers</th><th>source</th><th>target</th><th>route pair</th><th>route proof</th><th>tiles</th></tr></thead>",
        f"  <tbody>{''.join(selector_rows)}</tbody></table>",
        "  <h2>Valid Real Candidate Blockers</h2>",
        "  <table><thead><tr><th>file</th><th>source</th><th>selector bytes</th><th>selected pointer</th><th>source</th><th>target</th><th>route pair</th><th>route proof</th><th>block reasons</th></tr></thead>",
        f"  <tbody>{''.join(candidate_blocker_rows)}</tbody></table>",
        "  <h2>Required Captured Save Checklist</h2>",
        f"  <p>expected size {esc(capture.get('expectedSize'))}; selector "
        f"<code>{esc(capture.get('expectedSelector'))}</code>; selected pointer "
        f"<code>{esc(capture.get('expectedSelectedPointerHex'))}</code>; route maps "
        f"<code>{esc(', '.join(capture.get('expectedRouteMaps') or []))}</code>.</p>",
        "  <table><thead><tr><th>offset</th><th>required</th><th>meaning</th></tr></thead>",
        f"  <tbody>{''.join(capture_byte_rows)}</tbody></table>",
        f"  <p><b>Accepted intake paths</b></p><ul>{accepted_paths}</ul>",
        f"  <p><b>Scan commands</b></p><ul>{scan_commands}</ul>",
        f"  <p><b>Browser checks</b></p><ul>{browser_checks}</ul>",
        f"  <p><b>Still required after a matching save</b></p><ul>{still_required}</ul>",
        "  <h2>Required Byte Coverage</h2>",
        f"  <p>expected group byte <code>{esc(required_coverage.get('expectedGroupByteHex'))}</code>; "
        f"expected slot byte <code>{esc(required_coverage.get('expectedSlotByteHex'))}</code>; "
        f"required group matches {esc(required_coverage.get('requiredGroupByteRealSaveCount'))}; "
        f"required slot matches {esc(required_coverage.get('requiredSlotByteRealSaveCount'))}; "
        f"required selector byte pair matches {esc(required_coverage.get('requiredSelectorBytePairRealSaveCount'))}.</p>",
        "  <table><thead><tr><th>selector byte pair</th><th>count</th></tr></thead>",
        f"  <tbody>{''.join(required_pair_rows)}</tbody></table>",
        "  <h2>Valid Real Duplicate Groups</h2>",
        "  <table><thead><tr><th>sha256</th><th>count</th><th>selectors</th><th>paths</th></tr></thead>",
        f"  <tbody>{''.join(duplicate_rows)}</tbody></table>",
        "  <h2>Workspace Savedata Survey</h2>",
        f"  <p>hidden expected-size dat files outside parsed candidates: {esc(summary['workspaceHiddenExpectedSizeDatFileCount'])}; "
        f"skipped directories: <code>{esc(', '.join(survey.get('skipDirs') or []))}</code>.</p>",
        "  <table><thead><tr><th>file</th><th>size</th><th>expected size</th><th>savedat name</th><th>sha256</th></tr></thead>",
        f"  <tbody>{''.join(workspace_rows)}</tbody></table>",
        "  <table><thead><tr><th>archive member</th><th>size</th><th>expected size</th><th>savedat name</th><th>sha256</th></tr></thead>",
        f"  <tbody>{''.join(workspace_zip_rows)}</tbody></table>",
        "  <h2>Public Search Notes</h2>",
        f"  <ul>{public_notes}</ul>",
        "  <h2>Public Search Source Refs</h2>",
        f"  <ul>{''.join(public_refs)}</ul>",
        "  <h2>Real Candidates</h2>",
        "  <table><thead><tr><th>file</th><th>size</th><th>sha256</th><th>selector</th><th>selected pointer</th><th>tile</th><th>maps</th><th>route proof</th><th>status</th></tr></thead>",
        f"  <tbody>{''.join(real_rows)}</tbody></table>",
        "  <h2>Excluded Synthetic Diagnostics</h2>",
        "  <table><thead><tr><th>file</th><th>size</th><th>selector</th><th>reason</th></tr></thead>",
        f"  <tbody>{''.join(synthetic_rows)}</tbody></table>",
        "  <h2>Remaining Proofs</h2>",
        f"  <ul>{proofs}</ul>",
        "</body>",
        "</html>",
        "",
    ])


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


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--selectors", type=Path, default=OUT / "save_scene_selectors.json")
    parser.add_argument("--sample-coverage", type=Path, default=OUT / "savedata_sample_coverage.json")
    parser.add_argument("--synthetic-probe", type=Path, default=OUT / "synthetic_savedata_selector_probe.json")
    parser.add_argument(
        "--search-root",
        type=Path,
        action="append",
        help="savedat*.dat file or directory to scan; repeatable. Defaults to SAVEDATA, SaveData, data, and repo root.",
    )
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(
        json.loads(args.selectors.read_text(encoding="utf-8")) if args.selectors.exists() else [],
        json.loads(args.sample_coverage.read_text(encoding="utf-8")) if args.sample_coverage.exists() else {},
        json.loads(args.synthetic_probe.read_text(encoding="utf-8")) if args.synthetic_probe.exists() else {},
        args.search_root,
    )
    write_outputs(summary, args.out_dir)
    print(
        "wrote real savedata evidence gap "
        f"({summary['routePromotionRealSaveCount']} route-proof saves) -> "
        f"{args.out_dir / 'save_selector_real_savedata_evidence_gap.md'}"
    )


if __name__ == "__main__":
    main()
