#!/usr/bin/env python3
"""Scan local SAVEDATA/SaveData savedat*.dat/.zip slots for route-blocker evidence."""
from __future__ import annotations

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

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

from parse_savedata import parse_savedata, parse_savedata_bytes


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
DEFAULT_SLOT_STEMS = [f"savedat{index}" for index in range(1, 10)]
DEFAULT_SLOT_NAMES = [f"{stem}.dat" for stem in DEFAULT_SLOT_STEMS]
DEFAULT_ARCHIVE_SLOT_NAMES = [f"{stem}.zip" for stem in DEFAULT_SLOT_STEMS]
DEFAULT_SLOT_DIRS = [ROOT / "SAVEDATA", ROOT / "SaveData"]
ROUTE_SOURCE = "map1_01a"
ROUTE_TARGET = "map2_02d"
CURRENT_SELECTOR = (2, 0)
CURRENT_SELECTED_POINTER = "0x00540714"
KNOWN_SYNTHETIC_SHA256 = {
    # out/synthetic_savedat_selector_2_0.dat: constructed selector 2:0 diagnostic.
    "3ea19a255ce8243750088dd53a9cb3ba4cb595a9d2bc9ec54c9b24dc3289aa1d",
}
DIAGNOSTIC_PATH_MARKERS = [
    "synthetic_savedat_selector_2_0",
    "runtime_patched_public_savedat_selector_2_0",
    "runtime_patched_public_selector_2_0",
]
SLOT_SCAN_GATE_MISSING_EVIDENCE = {
    "local-slot-file-present": "SAVEDATA/SaveData savedat1-9 dat/zip slot file",
    "valid-slot-savedata": "valid 1274-byte savedat file in the scanned slots",
    "current-selector-2-0": "slot save with selector bytes 0x0002=0x02 and 0x0003=0x00",
    "real-route-evidence-candidate": (
        "real route evidence candidate covering selected pointer 0x00540714 and map1_01a -> map2_02d"
    ),
}


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


def evidence_ref(path: str, *fields: str) -> dict:
    return {"path": path, "fields": list(fields)}


def selector_value(summary: dict, key: str) -> int | None:
    record = (summary.get("sceneSelector") or {}).get(key) or {}
    value = record.get("value")
    return value if isinstance(value, int) else None


def field_maps(summary: dict) -> list[str]:
    resources = ((summary.get("sceneSelector") or {}).get("linkedResources") or {})
    maps = resources.get("fieldMaps") or []
    return [item for item in maps if isinstance(item, str)]


def file_sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


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


def synthetic_diagnostic_hashes() -> set[str]:
    hashes = set(KNOWN_SYNTHETIC_SHA256)
    path = OUT / "synthetic_savedat_selector_2_0.dat"
    if path.exists():
        hashes.add(file_sha256(path))
    return hashes


def generated_output_path(path_label: str) -> bool:
    path_text = str(path_label).split("::", 1)[0]
    try:
        path = Path(path_text)
        resolved = path.resolve() if path.is_absolute() else (ROOT / path).resolve()
        return resolved.is_relative_to(OUT.resolve())
    except (OSError, ValueError):
        normalized = path_text.replace("\\", "/").lstrip("./").lower()
        return normalized.startswith("out/")


def synthetic_diagnostic_reason(
    path_label: str,
    file_name: str,
    sha256: str,
    synthetic_hashes: set[str],
) -> str | None:
    text = f"{path_label} {file_name}".lower()
    if sha256 in synthetic_hashes:
        return "known synthetic selector 2:0 diagnostic hash"
    if generated_output_path(path_label):
        return "generated out/ diagnostic artifact"
    if "synthetic" in text:
        return "synthetic savedata diagnostic filename"
    if any(marker in text for marker in DIAGNOSTIC_PATH_MARKERS):
        return "patched selector 2:0 diagnostic filename"
    return None


def diagnostic_exclusion_policy(synthetic_hashes: set[str]) -> dict:
    return {
        "generatedOutArtifactsExcluded": True,
        "knownSyntheticSha256Count": len(synthetic_hashes),
        "pathMarkers": DIAGNOSTIC_PATH_MARKERS,
        "excludedReasonValues": [
            "known synthetic selector 2:0 diagnostic hash",
            "generated out/ diagnostic artifact",
            "synthetic savedata diagnostic filename",
            "patched selector 2:0 diagnostic filename",
        ],
        "broadSearchRootSafe": True,
        "note": (
            "Generated out/ selector 2:0 vectors and patched public savedat diagnostics "
            "are parser/runtime probes, not captured gameplay saves."
        ),
    }


def summarize_save(summary: dict, path_label: str, file_name: str, sha256: str, synthetic_hashes: set[str]) -> dict:
    selector = summary.get("sceneSelector") or {}
    group = selector_value(summary, "group")
    slot = selector_value(summary, "slot")
    maps = field_maps(summary)
    map_set = set(maps)
    web_start = summary.get("webStartCandidate") or {}
    position = summary.get("scenePositionCandidate") or {}
    x = ((position.get("x") or {}).get("value"))
    y = ((position.get("y") or {}).get("value"))
    selected_pointer = selector.get("selectedPointerHex")
    diagnostic_reason = synthetic_diagnostic_reason(path_label, file_name, sha256, synthetic_hashes)
    synthetic_diagnostic = diagnostic_reason is not None
    active_order = summary.get("activeDescriptorOrder") or {}
    active_count = ((active_order.get("count") or {}).get("value"))
    active_indices = active_order.get("activeDescriptorIndices") or []
    selector_matches = (group, slot) == CURRENT_SELECTOR
    pointer_matches = selected_pointer == CURRENT_SELECTED_POINTER
    route_pair = ROUTE_SOURCE in map_set and ROUTE_TARGET in map_set
    route_evidence = selector_matches or pointer_matches or route_pair
    real_route_evidence = route_evidence and not synthetic_diagnostic
    return {
        "path": path_label,
        "fileName": file_name,
        "size": summary.get("size"),
        "sha256": sha256,
        "valid": True,
        "group": group,
        "slot": slot,
        "selector": f"{group}:{slot}" if group is not None and slot is not None else None,
        "selectedPointerHex": selected_pointer,
        "x": x,
        "y": y,
        "fieldMaps": maps,
        "webStartMap": web_start.get("map"),
        "webStartReason": web_start.get("reason"),
        "webStartUrl": web_start.get("url"),
        "explicitMapUrl": web_start.get("explicitMapUrl"),
        "activeDescriptorCount": active_count,
        "activeDescriptorIndices": active_indices,
        "activeDescriptorOrderRaw": active_order.get("rawOrderValues") or [],
        "selectorMatchesCurrent": selector_matches,
        "selectedPointerMatchesCurrent": pointer_matches,
        "routePairCovered": route_pair,
        "routeEvidenceCandidate": route_evidence,
        "realRouteEvidenceCandidate": real_route_evidence,
        "syntheticDiagnostic": synthetic_diagnostic,
        "syntheticDiagnosticReason": diagnostic_reason,
    }


def error_row(path_label: str, file_name: str, error: Exception) -> dict:
    return {
        "path": path_label,
        "fileName": file_name,
        "valid": False,
        "error": str(error),
        "routeEvidenceCandidate": False,
    }


def savedat_archive_members(path: Path, *, savedat_prefix_required: bool = True) -> list[tuple[str, bytes]]:
    rows: list[tuple[str, bytes]] = []
    with zipfile.ZipFile(path) as archive:
        for info in archive.infolist():
            name = info.filename.replace("\\", "/")
            member_name = Path(name).name.lower()
            if info.is_dir() or not member_name.endswith(".dat"):
                continue
            if savedat_prefix_required and not member_name.startswith("savedat"):
                continue
            rows.append((name, archive.read(info)))
    return rows


def parse_search_candidate(
    path_label: str,
    file_name: str,
    data: bytes,
    exe: Path | None,
    maps_index: Path | None,
    synthetic_hashes: set[str],
    extra: dict,
) -> dict:
    try:
        summary = parse_savedata_bytes(data, path_label, exe, maps_index)
    except Exception as exc:
        return {
            **error_row(path_label, file_name, exc),
            **extra,
            "found": True,
        }
    return {
        **summarize_save(summary, path_label, file_name, bytes_sha256(data), synthetic_hashes),
        **extra,
        "found": True,
    }


def scan_search_roots(
    search_roots: list[Path],
    exe: Path | None,
    maps_index: Path | None,
    synthetic_hashes: set[str],
) -> list[dict]:
    rows: list[dict] = []
    seen: set[str] = set()

    def add_dat(path: Path, root: Path) -> None:
        key = f"dat:{path.resolve()}"
        if key in seen:
            return
        seen.add(key)
        rows.append(parse_search_candidate(
            str(path),
            path.name,
            path.read_bytes(),
            exe,
            maps_index,
            synthetic_hashes,
            {
                "slotType": "search-dat",
                "slotsDir": str(path.parent),
                "searchRoot": str(root),
            },
        ))

    def add_zip(path: Path, root: Path) -> None:
        key = f"zip:{path.resolve()}"
        if key in seen:
            return
        seen.add(key)
        try:
            members = savedat_archive_members(path, savedat_prefix_required=False)
            if not members:
                raise ValueError("zip archive has no .dat members")
        except Exception as exc:
            rows.append({
                **error_row(str(path), path.name, exc),
                "slotType": "search-zip",
                "slotsDir": str(path.parent),
                "searchRoot": str(root),
                "found": True,
            })
            return
        for member_name, data in members:
            path_label = f"{path}::{member_name}"
            rows.append(parse_search_candidate(
                path_label,
                f"{path.name}::{Path(member_name).name}",
                data,
                exe,
                maps_index,
                synthetic_hashes,
                {
                    "slotType": "search-zip",
                    "slotsDir": str(path.parent),
                    "archivePath": str(path),
                    "archiveMember": member_name,
                    "searchRoot": str(root),
                },
            ))

    for root in search_roots:
        if not root.exists():
            rows.append({
                "path": str(root),
                "fileName": root.name,
                "slotType": "search-root",
                "slotsDir": str(root.parent),
                "searchRoot": str(root),
                "found": False,
                "valid": False,
                "routeEvidenceCandidate": False,
                "error": "search root does not exist",
            })
            continue
        if root.is_file():
            lower = root.name.lower()
            if lower.endswith(".dat"):
                add_dat(root, root)
            elif lower.endswith(".zip"):
                add_zip(root, root)
            else:
                rows.append({
                    "path": str(root),
                    "fileName": root.name,
                    "slotType": "search-root",
                    "slotsDir": str(root.parent),
                    "searchRoot": str(root),
                    "found": True,
                    "valid": False,
                    "routeEvidenceCandidate": False,
                    "error": "search root file is not .dat or .zip",
                })
            continue
        for path in sorted(root.rglob("*"), key=lambda item: str(item)):
            if not path.is_file():
                continue
            lower = path.name.lower()
            if lower.endswith(".dat"):
                add_dat(path, root)
            elif lower.endswith(".zip"):
                add_zip(path, root)
    return rows


def scan_slots(
    slots_dirs: list[Path] | Path,
    exe: Path | None,
    maps_index: Path | None,
    search_roots: list[Path] | None = None,
) -> dict:
    if isinstance(slots_dirs, Path):
        slots_dirs = [slots_dirs]
    search_roots = search_roots or []
    slot_entries = []
    for slots_dir in slots_dirs:
        for stem in DEFAULT_SLOT_STEMS:
            slot_entries.append({"type": "dat", "slotsDir": slots_dir, "path": slots_dir / f"{stem}.dat"})
            slot_entries.append({"type": "zip", "slotsDir": slots_dir, "path": slots_dir / f"{stem}.zip"})
    synthetic_hashes = synthetic_diagnostic_hashes()
    rows = []
    for entry in slot_entries:
        path = entry["path"]
        if not path.exists():
            rows.append({
                "path": str(path),
                "fileName": path.name,
                "slotType": entry["type"],
                "slotsDir": str(entry["slotsDir"]),
                "found": False,
                "valid": False,
                "routeEvidenceCandidate": False,
            })
            continue
        if entry["type"] == "zip":
            try:
                members = savedat_archive_members(path)
                if not members:
                    raise ValueError("zip archive has no savedat*.dat members")
            except Exception as exc:
                rows.append({
                    **error_row(str(path), path.name, exc),
                    "slotType": "zip",
                    "slotsDir": str(entry["slotsDir"]),
                    "found": True,
                })
                continue
            for member_name, data in members:
                path_label = f"{path}::{member_name}"
                try:
                    summary = parse_savedata_bytes(data, path_label, exe, maps_index)
                except Exception as exc:
                    rows.append({
                        **error_row(path_label, f"{path.name}::{Path(member_name).name}", exc),
                        "slotType": "zip",
                        "slotsDir": str(entry["slotsDir"]),
                        "archivePath": str(path),
                        "archiveMember": member_name,
                        "found": True,
                    })
                    continue
                rows.append({
                    **summarize_save(
                        summary,
                        path_label,
                        f"{path.name}::{Path(member_name).name}",
                        bytes_sha256(data),
                        synthetic_hashes,
                    ),
                    "slotType": "zip",
                    "slotsDir": str(entry["slotsDir"]),
                    "archivePath": str(path),
                    "archiveMember": member_name,
                    "found": True,
                })
            continue
        try:
            summary = parse_savedata(path, exe, maps_index)
            data_hash = file_sha256(path)
        except Exception as exc:  # keep scan usable while the user is trying files
            rows.append({
                **error_row(str(path), path.name, exc),
                "slotType": "dat",
                "slotsDir": str(entry["slotsDir"]),
                "found": True,
            })
            continue
        rows.append({
            **summarize_save(summary, str(path), path.name, data_hash, synthetic_hashes),
            "slotType": "dat",
            "slotsDir": str(entry["slotsDir"]),
            "found": True,
        })
    rows.extend(scan_search_roots(search_roots, exe, maps_index, synthetic_hashes))

    found = [row for row in rows if row.get("found")]
    valid = [row for row in found if row.get("valid")]
    route_candidates = [row for row in valid if row.get("routeEvidenceCandidate")]
    real_route_candidates = [row for row in valid if row.get("realRouteEvidenceCandidate")]
    synthetic_route_candidates = [
        row for row in route_candidates
        if row.get("syntheticDiagnostic")
    ]
    current_selector_candidates = [
        row for row in real_route_candidates
        if row.get("selectorMatchesCurrent") or row.get("selectedPointerMatchesCurrent")
    ]
    diagnostic_current_selector_candidates = [
        row for row in synthetic_route_candidates
        if row.get("selectorMatchesCurrent") or row.get("selectedPointerMatchesCurrent")
    ]
    selected = (
        current_selector_candidates
        or real_route_candidates
        or diagnostic_current_selector_candidates
        or route_candidates
        or valid
        or [None]
    )[0]
    status = "current-selector-found" if current_selector_candidates else (
        "route-pair-found" if real_route_candidates else (
            "synthetic-diagnostic-only" if synthetic_route_candidates else (
            "valid-saves-without-current-selector" if valid else (
                "no-valid-saves" if found else "no-saves-found"
            )
            )
        )
    )
    scope_parts = [
        f"{slots_dir.name}/savedat1.dat through {slots_dir.name}/savedat9.dat"
        for slots_dir in slots_dirs
    ]
    archive_scope_parts = [
        f"{slots_dir.name}/savedat1.zip through {slots_dir.name}/savedat9.zip"
        for slots_dir in slots_dirs
    ]
    search_scope = ", ".join(str(path) for path in search_roots)
    gate_passed = {
        "local-slot-file-present": bool(found),
        "valid-slot-savedata": bool(valid),
        "current-selector-2-0": bool(current_selector_candidates),
        "real-route-evidence-candidate": bool(real_route_candidates),
    }
    failed_gate_ids = [gate_id for gate_id, passed in gate_passed.items() if not passed]
    missing_evidence = [
        SLOT_SCAN_GATE_MISSING_EVIDENCE[gate_id]
        for gate_id in failed_gate_ids
    ]
    proof_found = bool(real_route_candidates)
    evidence_refs = [
        evidence_ref(
            evidence_path_label(slots_dir),
            "savedat1.dat..savedat9.dat",
            "savedat1.zip..savedat9.zip",
        )
        for slots_dir in slots_dirs
    ] + [
        evidence_ref(
            "out/save_scene_selectors.json",
            "selector",
            "selectedPointerHex",
            "fieldMaps",
        ),
        evidence_ref(
            "out/synthetic_savedata_selector_probe.json",
            "selector",
            "selectedPointerHex",
            "notRoutePromotionProof",
        ),
    ]
    if search_roots:
        evidence_refs.extend(
            evidence_ref(
                evidence_path_label(root),
                "search-root .dat/.zip candidates",
                "selector",
                "selectedPointerHex",
            )
            for root in search_roots
        )
    return {
        "scope": "; ".join(scope_parts),
        "archiveScope": "; ".join(archive_scope_parts),
        "searchScope": search_scope,
        "slotsDir": str(slots_dirs[0]) if slots_dirs else "",
        "slotDirs": [str(path) for path in slots_dirs],
        "slotDirNames": [path.name for path in slots_dirs],
        "searchRoots": [str(path) for path in search_roots],
        "searchRootCount": len(search_roots),
        "route": f"{ROUTE_SOURCE} -> {ROUTE_TARGET}",
        "currentSelector": f"{CURRENT_SELECTOR[0]}:{CURRENT_SELECTOR[1]}",
        "currentSelectedPointerHex": CURRENT_SELECTED_POINTER,
        "status": status,
        "promotionStatus": "ready-for-review" if proof_found else "blocked",
        "proofFound": proof_found,
        "savedataSlotScanProofFound": proof_found,
        "failedSavedataSlotScanGateIds": failed_gate_ids,
        "missingEvidence": missing_evidence,
        "evidenceRefs": evidence_refs,
        "evidenceRefCount": len(evidence_refs),
        "foundCount": len(found),
        "validCount": len(valid),
        "routeEvidenceCandidateCount": len(route_candidates),
        "realRouteEvidenceCandidateCount": len(real_route_candidates),
        "syntheticDiagnosticCount": len([row for row in valid if row.get("syntheticDiagnostic")]),
        "currentSelectorCandidateCount": len(current_selector_candidates),
        "diagnosticCurrentSelectorCandidateCount": len(diagnostic_current_selector_candidates),
        "archiveFoundCount": len([row for row in found if row.get("slotType") in {"zip", "search-zip"}]),
        "archiveValidCount": len([row for row in valid if row.get("slotType") in {"zip", "search-zip"}]),
        "searchFoundCount": len([row for row in found if str(row.get("slotType") or "").startswith("search")]),
        "searchValidCount": len([row for row in valid if str(row.get("slotType") or "").startswith("search")]),
        "selected": selected,
        "diagnosticExclusionPolicy": diagnostic_exclusion_policy(synthetic_hashes),
        "rows": rows,
        "nextStep": (
            "A real captured selector 2:0 save can be used as route evidence input. "
            "The scan checks both the repo-facing SAVEDATA directory and the original EXE SaveData casing by default. "
            "Synthetic selector probes are reported separately and never count as real route evidence; "
            "route-pair-only rows still need strict hotspot or runtime proof before promotion."
        ),
    }


def markdown(summary: dict) -> str:
    selected = summary.get("selected") or {}
    lines = [
        "# Savedata Slot Scan",
        "",
        f"- scope: `{summary['scope']}`",
        f"- archive scope: `{summary['archiveScope']}`",
        f"- search scope: `{summary.get('searchScope') or '-'}`",
        f"- route: `{summary['route']}`",
        f"- status: `{summary['status']}`",
        f"- promotion status: `{summary['promotionStatus']}`",
        f"- proof found: {summary['proofFound']}",
        f"- savedata slot scan proof found: {summary['savedataSlotScanProofFound']}",
        f"- failed savedata slot scan gates: `{','.join(summary['failedSavedataSlotScanGateIds']) or '-'}`",
        f"- missing evidence count: {len(summary['missingEvidence'])}",
        f"- evidence refs: {summary['evidenceRefCount']}",
        f"- found/valid: {summary['foundCount']} / {summary['validCount']}",
        f"- search found/valid: {summary.get('searchFoundCount', 0)} / {summary.get('searchValidCount', 0)}",
        f"- current selector candidates: {summary['currentSelectorCandidateCount']}",
        f"- real route evidence candidates: {summary['realRouteEvidenceCandidateCount']}",
        f"- synthetic diagnostics: {summary['syntheticDiagnosticCount']}",
        f"- diagnostic exclusion policy: {summary.get('diagnosticExclusionPolicy', {}).get('note')}",
        f"- selected: `{selected.get('fileName') or '-'}` selector `{selected.get('selector') or '-'}` map `{selected.get('webStartMap') or '-'}`",
        f"- next step: {summary['nextStep']}",
        "",
        "## Missing Evidence",
        "",
        *[f"- {item}" for item in summary["missingEvidence"]],
        "",
        "## Evidence Refs",
        "",
        "| path | fields |",
        "| --- | --- |",
        *[
            f"| `{row['path']}` | {', '.join(row.get('fields') or []) or '-'} |"
            for row in summary["evidenceRefs"]
        ],
        "",
        "## Slot Rows",
        "",
        "| dir | file | type | member | found | valid | selector | pointer | tile | active order | maps | route evidence | real evidence | diagnostic | diagnostic reason | web start | error |",
        "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
    ]
    for row in summary["rows"]:
        maps = ", ".join((row.get("fieldMaps") or [])[:6])
        if len(row.get("fieldMaps") or []) > 6:
            maps += ", ..."
        tile = "-"
        if row.get("x") is not None and row.get("y") is not None:
            tile = f"{row['x']},{row['y']}"
        active_order = ",".join(str(value) for value in row.get("activeDescriptorIndices") or [])
        if row.get("activeDescriptorCount") is not None:
            active_order = f"{row.get('activeDescriptorCount')}:[{active_order}]"
        web_start = row.get("webStartMap") or "-"
        slots_dir_name = Path(row.get("slotsDir") or "").name or "-"
        lines.append(
            f"| `{slots_dir_name}` | `{row['fileName']}` | {row.get('slotType') or '-'} | `{row.get('archiveMember') or '-'}` | "
            f"{row.get('found', False)} | {row.get('valid', False)} | "
            f"`{row.get('selector') or '-'}` | `{row.get('selectedPointerHex') or '-'}` | "
            f"{tile} | {active_order or '-'} | {maps or '-'} | {row.get('routeEvidenceCandidate', False)} | "
            f"{row.get('realRouteEvidenceCandidate', False)} | {row.get('syntheticDiagnostic', False)} | "
            f"{row.get('syntheticDiagnosticReason') or '-'} | {web_start} | {row.get('error') or '-'} |"
        )
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    selected = summary.get("selected") or {}
    rows = []
    missing_items = "".join(
        f"<li>{html.escape(item)}</li>"
        for item in summary["missingEvidence"]
    ) or "<li>-</li>"
    ref_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(row.get('path') or '-')}</code></td>"
        f"<td>{html.escape(', '.join(row.get('fields') or []) or '-')}</td>"
        "</tr>"
        for row in summary["evidenceRefs"]
    )
    for row in summary["rows"]:
        maps = ", ".join((row.get("fieldMaps") or [])[:8])
        if len(row.get("fieldMaps") or []) > 8:
            maps += ", ..."
        tile = "-"
        if row.get("x") is not None and row.get("y") is not None:
            tile = f"{row['x']},{row['y']}"
        active_order = ",".join(str(value) for value in row.get("activeDescriptorIndices") or [])
        if row.get("activeDescriptorCount") is not None:
            active_order = f"{row.get('activeDescriptorCount')}:[{active_order}]"
        slots_dir_name = Path(row.get("slotsDir") or "").name or "-"
        rows.append(
            "<tr>"
            f"<td><code>{html.escape(slots_dir_name)}</code></td>"
            f"<td><code>{html.escape(row.get('fileName') or '-')}</code></td>"
            f"<td>{html.escape(str(row.get('slotType') or '-'))}</td>"
            f"<td><code>{html.escape(row.get('archiveMember') or '-')}</code></td>"
            f"<td>{row.get('found', False)}</td>"
            f"<td>{row.get('valid', False)}</td>"
            f"<td><code>{html.escape(row.get('selector') or '-')}</code></td>"
            f"<td><code>{html.escape(row.get('selectedPointerHex') or '-')}</code></td>"
            f"<td>{html.escape(tile)}</td>"
            f"<td>{html.escape(active_order or '-')}</td>"
            f"<td>{html.escape(maps or '-')}</td>"
            f"<td>{row.get('routeEvidenceCandidate', False)}</td>"
            f"<td>{row.get('realRouteEvidenceCandidate', False)}</td>"
            f"<td>{row.get('syntheticDiagnostic', False)}</td>"
            f"<td>{html.escape(row.get('syntheticDiagnosticReason') or '-')}</td>"
            f"<td>{html.escape(row.get('webStartMap') or '-')}</td>"
            f"<td>{html.escape(row.get('error') or '-')}</td>"
            "</tr>"
        )
    return "\n".join([
        "<!doctype html>",
        '<html lang="en">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>Savedata Slot Scan</title>",
        "  <style>",
        "    body { margin: 24px; background: #101010; color: #eee; font: 14px system-ui, sans-serif; }",
        "    table { border-collapse: collapse; width: 100%; }",
        "    th, td { border: 1px solid #333; padding: 6px 8px; vertical-align: top; }",
        "    th { background: #1d1d1d; }",
        "    code { color: #f5d76e; }",
        "  </style>",
        "</head>",
        "<body>",
        "  <h1>Savedata Slot Scan</h1>",
        f"  <p>Scope: <code>{html.escape(summary['scope'])}</code>. Route: <code>{html.escape(summary['route'])}</code>.</p>",
        f"  <p>Archive scope: <code>{html.escape(summary['archiveScope'])}</code>.</p>",
        f"  <p>Search scope: <code>{html.escape(summary.get('searchScope') or '-')}</code>.</p>",
        (
            f"  <p>Status: <code>{html.escape(summary['status'])}</code>. "
            f"Promotion status: <code>{html.escape(summary['promotionStatus'])}</code>. "
            f"Proof found: {html.escape(str(summary['proofFound']))}. "
            f"Savedata slot scan proof found: {html.escape(str(summary['savedataSlotScanProofFound']))}. "
            "Failed savedata slot scan gates: "
            f"<code>{html.escape(','.join(summary['failedSavedataSlotScanGateIds']) or '-')}</code>. "
            f"Missing evidence count: {len(summary['missingEvidence'])}. "
            f"Evidence refs: {summary['evidenceRefCount']}.</p>"
        ),
        f"  <p>Found/valid: {summary['foundCount']} / {summary['validCount']}. Search found/valid: {summary.get('searchFoundCount', 0)} / {summary.get('searchValidCount', 0)}. Current selector candidates: {summary['currentSelectorCandidateCount']}. Synthetic diagnostics: {summary['syntheticDiagnosticCount']}.</p>",
        f"  <p>Diagnostic exclusion policy: {html.escape((summary.get('diagnosticExclusionPolicy') or {}).get('note') or '-')}</p>",
        f"  <p>Selected: <code>{html.escape(selected.get('fileName') or '-')}</code> selector <code>{html.escape(selected.get('selector') or '-')}</code>.</p>",
        f"  <p>{html.escape(summary['nextStep'])}</p>",
        "  <h2>Missing Evidence</h2>",
        f"  <ul>{missing_items}</ul>",
        "  <h2>Evidence Refs</h2>",
        f"  <table><thead><tr><th>path</th><th>fields</th></tr></thead><tbody>{ref_rows}</tbody></table>",
        "  <h2>Slot Rows</h2>",
        "  <table><thead><tr><th>dir</th><th>file</th><th>type</th><th>member</th><th>found</th><th>valid</th><th>selector</th><th>pointer</th><th>tile</th><th>active order</th><th>maps</th><th>route evidence</th><th>real evidence</th><th>diagnostic</th><th>diagnostic reason</th><th>web start</th><th>error</th></tr></thead>",
        f"  <tbody>{''.join(rows)}</tbody></table>",
        "</body>",
        "</html>",
        "",
    ])


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


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--slots-dir",
        type=Path,
        action="append",
        default=[],
        help="directory to scan; may be repeated. Defaults to SAVEDATA and SaveData.",
    )
    parser.add_argument(
        "--search-root",
        type=Path,
        action="append",
        default=[],
        help="additional captured savedata .dat file, .zip archive, or directory to scan; repeatable.",
    )
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--no-exe", action="store_true")
    parser.add_argument("--maps-index", type=Path, default=ROOT / "out" / "maps_runtime.js")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--json", action="store_true", help="print JSON to stdout instead of writing reports")
    args = parser.parse_args()

    slots_dirs = args.slots_dir or DEFAULT_SLOT_DIRS
    summary = scan_slots(
        slots_dirs,
        None if args.no_exe else args.exe,
        args.maps_index if args.maps_index.exists() else None,
        args.search_root,
    )
    if args.json:
        print(json.dumps(summary, ensure_ascii=False, indent=2))
    else:
        write_outputs(summary, args.out_dir)
        print(f"wrote savedata slot scan -> {args.out_dir / 'savedata_slot_scan.md'}")


if __name__ == "__main__":
    main()
