#!/usr/bin/env python3
"""Build a compact browser playtest route index."""
from __future__ import annotations

import argparse
import html
import json
import re
from pathlib import Path
from typing import Any
from collections import deque


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")


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


def optional_out_href(name: str) -> str | None:
    return name if (OUT / name).exists() else None


def web_url(map_name: str, **params: Any) -> str:
    query = {"map": map_name}
    query.update({key: value for key, value in params.items() if value is not None and value != ""})
    pairs = []
    for key, value in query.items():
        text = str(value).replace(",", "%2C")
        pairs.append(f"{key}={text}")
    return "../web/game.html?" + "&".join(pairs)


def find_route_assist_row(rows: list[dict]) -> dict:
    for row in rows:
        if row.get("source") == "map1_01a" and row.get("target") == "map2_02d":
            return row
    return {}


def best_blocker_exit(route_row: dict) -> dict:
    candidates = route_row.get("candidateExits") or []
    if not candidates:
        return {}
    return sorted(
        candidates,
        key=lambda row: (
            0 if row.get("autoTrigger") else 1,
            row.get("edgeDistance") if row.get("edgeDistance") is not None else 999,
            (row.get("targetHint") or {}).get("projectionDelta", 999),
            row.get("side") or "",
        ),
    )[0]


def strict_hotspot_candidate_by_side(packet: dict | None) -> dict[str, dict]:
    rows = (packet or {}).get("candidateRows") or []
    return {
        row.get("side"): row
        for row in rows
        if row.get("side")
    }


def enrich_route_row_with_strict_hotspot(route_row: dict, packet: dict | None) -> dict:
    by_side = strict_hotspot_candidate_by_side(packet)
    enriched = dict(route_row)
    exits = []
    for row in route_row.get("candidateExits") or []:
        exit_row = dict(row)
        strict_row = by_side.get(row.get("side")) or {}
        if strict_row:
            exit_row["strictHotspotStatus"] = strict_row.get("promotionStatus")
            exit_row["blockReasons"] = strict_row.get("blockReasons") or []
            exit_row["strictReviewUrl"] = (strict_row.get("reviewUrls") or {}).get("reviewPacketUrl") or ""
            exit_row["strictRouteAssistUrl"] = (strict_row.get("reviewUrls") or {}).get("routeAssistUrl") or ""
        exits.append(exit_row)
    enriched["candidateExits"] = exits
    return enriched


def compact_strict_hotspot_candidate(row: dict) -> dict:
    source_tile = row.get("sourceTile") or {}
    target_spawn = row.get("targetSpawn") or {}
    coordinate = row.get("coordinateEvidence") or {}
    variant = row.get("variantEvidence") or {}
    review = row.get("reviewEvidence") or {}
    urls = row.get("reviewUrls") or {}
    block_reasons = row.get("blockReasons") or []
    return {
        "side": row.get("side"),
        "sourceTile": {
            "x": source_tile.get("x"),
            "y": source_tile.get("y"),
            "geometryStandable": source_tile.get("geometryStandable"),
            "sourceOriginalStandable": source_tile.get("sourceOriginalStandable"),
            "centerPairKey": source_tile.get("centerPairKey"),
        },
        "targetSpawn": {
            "side": target_spawn.get("side"),
            "x": target_spawn.get("x"),
            "y": target_spawn.get("y"),
            "targetSpawnOriginalStandable": target_spawn.get("targetSpawnOriginalStandable"),
            "inwardMoveAllowed": target_spawn.get("inwardMoveAllowed"),
            "centerPairKey": target_spawn.get("centerPairKey"),
        },
        "promotionStatus": row.get("promotionStatus"),
        "coordinateStatus": coordinate.get("status"),
        "coordinatePromotable": coordinate.get("promotable") is True,
        "coordinateHitCount": int(coordinate.get("xyHitCount") or 0) + int(coordinate.get("yxHitCount") or 0),
        "variantStrictCoordinateEvidenceFound": variant.get("strictCoordinateEvidenceFound") is True,
        "variantCurrentRootHitCount": variant.get("currentRootHitCount"),
        "variantCharacterDescriptorHitCount": variant.get("characterDescriptorHitCount"),
        "routeReviewRowCount": review.get("routeReviewRowCount"),
        "eventTransitionCount": review.get("eventTransitionCount"),
        "blockReasons": block_reasons,
        "blockReasonCount": len(block_reasons),
        "routeAssistUrl": urls.get("routeAssistUrl") or "",
        "targetSpawnReviewUrl": urls.get("targetSpawnReviewUrl") or urls.get("targetReviewUrl") or "",
    }


def strict_hotspot_runtime_checklist(packet: dict | None) -> dict:
    packet = packet or {}
    candidates = [
        compact_strict_hotspot_candidate(row)
        for row in packet.get("candidateRows") or []
    ]
    return {
        "source": packet.get("source") or "",
        "target": packet.get("target") or "",
        "promotionStatus": packet.get("promotionStatus") or "",
        "proofFound": packet.get("proofFound") is True,
        "strictSourceHotspotProofFound": packet.get("strictSourceHotspotProofFound") is True,
        "tileHotspotConfirmed": packet.get("tileHotspotConfirmed") is True,
        "rejectionClassification": packet.get("strictHotspotRejectionClassification") or "",
        "candidateCount": packet.get("candidateCount") or len(candidates),
        "rejectedCandidateCount": len([row for row in candidates if row.get("promotionStatus") == "blocked"]),
        "missingEvidence": packet.get("missingEvidence") or [],
        "failedGateIds": packet.get("failedStrictSourceHotspotReviewGateIds") or [],
        "candidateRows": candidates,
    }


def route_assist_graph_edges(playable_progress: dict, route_row: dict) -> list[dict]:
    edges = []
    for edge in playable_progress.get("confirmedEdges") or []:
        point = ((edge.get("points") or [{}])[0]) or {}
        start_tile = f"{point.get('x')},{point.get('y')}" if point.get("x") is not None else None
        spawn_tile = (
            f"{point.get('spawnX')},{point.get('spawnY')}"
            if point.get("spawnX") is not None and point.get("spawnY") is not None
            else None
        )
        edges.append({
            "source": edge.get("source"),
            "target": edge.get("target"),
            "kind": "confirmed",
            "rank": 0,
            "status": "confirmed",
            "openUrl": web_url(edge.get("source") or "", startTile=start_tile, focusTile=start_tile),
            "nextUrl": web_url(edge.get("target") or "", startTile=spawn_tile, trialTransitions="routeAssist") if spawn_tile else "",
            "targetUrl": web_url(edge.get("target") or "", overview=1),
            "detail": f"{point.get('x')},{point.get('y')} -> {point.get('spawnX')},{point.get('spawnY')}",
        })
    blocker_exit = best_blocker_exit(route_row)
    if blocker_exit:
        sample = blocker_exit.get("sample") or {}
        hint = blocker_exit.get("targetHint") or {}
        edges.append({
            "source": route_row.get("source"),
            "target": route_row.get("target"),
            "kind": "routeAssist-blocker",
            "rank": 1,
            "status": route_row.get("promotionStatus"),
            "openUrl": blocker_exit.get("routeAssistUrl"),
            "targetUrl": blocker_exit.get("targetSpawnUrl"),
            "detail": (
                f"{blocker_exit.get('side')} {sample.get('x')},{sample.get('y')} -> "
                f"{hint.get('side')} {hint.get('x')},{hint.get('y')}"
            ),
            "strictHotspotStatus": blocker_exit.get("strictHotspotStatus"),
            "blockReasons": blocker_exit.get("blockReasons") or [],
            "promotionBlockSummary": "; ".join(blocker_exit.get("blockReasons") or []),
        })
    for edge in playable_progress.get("saveSelectorCandidateReachableEdges") or []:
        source = edge.get("source")
        target = edge.get("target")
        if not source or not target:
            continue
        if source == route_row.get("source") and target == route_row.get("target"):
            continue
        edges.append({
            "source": source,
            "target": target,
            "kind": "save-selector-trial",
            "rank": 2,
            "status": edge.get("promotionRisk") or "candidate",
            "openUrl": web_url(source, trialTransitions="routeAssist", transitionTarget=target, overview=1),
            "targetUrl": web_url(target, overview=1),
            "detail": ",".join(edge.get("selectors") or []) or "-",
        })
    for edge in playable_progress.get("mapExitTrialReachableEdges") or []:
        source = edge.get("source")
        target = edge.get("target")
        if not source or not target:
            continue
        tile = f"{edge.get('x')},{edge.get('y')}"
        auto = edge.get("standable") is True and edge.get("side") in {"top", "bottom", "left", "right"}
        edges.append({
            "source": source,
            "target": target,
            "kind": "map-exit-trial",
            "rank": 3 if auto else 4,
            "status": edge.get("status"),
            "openUrl": web_url(
                source,
                startTile=tile,
                focusTile=tile,
                collision=1,
                overview=1,
                trialTransitions="routeAssist",
                transitionTarget=target,
            ),
            "targetUrl": web_url(target, overview=1),
            "detail": f"{edge.get('side')} {tile}",
        })
    best_by_pair = {}
    for edge in edges:
        source = edge.get("source")
        target = edge.get("target")
        if not source or not target or source == target:
            continue
        key = (source, target)
        previous = best_by_pair.get(key)
        if previous is None or (edge.get("rank", 99), edge.get("kind", "")) < (previous.get("rank", 99), previous.get("kind", "")):
            best_by_pair[key] = edge
    return sorted(best_by_pair.values(), key=lambda row: (row["source"], row["rank"], row["target"]))


def route_assist_paths(start_map: str, edges: list[dict], limit: int = 64) -> tuple[list[str], list[dict]]:
    adjacency: dict[str, list[dict]] = {}
    for edge in edges:
        adjacency.setdefault(edge["source"], []).append(edge)
    for rows in adjacency.values():
        rows.sort(key=lambda row: (row.get("rank", 99), row.get("target") or ""))
    seen = {start_map}
    parents: dict[str, tuple[str, dict]] = {}
    order = [start_map]
    queue = deque([start_map])
    while queue:
        current = queue.popleft()
        for edge in adjacency.get(current, []):
            target = edge["target"]
            if target in seen:
                continue
            seen.add(target)
            parents[target] = (current, edge)
            order.append(target)
            queue.append(target)
    samples = []
    for target in order[1:limit + 1]:
        steps = []
        cursor = target
        while cursor in parents:
            previous, edge = parents[cursor]
            steps.append(edge)
            cursor = previous
        steps.reverse()
        samples.append({
            "target": target,
            "hopCount": len(steps),
            "path": [steps[0]["source"], *[step["target"] for step in steps]] if steps else [target],
            "steps": [
                {
                    "source": step.get("source"),
                    "target": step.get("target"),
                    "kind": step.get("kind"),
                    "status": step.get("status"),
                    "detail": step.get("detail"),
                    "openUrl": step.get("openUrl"),
                    "nextUrl": step.get("nextUrl"),
                    "targetUrl": step.get("targetUrl"),
                    "strictHotspotStatus": step.get("strictHotspotStatus"),
                    "blockReasons": step.get("blockReasons") or [],
                    "promotionBlockSummary": step.get("promotionBlockSummary") or "",
                }
                for step in steps
            ],
            "openUrl": web_url(target, trialTransitions="routeAssist", overview=1),
        })
    return order, samples


def add_query_params(href: str | None, **params: Any) -> str:
    if not href:
        return ""
    separator = "&" if "?" in href else "?"
    pairs = []
    for key, value in params.items():
        if value is None or value == "":
            continue
        pairs.append(f"{key}={str(value).replace(',', '%2C')}")
    return href + (separator + "&".join(pairs) if pairs else "")


def reciprocal_return_path_sample(reciprocal_transition_candidates: dict | None) -> dict | None:
    rows = (reciprocal_transition_candidates or {}).get("rows") or []
    if not rows:
        return None
    reverse = (rows[0].get("reverse") or {})
    source = reverse.get("source")
    target = reverse.get("target")
    if not source or not target:
        return None
    exits = reverse.get("exitCandidates") or []
    if not exits:
        return None
    candidate = exits[0]
    hint = candidate.get("targetHint") or {}
    open_url = web_url(
        source,
        startTile=f"{candidate.get('x')},{candidate.get('y')}",
        focusTile=f"{candidate.get('x')},{candidate.get('y')}",
        collision=1,
        overview=1,
        trialTransitions="routeAssist",
        transitionTarget=target,
    )
    target_url = web_url(
        target,
        startTile=f"{hint.get('x')},{hint.get('y')}" if hint.get("x") is not None else None,
        focusTile=f"{hint.get('x')},{hint.get('y')}" if hint.get("x") is not None else None,
        overview=1,
    )
    detail = (
        f"{candidate.get('side')} {candidate.get('x')},{candidate.get('y')} -> "
        f"{hint.get('side')} {hint.get('x')},{hint.get('y')}"
    )
    return {
        "target": target,
        "hopCount": 1,
        "path": [source, target],
        "steps": [
            {
                "source": source,
                "target": target,
                "kind": "reciprocal-return-trial",
                "status": reverse.get("promotionStatus"),
                "detail": detail,
                "openUrl": open_url,
                "nextUrl": None,
                "targetUrl": target_url,
            }
        ],
        "openUrl": web_url(target, trialTransitions="routeAssist", overview=1),
        "goalStartUrl": add_query_params(open_url, routeGoal=target),
    }


def build_summary(
    playable_progress: dict,
    route_assist_frontier: list[dict],
    route_blocker_matrix: dict,
    real_savedata_gap: dict | None = None,
    savedata_sample_deltas: dict | None = None,
    savedata_slot_scan: dict | None = None,
    reciprocal_transition_candidates: dict | None = None,
    runtime_title_start_context: dict | None = None,
    strict_event_source_coverage: dict | None = None,
    strict_source_hotspot_external_review_packet: dict | None = None,
    route_promotion_external_proof_handoff: dict | None = None,
) -> dict:
    start_map = playable_progress.get("startMap") or "map1_02b"
    first_route = (playable_progress.get("confirmedEntryRoutes") or [{}])[0]
    start_tile = first_route.get("startTile") or {"x": 11, "y": 12}
    start_tile_text = f"{start_tile.get('x')},{start_tile.get('y')}"
    route_row = enrich_route_row_with_strict_hotspot(
        find_route_assist_row(route_assist_frontier),
        strict_source_hotspot_external_review_packet,
    )
    candidate_exits = []
    for row in route_row.get("candidateExits") or []:
        sample = row.get("sample") or {}
        hint = row.get("targetHint") or {}
        candidate_exits.append({
            "side": row.get("side"),
            "tile": {"x": sample.get("x"), "y": sample.get("y")},
            "autoTrigger": row.get("autoTrigger"),
            "edgeDistance": row.get("edgeDistance"),
            "targetSpawn": {"x": hint.get("x"), "y": hint.get("y"), "side": hint.get("side")},
            "routeAssistUrl": row.get("routeAssistUrl"),
            "targetSpawnUrl": row.get("targetSpawnUrl"),
            "strictHotspotStatus": row.get("strictHotspotStatus"),
            "blockReasons": row.get("blockReasons") or [],
            "promotionBlockSummary": "; ".join(row.get("blockReasons") or []),
        })
    route_graph_edges = route_assist_graph_edges(playable_progress, route_row)
    route_assist_reachable, route_assist_paths_sample = route_assist_paths(start_map, route_graph_edges)
    for sample in route_assist_paths_sample:
        target = sample.get("target")
        if target:
            sample["goalStartUrl"] = web_url(
                start_map,
                startTile=start_tile_text,
                trialTransitions="routeAssist",
                routeGoal=target,
            )
    post_blocker_edges = [
        {
            "source": edge.get("source"),
            "target": edge.get("target"),
            "selectors": edge.get("selectors") or [],
            "leafPointers": edge.get("leafPointers") or [],
            "openUrl": web_url(edge.get("source") or "map2_02d", trialTransitions="routeAssist", overview=1),
            "targetUrl": web_url(edge.get("target") or "map2_18d", overview=1),
        }
        for edge in playable_progress.get("saveSelectorCandidateReachableEdges") or []
        if edge.get("source") == route_row.get("target") and edge.get("target") != route_row.get("source")
    ][:8]
    reciprocal_transition_candidates = reciprocal_transition_candidates or {}
    reciprocal_rows = reciprocal_transition_candidates.get("rows") or []
    first_reciprocal = reciprocal_rows[0] if reciprocal_rows else {}
    reciprocal_reverse = first_reciprocal.get("reverse") or {}
    reciprocal_return = {
        "url": "reciprocal_transition_candidates.html",
        "source": reciprocal_reverse.get("source"),
        "target": reciprocal_reverse.get("target"),
        "classification": reciprocal_reverse.get("classification"),
        "promotionStatus": reciprocal_reverse.get("promotionStatus"),
        "selectorRecordCount": reciprocal_reverse.get("selectorRecordCount"),
        "strictEventRecordCount": reciprocal_reverse.get("strictEventRecordCount"),
        "exitCandidateCount": reciprocal_reverse.get("exitCandidateCount"),
        "reciprocalExitCandidateCount": reciprocal_reverse.get("reciprocalExitCandidateCount"),
        "autoExitCandidateCount": reciprocal_reverse.get("autoExitCandidateCount"),
    }
    reciprocal_path_sample = reciprocal_return_path_sample(reciprocal_transition_candidates)
    if reciprocal_path_sample:
        route_assist_paths_sample.append(reciprocal_path_sample)
    real_savedata_gap = real_savedata_gap or {}
    savedata_sample_deltas = savedata_sample_deltas or {}
    savedata_slot_scan = savedata_slot_scan or {}
    runtime_title_start_context = runtime_title_start_context or {}
    input_transition = runtime_title_start_context.get("inputTransitionContext") or {}
    title_start_context = {
        "reportUrl": "runtime_title_start_context.json",
        "selectedPointer": runtime_title_start_context.get("selectedPointerStaticHex"),
        "selector": runtime_title_start_context.get("selectedPointerSelector"),
        "livePointerClassification": runtime_title_start_context.get("livePointerClassification"),
        "selector8FieldRecordSpanHex": runtime_title_start_context.get("selector8FieldRecordSpanHex"),
        "titleResourceTailSpanHex": runtime_title_start_context.get("titleResourceTailSpanHex"),
        "fieldMapCount": runtime_title_start_context.get("fieldMapCount"),
        "promotionStatus": runtime_title_start_context.get("promotionStatus"),
        "routePromotionAllowed": runtime_title_start_context.get("routePromotionAllowed"),
        "inputTransition": {
            "fromSelector": input_transition.get("fromSelector"),
            "toSelector": input_transition.get("toSelector"),
            "toPointer": input_transition.get("toPointerHex"),
            "toSelectedRoot": input_transition.get("toSelectedRootHex"),
            "classification": input_transition.get("classification"),
            "promotionStatus": input_transition.get("promotionStatus"),
            "toFieldMapCount": input_transition.get("toFieldMapCount"),
            "toLinkedCns": input_transition.get("toLinkedCns") or [],
            "toReferencedCns": input_transition.get("toReferencedCns") or [],
            "toResourcePayloads": [
                {
                    "name": row.get("name"),
                    "kind": row.get("kind"),
                    "width": row.get("width"),
                    "height": row.get("height"),
                    "bpp": row.get("bpp"),
                }
                for row in input_transition.get("toResourcePayloads") or []
            ],
            "toSelectionReaderCount": input_transition.get("toSelectionReaderCount"),
            "toSelectionWriterCount": input_transition.get("toSelectionWriterCount"),
        },
        "candidateMaps": (runtime_title_start_context.get("candidateMaps") or [])[:6],
    }
    real_gap_url = optional_out_href("save_selector_real_savedata_evidence_gap.html")
    sample_deltas_url = optional_out_href("savedata_sample_deltas.html")
    slot_scan_url = optional_out_href("savedata_slot_scan.html")
    savedata_evidence = {
        "realGapUrl": real_gap_url,
        "realGapStatus": "available" if real_gap_url else "parked until external savedata refresh",
        "sampleDeltasUrl": sample_deltas_url,
        "sampleDeltasStatus": "available" if sample_deltas_url else "parked until external savedata refresh",
        "slotScanUrl": slot_scan_url,
        "slotScanReportStatus": "available" if slot_scan_url else "parked until external savedata refresh",
        "slotScanStatus": savedata_slot_scan.get("status") or "unknown",
        "slotScanFoundCount": savedata_slot_scan.get("foundCount"),
        "slotScanValidCount": savedata_slot_scan.get("validCount"),
        "slotScanRealRouteEvidenceCount": savedata_slot_scan.get("realRouteEvidenceCandidateCount") or 0,
        "slotScanSyntheticDiagnosticCount": savedata_slot_scan.get("syntheticDiagnosticCount") or 0,
        "realCandidateCount": real_savedata_gap.get("realCandidateCount"),
        "validRealCandidateCount": real_savedata_gap.get("validRealCandidateCount"),
        "validRealCandidatesAllBlocked": real_savedata_gap.get("validRealCandidatesAllBlocked"),
        "validRealCandidateBlockReasonCounts": real_savedata_gap.get(
            "validRealCandidateBlockReasonCounts"
        ) or {},
        "archiveCandidateCount": real_savedata_gap.get("archiveCandidateCount"),
        "archiveSkippedCount": real_savedata_gap.get("archiveSkippedCount"),
        "currentSelectorRealSaveCount": real_savedata_gap.get("currentSelectorRealSaveCount"),
        "routePromotionRealSaveCount": real_savedata_gap.get("routePromotionRealSaveCount"),
        "publicSampleSelectors": real_savedata_gap.get("publicSampleSelectors") or [],
        "sampleDeltaSelectorDistinguishingOffsetCount": savedata_sample_deltas.get("selectorDistinguishingOffsetCount"),
        "sampleDeltaCurrentSelectorPublicSampleCount": savedata_sample_deltas.get("currentSelectorPublicSampleCount"),
        "sampleDeltaRoutePairPublicSampleCount": savedata_sample_deltas.get("routePairPublicSampleCount"),
        "sampleDeltaClosestSourceTargetPair": savedata_sample_deltas.get("closestPublicSourceTargetPair") or {},
        "publicSearchNotes": real_savedata_gap.get("publicSearchNotes") or [],
        "publicCurrentFrontierCovered": real_savedata_gap.get("publicCurrentFrontierCovered"),
        "syntheticDiagnosticExcluded": real_savedata_gap.get("syntheticDiagnosticExcluded"),
        "requiredSelector": "2:0",
        "requiredSelectedPointer": "0x00540714",
        "requiredSource": route_row.get("source"),
        "requiredTarget": route_row.get("target"),
        "requiredSaveBytes": [
            {"offset": "0x0002", "value": "0x02", "label": "scene selector group"},
            {"offset": "0x0003", "value": "0x00", "label": "scene selector slot"},
        ],
        "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",
        ],
        "savedatUrlDatExample": "../web/game.html?savedatUrl=../SAVEDATA/savedat2.dat",
        "savedatUrlZipExample": "../web/game.html?savedatUrl=../SAVEDATA/savedat2.zip",
        "savedatScanUrl": "../web/game.html?savedatScan=1",
    }
    strict_event_source_coverage = strict_event_source_coverage or {}
    route_promotion_external_proof_handoff = route_promotion_external_proof_handoff or {}
    strict_hotspot_checklist = strict_hotspot_runtime_checklist(strict_source_hotspot_external_review_packet)
    external_input_checklist = [
        {
            "id": row.get("id"),
            "neededInput": row.get("neededInput"),
            "currentState": row.get("currentState"),
            "acceptedSignal": row.get("acceptedSignal"),
            "refresh": row.get("refresh"),
        }
        for row in route_promotion_external_proof_handoff.get("externalInputChecklist") or []
    ]
    strict_route = strict_event_source_coverage.get("routeStatus") or {}
    strict_confirmed = strict_event_source_coverage.get("confirmedReference") or {}
    strict_event_coverage = {
        "reportUrl": "strict_event_source_coverage.html",
        "fieldMapCount": strict_event_source_coverage.get("fieldMapCount"),
        "strictEventRecordCount": strict_event_source_coverage.get("strictEventRecordCount"),
        "strictEventSourceMapCount": strict_event_source_coverage.get("strictEventSourceMapCount"),
        "strictEventPairCount": strict_event_source_coverage.get("strictEventPairCount"),
        "strictEventSourceCoveragePercent": strict_event_source_coverage.get("strictEventSourceCoveragePercent"),
        "promotionStatus": strict_event_source_coverage.get("promotionStatus"),
        "promotionAllowed": strict_event_source_coverage.get("promotionAllowed"),
        "reachableStrictSourceUncovered": strict_event_source_coverage.get("reachableStrictSourceUncovered") or [],
        "route": {
            "source": strict_route.get("source"),
            "target": strict_route.get("target"),
            "directStrictPairCount": strict_route.get("directStrictPairCount"),
            "sourceStrictEventCount": strict_route.get("sourceStrictEventCount"),
            "targetStrictIncomingCount": strict_route.get("targetStrictIncomingCount"),
            "sourceAsEventSourceStrictClusterCount": strict_route.get("sourceAsEventSourceStrictClusterCount"),
            "sourceAsEventTargetStrictClusterCount": strict_route.get("sourceAsEventTargetStrictClusterCount"),
            "sourceAsManifestStrictClusterCount": strict_route.get("sourceAsManifestStrictClusterCount"),
            "targetStrictClusterRoleCount": strict_route.get("targetStrictClusterRoleCount"),
            "promotionStatus": strict_route.get("promotionStatus"),
        },
        "confirmedReference": {
            "source": strict_confirmed.get("source"),
            "target": strict_confirmed.get("target"),
            "directStrictPairCount": strict_confirmed.get("directStrictPairCount"),
            "confirmedReviewCount": strict_confirmed.get("confirmedReviewCount"),
            "rejectedReviewCount": strict_confirmed.get("rejectedReviewCount"),
        },
        "conclusion": strict_event_source_coverage.get("conclusion"),
    }
    return {
        "title": "Hwanse Web Playtest Route",
        "status": "routeAssist-required",
        "confirmedStart": {
            "map": start_map,
            "startTile": start_tile,
            "url": web_url(start_map, startTile=start_tile_text),
        },
        "assistStart": {
            "map": start_map,
            "startTile": start_tile,
            "url": web_url(start_map, startTile=start_tile_text, trialTransitions="routeAssist"),
        },
        "confirmedReachable": playable_progress.get("reachableFromStart") or [],
        "confirmedEdges": playable_progress.get("confirmedEdges") or [],
        "blocker": {
            "source": route_row.get("source"),
            "target": route_row.get("target"),
            "status": route_row.get("status"),
            "promotionStatus": route_row.get("promotionStatus"),
            "missingEvidence": route_row.get("missingEvidence") or [],
            "sourceRouteAssistUrl": route_row.get("sourceRouteAssistUrl"),
            "targetOverviewUrl": route_row.get("targetOverviewUrl"),
            "evidenceMatrixUrl": "route_blocker_evidence_matrix.html",
            "evidenceMatrixStatus": route_blocker_matrix.get("promotionStatus"),
            "externalProofHandoffUrl": route_blocker_matrix.get("externalProofHandoffUrl")
            or "route_promotion_external_proof_handoff.html",
            "externalProofHandoffPackageIds": (
                route_blocker_matrix.get("externalProofHandoffExpectedPackageIds") or []
            ),
            "externalProofHandoffStatus": route_promotion_external_proof_handoff.get("promotionStatus"),
            "externalProofHandoffAchieved": route_promotion_external_proof_handoff.get("achieved"),
            "externalProofRequiredInputs": external_input_checklist,
            "completionAuditUrl": "completion_audit.html",
            "goalCompletionChecklistUrl": "goal_completion_checklist.html",
            "strictEventCoverageUrl": strict_event_coverage.get("reportUrl"),
            "strictEventCoverageStatus": strict_event_coverage.get("promotionStatus"),
            "strictHotspotChecklist": strict_hotspot_checklist,
        },
        "candidateExits": candidate_exits,
        "reciprocalReturn": reciprocal_return,
        "postBlockerCandidateEdges": post_blocker_edges,
        "titleStartContext": title_start_context,
        "savedataEvidence": savedata_evidence,
        "strictEventCoverage": strict_event_coverage,
        "routeAssistGraphEdgeCount": len(route_graph_edges),
        "routeAssistReachableCount": len(route_assist_reachable),
        "routeAssistReachable": route_assist_reachable,
        "routeAssistPathSamples": route_assist_paths_sample,
        "playtestNotes": [
            "Confirmed gameplay reaches map1_02b and map1_01a only.",
            f"RouteAssist can currently navigate {len(route_assist_reachable)} maps through confirmed, selector-only, and geometry-only trial edges; this is a playtest graph, not normal-route proof.",
            "Use assistStart or the in-game 보조 button to continue through trial-only routeAssist candidates.",
            (
                "The map1_01a -> map1_02b return candidate is listed in "
                "[reciprocal transition candidates](reciprocal_transition_candidates.html); it is selector/geometry-only "
                "and remains manual-review-only. It is also available as a route path target when routeAssist is enabled."
            ),
            "If a captured gameplay save is copied into SAVEDATA/ or original-case SaveData/, open [savedatUrl dat example](../web/game.html?savedatUrl=../SAVEDATA/savedat2.dat) or [savedatUrl zip example](../web/game.html?savedatUrl=../SAVEDATA/savedat2.zip) to load it and check whether it covers selector 2:0.",
            "Use [scan SAVEDATA](../web/game.html?savedatScan=1) to scan SAVEDATA/savedat1.dat through savedat9.dat, SAVEDATA/savedat1.zip through savedat9.zip, and matching SaveData aliases, then load the first selector 2:0 candidate if one is present.",
            "The terminal real-savedata gap scan also accepts downloaded zip archives and checks savedat*.dat members without manual extraction.",
            (
                "Savedata public sample byte deltas report is parked until an external savedata refresh; "
                "current cached values still show selector 2:0 public samples `0` and route-pair public samples `0`."
                if not sample_deltas_url
                else "Savedata public sample byte deltas are summarized in [savedata sample deltas](savedata_sample_deltas.html); they still show selector 2:0 public samples `0` and route-pair public samples `0`."
            ),
            (
                "Savedata evidence is still blocked: "
                f"SAVEDATA scan status `{savedata_evidence['slotScanStatus']}`, "
                f"current selector real saves `{savedata_evidence['currentSelectorRealSaveCount']}`."
            ),
            "Latest public savedata search found no additional non-synthetic selector 2:0 save beyond the preserved calibration samples.",
            (
                "Original EXE title/start diagnostics are listed in "
                "[runtime title start context](runtime_title_start_context.json); the live pointer is classified as "
                f"`{title_start_context.get('livePointerClassification')}` and remains diagnostic-only."
            ),
            (
                "The observed original input transition is "
                f"`{(title_start_context.get('inputTransition') or {}).get('fromSelector')} -> "
                f"{(title_start_context.get('inputTransition') or {}).get('toSelector')}` and resolves to non-field-map "
                "resource payloads, so it is not normal map-start evidence."
            ),
            (
                "Strict event source coverage is summarized in "
                "[strict event source coverage](strict_event_source_coverage.html); "
                f"{route_row.get('source')} is not an extracted strict event source, so "
                f"{route_row.get('source')} -> {route_row.get('target')} still needs a strict source hotspot, "
                "captured selector 2:0 save, or runtime selected-pointer trace."
            ),
            (
                "RouteAssist blocker candidates keep their strict-hotspot block reasons in the runtime route data, "
                "so geometry-only movement stays visibly trial-only until strict event, captured save, or runtime trace evidence exists."
            ),
            (
                "The runtime route blocker marker now carries a strict-hotspot checklist with "
                f"{strict_hotspot_checklist.get('candidateCount')} candidates, rejection "
                f"`{strict_hotspot_checklist.get('rejectionClassification')}`, and "
                f"{len(strict_hotspot_checklist.get('missingEvidence') or [])} missing proof items."
            ),
            (
                "External proof intake is summarized in "
                "[route promotion external proof handoff](route_promotion_external_proof_handoff.html); "
                "it links the captured savedata intake, runtime trace feasibility, strict hotspot packet, "
                "selected-root packet, predecessor fill packet, and opcode24 producer packet."
            ),
            (
                "The route index mirrors the handoff Required External Inputs table so the three accepted proof "
                "paths stay visible from the playtest page: real selector 2:0 save, normal-route runtime trace, "
                "and strict source hotspot."
            ),
            (
                "The same route index links the completion audit so the top-level achieved=false status, "
                "prompt-to-artifact checklist, and partial/missing requirements stay one click from the playtest route."
            ),
            "These routeAssist steps are for playtesting and do not promote normal-route evidence.",
        ],
    }


def markdown(summary: dict) -> str:
    def md_report(label: str, href: str | None, status: str | None = None) -> str:
        if href:
            return f"[{label}]({href})"
        return f"{label} ({status or 'not generated'})"

    start = summary["confirmedStart"]
    assist = summary["assistStart"]
    blocker = summary["blocker"]
    lines = [
        "# Hwanse Web Playtest Route",
        "",
        f"- status: `{summary['status']}`",
        f"- confirmed start: [{start['map']} `{start['startTile']['x']},{start['startTile']['y']}`]({start['url']})",
        f"- routeAssist start: [open]({assist['url']})",
        f"- confirmed reachable: {', '.join(summary.get('confirmedReachable') or [])}",
        f"- routeAssist reachable: {summary.get('routeAssistReachableCount')} maps",
        f"- routeAssist reachable maps: {', '.join(summary.get('routeAssistReachable') or [])}",
        f"- blocker: `{blocker.get('source')} -> {blocker.get('target')}` / {blocker.get('promotionStatus')}",
        f"- evidence: [{blocker.get('evidenceMatrixUrl')}]({blocker.get('evidenceMatrixUrl')}) / [{blocker.get('strictEventCoverageUrl')}]({blocker.get('strictEventCoverageUrl')}) / [{blocker.get('externalProofHandoffUrl')}]({blocker.get('externalProofHandoffUrl')})",
        f"- completion audit: [{blocker.get('completionAuditUrl')}]({blocker.get('completionAuditUrl')})",
        f"- goal checklist: [{blocker.get('goalCompletionChecklistUrl')}]({blocker.get('goalCompletionChecklistUrl')})",
        f"- external proof packages: {', '.join(blocker.get('externalProofHandoffPackageIds') or [])}",
        f"- external proof status: `{blocker.get('externalProofHandoffStatus')}` / achieved `{blocker.get('externalProofHandoffAchieved')}`",
        "",
        "## Playtest Notes",
        "",
    ]
    lines.extend(f"- {item}" for item in summary["playtestNotes"])
    external_inputs = blocker.get("externalProofRequiredInputs") or []
    lines.extend([
        "",
        "## Required External Inputs",
        "",
        "| id | needed input | current state | accepted signal | refresh |",
        "| --- | --- | --- | --- | --- |",
    ])
    for row in external_inputs:
        lines.append(
            f"| `{row.get('id')}` | {row.get('neededInput')} | {row.get('currentState')} | "
            f"{row.get('acceptedSignal')} | `{row.get('refresh')}` |"
        )
    strict_hotspot = blocker.get("strictHotspotChecklist") or {}
    lines.extend([
        "",
        "## Strict Hotspot Runtime Checklist",
        "",
        f"- source-target: `{strict_hotspot.get('source')} -> {strict_hotspot.get('target')}`",
        f"- status: `{strict_hotspot.get('promotionStatus')}` / proof `{strict_hotspot.get('proofFound')}` / tile hotspot `{strict_hotspot.get('tileHotspotConfirmed')}`",
        f"- rejection: `{strict_hotspot.get('rejectionClassification')}`",
        f"- candidates: `{strict_hotspot.get('candidateCount')}` / rejected `{strict_hotspot.get('rejectedCandidateCount')}`",
        f"- missing evidence: {', '.join(strict_hotspot.get('missingEvidence') or []) or '-'}",
        "",
        "| side | source tile | source standable | target spawn | coordinate status | reviews/events | block reasons | links |",
        "| --- | --- | --- | --- | --- | --- | --- | --- |",
    ])
    for row in strict_hotspot.get("candidateRows") or []:
        source_tile = row.get("sourceTile") or {}
        target_spawn = row.get("targetSpawn") or {}
        links = []
        if row.get("routeAssistUrl"):
            links.append(f"[assist]({row.get('routeAssistUrl')})")
        if row.get("targetSpawnReviewUrl"):
            links.append(f"[spawn]({row.get('targetSpawnReviewUrl')})")
        lines.append(
            f"| {row.get('side')} | `{source_tile.get('x')},{source_tile.get('y')}` | "
            f"geom `{source_tile.get('geometryStandable')}` / original `{source_tile.get('sourceOriginalStandable')}` | "
            f"{target_spawn.get('side')} `{target_spawn.get('x')},{target_spawn.get('y')}` original `{target_spawn.get('targetSpawnOriginalStandable')}` | "
            f"`{row.get('coordinateStatus')}` / promotable `{row.get('coordinatePromotable')}` | "
            f"{row.get('routeReviewRowCount')}/{row.get('eventTransitionCount')} | "
            f"{'; '.join(row.get('blockReasons') or []) or '-'} | {' / '.join(links) or '-'} |"
        )
    strict = summary.get("strictEventCoverage") or {}
    strict_route = strict.get("route") or {}
    strict_confirmed = strict.get("confirmedReference") or {}
    lines.extend([
        "",
        "## Strict Event Coverage",
        "",
        f"- report: [{strict.get('reportUrl')}]({strict.get('reportUrl')})",
        f"- field maps: `{strict.get('fieldMapCount')}` / strict event records: `{strict.get('strictEventRecordCount')}` / strict source maps: `{strict.get('strictEventSourceMapCount')}` ({strict.get('strictEventSourceCoveragePercent')}%)",
        f"- route: `{strict_route.get('source')} -> {strict_route.get('target')}` / direct strict pairs `{strict_route.get('directStrictPairCount')}` / source strict events `{strict_route.get('sourceStrictEventCount')}` / target strict incoming `{strict_route.get('targetStrictIncomingCount')}`",
        f"- route cluster roles: source-as-event-source `{strict_route.get('sourceAsEventSourceStrictClusterCount')}` / source-as-target `{strict_route.get('sourceAsEventTargetStrictClusterCount')}` / source-as-manifest `{strict_route.get('sourceAsManifestStrictClusterCount')}` / target roles `{strict_route.get('targetStrictClusterRoleCount')}`",
        f"- confirmed reference: `{strict_confirmed.get('source')} -> {strict_confirmed.get('target')}` / direct strict pairs `{strict_confirmed.get('directStrictPairCount')}` / confirmed reviews `{strict_confirmed.get('confirmedReviewCount')}` / rejected reviews `{strict_confirmed.get('rejectedReviewCount')}`",
        f"- reachable maps without strict source coverage: {', '.join(strict.get('reachableStrictSourceUncovered') or []) or '-'}",
        f"- promotion: `{strict.get('promotionStatus')}` / allowed `{strict.get('promotionAllowed')}`",
    ])
    savedata = summary.get("savedataEvidence") or {}
    required = ", ".join(
        f"`{row.get('offset')}={row.get('value')}`"
        for row in savedata.get("requiredSaveBytes") or []
    )
    lines.extend([
        "",
        "## Savedata Evidence",
        "",
        f"- slot scan: {md_report('SAVEDATA slot scan', savedata.get('slotScanUrl'), savedata.get('slotScanReportStatus'))} / status `{savedata.get('slotScanStatus')}` / found-valid `{savedata.get('slotScanFoundCount')}/{savedata.get('slotScanValidCount')}`",
        f"- slot scan real route evidence: `{savedata.get('slotScanRealRouteEvidenceCount')}` / synthetic diagnostics: `{savedata.get('slotScanSyntheticDiagnosticCount')}`",
        f"- real savedata gap: {md_report('real savedata evidence gap', savedata.get('realGapUrl'), savedata.get('realGapStatus'))}",
        f"- public sample deltas: {md_report('savedata sample deltas', savedata.get('sampleDeltasUrl'), savedata.get('sampleDeltasStatus'))} / selector-distinguishing offsets `{savedata.get('sampleDeltaSelectorDistinguishingOffsetCount')}` / selector 2:0 public samples `{savedata.get('sampleDeltaCurrentSelectorPublicSampleCount')}` / route-pair public samples `{savedata.get('sampleDeltaRoutePairPublicSampleCount')}`",
        f"- real savedata candidates: `{savedata.get('realCandidateCount')}` / valid `{savedata.get('validRealCandidateCount')}` / archive `{savedata.get('archiveCandidateCount')}` / archive skipped `{savedata.get('archiveSkippedCount')}`",
        f"- valid real candidates all blocked: `{savedata.get('validRealCandidatesAllBlocked')}` / block counts `{savedata.get('validRealCandidateBlockReasonCounts')}`",
        f"- current selector real saves: `{savedata.get('currentSelectorRealSaveCount')}`",
        f"- route-promotion real saves: `{savedata.get('routePromotionRealSaveCount')}`",
        f"- public sample selectors: {', '.join(savedata.get('publicSampleSelectors') or []) or '-'}",
        f"- required captured-save bytes: {required}",
        f"- required selected pointer: `{savedata.get('requiredSelectedPointer')}` covering `{savedata.get('requiredSource')} -> {savedata.get('requiredTarget')}`",
        f"- browser scan patterns: {'; '.join(savedata.get('browserScanPatterns') or [])}",
        f"- browser links: [dat]({savedata.get('savedatUrlDatExample')}) / [zip]({savedata.get('savedatUrlZipExample')}) / [scan]({savedata.get('savedatScanUrl')})",
    ])
    lines.extend(["", "### Public Search Notes", ""])
    public_notes = savedata.get("publicSearchNotes") or []
    if public_notes:
        lines.extend(f"- {item}" for item in public_notes)
    else:
        lines.append("- No additional public search notes recorded.")
    title_context = summary.get("titleStartContext") or {}
    input_transition = title_context.get("inputTransition") or {}
    input_payloads = [
        (
            f"{row.get('name')}:{row.get('kind')}:"
            f"{row.get('width')}x{row.get('height')}"
        )
        for row in input_transition.get("toResourcePayloads") or []
    ]
    lines.extend([
        "",
        "## Original Title/Start Context",
        "",
        f"- report: [{title_context.get('reportUrl')}]({title_context.get('reportUrl')})",
        f"- selected pointer: `{title_context.get('selectedPointer')}`",
        f"- selector: `{title_context.get('selector')}`",
        f"- live pointer classification: `{title_context.get('livePointerClassification')}`",
        f"- selector 8:0 field-record span: `{title_context.get('selector8FieldRecordSpanHex')}`",
        f"- title resource tail span: `{title_context.get('titleResourceTailSpanHex')}`",
        f"- promotion: `{title_context.get('promotionStatus')}` / allowed `{title_context.get('routePromotionAllowed')}`",
        f"- Input transition: `{input_transition.get('fromSelector')} -> {input_transition.get('toSelector')}` / `{input_transition.get('classification')}` / promotion `{input_transition.get('promotionStatus')}`",
        f"- Input transition target: pointer `{input_transition.get('toPointer')}`, root `{input_transition.get('toSelectedRoot')}`, field maps `{input_transition.get('toFieldMapCount')}`",
        f"- Input transition CNS: linked `{', '.join(input_transition.get('toLinkedCns') or []) or '-'}`, referenced `{', '.join(input_transition.get('toReferencedCns') or []) or '-'}`",
        f"- Input transition payloads: `{', '.join(input_payloads) or '-'}`",
        f"- Input transition selector rows: readers `{input_transition.get('toSelectionReaderCount')}`, writers `{input_transition.get('toSelectionWriterCount')}`",
        "",
        "| map | scene ids | tilesets | open | walk | review |",
        "| --- | --- | --- | --- | --- | --- |",
    ])
    for row in title_context.get("candidateMaps") or []:
        lines.append(
            f"| `{row.get('map')}` | {', '.join(row.get('sceneIds') or []) or '-'} | "
            f"{', '.join(row.get('tilesets') or []) or '-'} | "
            f"[open]({row.get('openUrl')}) | [walk]({row.get('walkUrl')}) | [review]({row.get('reviewUrl')}) |"
        )
    lines.extend([
        "",
        "## Candidate Exits",
        "",
        "| side | tile | auto | status | spawn | block reasons | open |",
        "| --- | --- | --- | --- | --- | --- | --- |",
    ])
    for row in summary["candidateExits"]:
        tile = row.get("tile") or {}
        spawn = row.get("targetSpawn") or {}
        lines.append(
            f"| {row.get('side')} | `{tile.get('x')},{tile.get('y')}` | {row.get('autoTrigger')} | "
            f"`{row.get('strictHotspotStatus')}` | {spawn.get('side')} `{spawn.get('x')},{spawn.get('y')}` | "
            f"{'; '.join(row.get('blockReasons') or []) or '-'} | "
            f"[assist]({row.get('routeAssistUrl')}) / [spawn]({row.get('targetSpawnUrl')}) |"
        )
    reciprocal = summary.get("reciprocalReturn") or {}
    lines.extend([
        "",
        "## Reciprocal Return Candidate",
        "",
        f"- report: [{reciprocal.get('url')}]({reciprocal.get('url')})",
        f"- reverse: `{reciprocal.get('source')} -> {reciprocal.get('target')}`",
        f"- class: `{reciprocal.get('classification')}` / promotion `{reciprocal.get('promotionStatus')}`",
        f"- selector records: `{reciprocal.get('selectorRecordCount')}` / strict events: `{reciprocal.get('strictEventRecordCount')}`",
        f"- exits: `{reciprocal.get('exitCandidateCount')}` / reciprocal hints: `{reciprocal.get('reciprocalExitCandidateCount')}` / auto exits: `{reciprocal.get('autoExitCandidateCount')}`",
    ])
    lines.extend([
        "",
        "## Post-Blocker Candidate Edges",
        "",
        "| source | target | selectors | open |",
        "| --- | --- | --- | --- |",
    ])
    for row in summary["postBlockerCandidateEdges"]:
        lines.append(
            f"| {row.get('source')} | {row.get('target')} | {', '.join(row.get('selectors') or []) or '-'} | "
            f"[source]({row.get('openUrl')}) / [target]({row.get('targetUrl')}) |"
        )
    lines.extend([
        "",
        "## RouteAssist Path Samples",
        "",
        "| target | hops | path | first open |",
        "| --- | ---: | --- | --- |",
    ])
    for row in summary.get("routeAssistPathSamples") or []:
        first = (row.get("steps") or [{}])[0]
        lines.append(
            f"| {row.get('target')} | {row.get('hopCount')} | {' -> '.join(row.get('path') or [])} | "
            f"[open]({row.get('goalStartUrl') or first.get('openUrl') or row.get('openUrl')}) |"
        )
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    def link(href: str | None, label: str) -> str:
        if not href:
            return f"{html.escape(label)} <span class=\"muted\">(not generated)</span>"
        return f'<a href="{html.escape(href)}">{html.escape(label)}</a>'

    def inline_note(text: str) -> str:
        parts = []
        offset = 0
        for match in MARKDOWN_LINK_RE.finditer(text):
            parts.append(html.escape(text[offset:match.start()]))
            parts.append(link(match.group(2), match.group(1)))
            offset = match.end()
        parts.append(html.escape(text[offset:]))
        return "".join(parts)

    start = summary["confirmedStart"]
    assist = summary["assistStart"]
    blocker = summary["blocker"]
    savedata = summary.get("savedataEvidence") or {}
    strict = summary.get("strictEventCoverage") or {}
    strict_route = strict.get("route") or {}
    strict_confirmed = strict.get("confirmedReference") or {}
    title_context = summary.get("titleStartContext") or {}
    input_transition = title_context.get("inputTransition") or {}
    input_payloads = [
        (
            f"{row.get('name')}:{row.get('kind')}:"
            f"{row.get('width')}x{row.get('height')}"
        )
        for row in input_transition.get("toResourcePayloads") or []
    ]
    reciprocal = summary.get("reciprocalReturn") or {}
    exit_rows = []
    for row in summary["candidateExits"]:
        tile = row.get("tile") or {}
        spawn = row.get("targetSpawn") or {}
        exit_rows.append(
            "<tr>"
            f"<td>{html.escape(str(row.get('side')))}</td>"
            f"<td><code>{tile.get('x')},{tile.get('y')}</code></td>"
            f"<td>{html.escape(str(row.get('autoTrigger')))}</td>"
            f"<td><code>{html.escape(str(row.get('strictHotspotStatus')))}</code></td>"
            f"<td>{html.escape(str(spawn.get('side')))} <code>{spawn.get('x')},{spawn.get('y')}</code></td>"
            f"<td>{html.escape('; '.join(row.get('blockReasons') or []) or '-')}</td>"
            f"<td>{link(row.get('routeAssistUrl'), 'assist')} / {link(row.get('targetSpawnUrl'), 'spawn')}</td>"
            "</tr>"
        )
    post_rows = []
    for row in summary["postBlockerCandidateEdges"]:
        post_rows.append(
            "<tr>"
            f"<td>{html.escape(str(row.get('source')))}</td>"
            f"<td>{html.escape(str(row.get('target')))}</td>"
            f"<td>{html.escape(', '.join(row.get('selectors') or []) or '-')}</td>"
            f"<td>{link(row.get('openUrl'), 'source')} / {link(row.get('targetUrl'), 'target')}</td>"
            "</tr>"
        )
    path_rows = []
    for row in summary.get("routeAssistPathSamples") or []:
        first = (row.get("steps") or [{}])[0]
        path_rows.append(
            "<tr>"
            f"<td>{html.escape(str(row.get('target')))}</td>"
            f"<td>{html.escape(str(row.get('hopCount')))}</td>"
            f"<td>{html.escape(' -> '.join(row.get('path') or []))}</td>"
            f"<td>{link(row.get('goalStartUrl') or first.get('openUrl') or row.get('openUrl'), 'open')}</td>"
            "</tr>"
        )
    notes = "".join(f"<li>{inline_note(item)}</li>" for item in summary["playtestNotes"])
    external_input_rows = []
    for row in blocker.get("externalProofRequiredInputs") or []:
        external_input_rows.append(
            "<tr>"
            f"<td><code>{html.escape(str(row.get('id')))}</code></td>"
            f"<td>{html.escape(str(row.get('neededInput')))}</td>"
            f"<td>{html.escape(str(row.get('currentState')))}</td>"
            f"<td>{html.escape(str(row.get('acceptedSignal')))}</td>"
            f"<td><code>{html.escape(str(row.get('refresh')))}</code></td>"
            "</tr>"
        )
    strict_hotspot = blocker.get("strictHotspotChecklist") or {}
    strict_hotspot_rows = []
    for row in strict_hotspot.get("candidateRows") or []:
        source_tile = row.get("sourceTile") or {}
        target_spawn = row.get("targetSpawn") or {}
        strict_hotspot_rows.append(
            "<tr>"
            f"<td>{html.escape(str(row.get('side')))}</td>"
            f"<td><code>{html.escape(str(source_tile.get('x')))}," 
            f"{html.escape(str(source_tile.get('y')))}</code></td>"
            f"<td>geom <code>{html.escape(str(source_tile.get('geometryStandable')))}</code><br>"
            f"original <code>{html.escape(str(source_tile.get('sourceOriginalStandable')))}</code></td>"
            f"<td>{html.escape(str(target_spawn.get('side')))} "
            f"<code>{html.escape(str(target_spawn.get('x')))}," 
            f"{html.escape(str(target_spawn.get('y')))}</code><br>"
            f"original <code>{html.escape(str(target_spawn.get('targetSpawnOriginalStandable')))}</code></td>"
            f"<td><code>{html.escape(str(row.get('coordinateStatus')))}</code><br>"
            f"promotable <code>{html.escape(str(row.get('coordinatePromotable')))}</code></td>"
            f"<td>{html.escape(str(row.get('routeReviewRowCount')))} / {html.escape(str(row.get('eventTransitionCount')))}</td>"
            f"<td>{html.escape('; '.join(row.get('blockReasons') or []) or '-')}</td>"
            f"<td>{link(row.get('routeAssistUrl'), 'assist')} / {link(row.get('targetSpawnReviewUrl'), 'spawn')}</td>"
            "</tr>"
        )
    required_rows = []
    for row in savedata.get("requiredSaveBytes") or []:
        required_rows.append(
            "<tr>"
            f"<td><code>{html.escape(str(row.get('offset')))}</code></td>"
            f"<td><code>{html.escape(str(row.get('value')))}</code></td>"
            f"<td>{html.escape(str(row.get('label')))}</td>"
            "</tr>"
        )
    public_note_rows = "".join(
        f"<li>{html.escape(str(item))}</li>"
        for item in (savedata.get("publicSearchNotes") or [])
    ) or "<li>No additional public search notes recorded.</li>"
    title_candidate_rows = []
    for row in title_context.get("candidateMaps") or []:
        title_candidate_rows.append(
            "<tr>"
            f"<td><code>{html.escape(str(row.get('map')))}</code></td>"
            f"<td>{html.escape(', '.join(row.get('sceneIds') or []) or '-')}</td>"
            f"<td>{html.escape(', '.join(row.get('tilesets') or []) or '-')}</td>"
            f"<td>{link(row.get('openUrl'), 'open')}</td>"
            f"<td>{link(row.get('walkUrl'), 'walk')}</td>"
            f"<td>{link(row.get('reviewUrl'), 'review')}</td>"
            "</tr>"
        )
    return "\n".join([
        "<!doctype html><meta charset=\"utf-8\"><title>Hwanse Web Playtest Route</title>",
        "<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;max-width:1100px;margin:24px auto}table{border-collapse:collapse;width:100%}td,th{border:1px solid #444;padding:6px 8px;vertical-align:top}code{color:#9bd4ff}a{color:#9bd4ff}.muted{color:#aaa}</style>",
        "<h1>Hwanse Web Playtest Route</h1>",
        f"<p>Status: <code>{html.escape(summary['status'])}</code></p>",
        f"<p>Confirmed start: {link(start.get('url'), start.get('map') or 'start')} <code>{start['startTile'].get('x')},{start['startTile'].get('y')}</code>; routeAssist start: {link(assist.get('url'), 'open')}</p>",
        f"<p>Confirmed reachable: {html.escape(', '.join(summary.get('confirmedReachable') or []))}</p>",
        f"<p>RouteAssist reachable: {summary.get('routeAssistReachableCount')} maps from {html.escape(str(assist.get('map')))} across {summary.get('routeAssistGraphEdgeCount')} trial graph edges.</p>",
        f"<p>RouteAssist maps: {html.escape(', '.join(summary.get('routeAssistReachable') or []))}</p>",
        f"<p>Blocker: <code>{html.escape(str(blocker.get('source')))} -&gt; {html.escape(str(blocker.get('target')))}</code>; promotion: <code>{html.escape(str(blocker.get('promotionStatus')))}</code>; evidence: {link(blocker.get('evidenceMatrixUrl'), 'matrix')} / {link(blocker.get('strictEventCoverageUrl'), 'strict event coverage')} / {link(blocker.get('externalProofHandoffUrl'), 'external proof handoff')}</p>",
        f"<p>Completion audit: {link(blocker.get('completionAuditUrl'), 'completion_audit')}; goal checklist: {link(blocker.get('goalCompletionChecklistUrl'), 'goal_completion_checklist')}.</p>",
        f"<p>External proof packages: <code>{html.escape(', '.join(blocker.get('externalProofHandoffPackageIds') or []) or '-')}</code>.</p>",
        f"<p>External proof status: <code>{html.escape(str(blocker.get('externalProofHandoffStatus')))}</code>; achieved <code>{html.escape(str(blocker.get('externalProofHandoffAchieved')))}</code>.</p>",
        f"<ul>{notes}</ul>",
        "<h2>Required External Inputs</h2>",
        "<table><thead><tr><th>id</th><th>needed input</th><th>current state</th><th>accepted signal</th><th>refresh</th></tr></thead><tbody>",
        *external_input_rows,
        "</tbody></table>",
        "<h2>Strict Hotspot Runtime Checklist</h2>",
        f"<p>Route: <code>{html.escape(str(strict_hotspot.get('source')))} -&gt; {html.escape(str(strict_hotspot.get('target')))}</code>; "
        f"status <code>{html.escape(str(strict_hotspot.get('promotionStatus')))}</code>; "
        f"proof <code>{html.escape(str(strict_hotspot.get('proofFound')))}</code>; "
        f"tile hotspot <code>{html.escape(str(strict_hotspot.get('tileHotspotConfirmed')))}</code>; "
        f"rejection <code>{html.escape(str(strict_hotspot.get('rejectionClassification')))}</code>; "
        f"candidates <code>{html.escape(str(strict_hotspot.get('candidateCount')))}</code>; "
        f"rejected <code>{html.escape(str(strict_hotspot.get('rejectedCandidateCount')))}</code>.</p>",
        f"<p>Missing evidence: {html.escape(', '.join(strict_hotspot.get('missingEvidence') or []) or '-')}</p>",
        "<table><thead><tr><th>side</th><th>source tile</th><th>source standable</th><th>target spawn</th><th>coordinate</th><th>reviews/events</th><th>block reasons</th><th>links</th></tr></thead><tbody>",
        *strict_hotspot_rows,
        "</tbody></table>",
        "<h2>Strict Event Coverage</h2>",
        f"<p>Report: {link(strict.get('reportUrl'), 'strict_event_source_coverage')}. "
        f"Field maps <code>{html.escape(str(strict.get('fieldMapCount')))}</code>; "
        f"strict event records <code>{html.escape(str(strict.get('strictEventRecordCount')))}</code>; "
        f"strict source maps <code>{html.escape(str(strict.get('strictEventSourceMapCount')))}</code> "
        f"(<code>{html.escape(str(strict.get('strictEventSourceCoveragePercent')))}%</code>); "
        f"strict event pairs <code>{html.escape(str(strict.get('strictEventPairCount')))}</code>.</p>",
        f"<p>Route: <code>{html.escape(str(strict_route.get('source')))} -&gt; {html.escape(str(strict_route.get('target')))}</code>; "
        f"direct strict pairs <code>{html.escape(str(strict_route.get('directStrictPairCount')))}</code>; "
        f"source strict events <code>{html.escape(str(strict_route.get('sourceStrictEventCount')))}</code>; "
        f"target strict incoming <code>{html.escape(str(strict_route.get('targetStrictIncomingCount')))}</code>; "
        f"promotion <code>{html.escape(str(strict.get('promotionStatus')))}</code>; "
        f"allowed <code>{html.escape(str(strict.get('promotionAllowed')))}</code>.</p>",
        f"<p>Route cluster roles: source-as-event-source <code>{html.escape(str(strict_route.get('sourceAsEventSourceStrictClusterCount')))}</code>; "
        f"source-as-target <code>{html.escape(str(strict_route.get('sourceAsEventTargetStrictClusterCount')))}</code>; "
        f"source-as-manifest <code>{html.escape(str(strict_route.get('sourceAsManifestStrictClusterCount')))}</code>; "
        f"target roles <code>{html.escape(str(strict_route.get('targetStrictClusterRoleCount')))}</code>. "
        f"reachable maps without strict source coverage: <code>{html.escape(', '.join(strict.get('reachableStrictSourceUncovered') or []) or '-')}</code>.</p>",
        f"<p>confirmed reference: <code>{html.escape(str(strict_confirmed.get('source')))} -&gt; {html.escape(str(strict_confirmed.get('target')))}</code>; "
        f"direct strict pairs <code>{html.escape(str(strict_confirmed.get('directStrictPairCount')))}</code>; "
        f"confirmed reviews <code>{html.escape(str(strict_confirmed.get('confirmedReviewCount')))}</code>; "
        f"rejected reviews <code>{html.escape(str(strict_confirmed.get('rejectedReviewCount')))}</code>.</p>",
        "<h2>Reciprocal Return Candidate</h2>",
        f"<p>Report: {link(reciprocal.get('url'), 'reciprocal_transition_candidates')}. "
        f"Reverse: <code>{html.escape(str(reciprocal.get('source')))} -&gt; {html.escape(str(reciprocal.get('target')))}</code>; "
        f"class <code>{html.escape(str(reciprocal.get('classification')))}</code>; "
        f"promotion <code>{html.escape(str(reciprocal.get('promotionStatus')))}</code>.</p>",
        f"<p>Selector records <code>{html.escape(str(reciprocal.get('selectorRecordCount')))}</code>; "
        f"strict events <code>{html.escape(str(reciprocal.get('strictEventRecordCount')))}</code>; "
        f"exit candidates <code>{html.escape(str(reciprocal.get('exitCandidateCount')))}</code>; "
        f"reciprocal hints <code>{html.escape(str(reciprocal.get('reciprocalExitCandidateCount')))}</code>; "
        f"auto exits <code>{html.escape(str(reciprocal.get('autoExitCandidateCount')))}</code>.</p>",
        "<h2>Savedata Evidence</h2>",
        f"<p>Slot scan: {link(savedata.get('slotScanUrl'), 'savedata_slot_scan')} <code>{html.escape(str(savedata.get('slotScanStatus')))}</code>; found/valid <code>{html.escape(str(savedata.get('slotScanFoundCount')))} / {html.escape(str(savedata.get('slotScanValidCount')))}</code>; real route evidence <code>{html.escape(str(savedata.get('slotScanRealRouteEvidenceCount')))}</code>; synthetic diagnostics <code>{html.escape(str(savedata.get('slotScanSyntheticDiagnosticCount')))}</code>.</p>",
        f"<p>Real savedata gap: {link(savedata.get('realGapUrl'), 'save_selector_real_savedata_evidence_gap')}. Current selector real saves: <code>{html.escape(str(savedata.get('currentSelectorRealSaveCount')))}</code>; route-promotion real saves: <code>{html.escape(str(savedata.get('routePromotionRealSaveCount')))}</code>.</p>",
        f"<p>Public sample deltas: {link(savedata.get('sampleDeltasUrl'), 'savedata_sample_deltas')}. Selector-distinguishing offsets: <code>{html.escape(str(savedata.get('sampleDeltaSelectorDistinguishingOffsetCount')))}</code>; selector 2:0 public samples: <code>{html.escape(str(savedata.get('sampleDeltaCurrentSelectorPublicSampleCount')))}</code>; route-pair public samples: <code>{html.escape(str(savedata.get('sampleDeltaRoutePairPublicSampleCount')))}</code>.</p>",
        f"<p>Real savedata candidates: <code>{html.escape(str(savedata.get('realCandidateCount')))}</code>; valid <code>{html.escape(str(savedata.get('validRealCandidateCount')))}</code>; archive candidates <code>{html.escape(str(savedata.get('archiveCandidateCount')))}</code>; archive skipped <code>{html.escape(str(savedata.get('archiveSkippedCount')))}</code>.</p>",
        f"<p>valid real candidates all blocked: <code>{html.escape(str(savedata.get('validRealCandidatesAllBlocked')))}</code>; block counts <code>{html.escape(str(savedata.get('validRealCandidateBlockReasonCounts')))}</code>.</p>",
        f"<p>Public sample selectors: <code>{html.escape(', '.join(savedata.get('publicSampleSelectors') or []) or '-')}</code>. Required selected pointer: <code>{html.escape(str(savedata.get('requiredSelectedPointer')))}</code>.</p>",
        f"<p>Browser scan patterns: <code>{html.escape('; '.join(savedata.get('browserScanPatterns') or []))}</code>.</p>",
        f"<p>Browser links: {link(savedata.get('savedatUrlDatExample'), 'savedatUrl dat')} / {link(savedata.get('savedatUrlZipExample'), 'savedatUrl zip')} / {link(savedata.get('savedatScanUrl'), 'scan SAVEDATA')}.</p>",
        "<table><thead><tr><th>save offset</th><th>required value</th><th>meaning</th></tr></thead><tbody>",
        *required_rows,
        "</tbody></table>",
        "<h3>Public Search Notes</h3>",
        f"<ul>{public_note_rows}</ul>",
        "<h2>Original Title/Start Context</h2>",
        f"<p>Report: {link(title_context.get('reportUrl'), 'runtime_title_start_context')}. "
        f"Selected pointer: <code>{html.escape(str(title_context.get('selectedPointer')))}</code>; "
        f"selector: <code>{html.escape(str(title_context.get('selector')))}</code>; "
        f"classification: <code>{html.escape(str(title_context.get('livePointerClassification')))}</code>; "
        f"promotion: <code>{html.escape(str(title_context.get('promotionStatus')))}</code>; "
        f"allowed: <code>{html.escape(str(title_context.get('routePromotionAllowed')))}</code>.</p>",
        f"<p>Selector 8:0 field-record span: <code>{html.escape(str(title_context.get('selector8FieldRecordSpanHex')))}</code>; "
        f"title resource tail span: <code>{html.escape(str(title_context.get('titleResourceTailSpanHex')))}</code>.</p>",
        f"<p>Input transition: <code>{html.escape(str(input_transition.get('fromSelector')))} -&gt; {html.escape(str(input_transition.get('toSelector')))}</code>; "
        f"classification <code>{html.escape(str(input_transition.get('classification')))}</code>; "
        f"promotion <code>{html.escape(str(input_transition.get('promotionStatus')))}</code>; "
        f"target pointer <code>{html.escape(str(input_transition.get('toPointer')))}</code>; "
        f"target root <code>{html.escape(str(input_transition.get('toSelectedRoot')))}</code>; "
        f"field maps <code>{html.escape(str(input_transition.get('toFieldMapCount')))}</code>.</p>",
        f"<p>Input transition CNS: linked <code>{html.escape(', '.join(input_transition.get('toLinkedCns') or []) or '-')}</code>; "
        f"referenced <code>{html.escape(', '.join(input_transition.get('toReferencedCns') or []) or '-')}</code>; "
        f"payloads <code>{html.escape(', '.join(input_payloads) or '-')}</code>; "
        f"selector rows readers/writers <code>{html.escape(str(input_transition.get('toSelectionReaderCount')))} / {html.escape(str(input_transition.get('toSelectionWriterCount')))}</code>.</p>",
        "<table><thead><tr><th>map</th><th>scene ids</th><th>tilesets</th><th>open</th><th>walk</th><th>review</th></tr></thead><tbody>",
        *title_candidate_rows,
        "</tbody></table>",
        "<h2>Candidate Exits</h2>",
        "<table><thead><tr><th>side</th><th>tile</th><th>auto</th><th>status</th><th>spawn</th><th>block reasons</th><th>open</th></tr></thead><tbody>",
        *exit_rows,
        "</tbody></table>",
        "<h2>Post-Blocker Candidate Edges</h2>",
        "<table><thead><tr><th>source</th><th>target</th><th>selectors</th><th>open</th></tr></thead><tbody>",
        *post_rows,
        "</tbody></table>",
        "<h2>RouteAssist Path Samples</h2>",
        "<table><thead><tr><th>target</th><th>hops</th><th>path</th><th>first open</th></tr></thead><tbody>",
        *path_rows,
        "</tbody></table>",
    ])


def write_outputs(summary: dict, out_dir: Path) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    json_text = json.dumps(summary, ensure_ascii=False, separators=(",", ":"))
    (out_dir / "web_playtest_route.json").write_text(
        json_text + "\n",
        encoding="utf-8",
    )
    (out_dir / "web_playtest_route.html").write_text(html_page(summary), encoding="utf-8")
    (out_dir / "web_playtest_route_runtime.js").write_text(
        f"window.HWANSE_WEB_PLAYTEST_ROUTE = {json_text};\n",
        encoding="utf-8",
    )


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(
        load_json(args.out_dir / "playable_progress.json", {}),
        load_json(args.out_dir / "route_assist_frontier.json", []),
        load_json(args.out_dir / "route_blocker_evidence_matrix.json", {}),
        load_json(args.out_dir / "save_selector_real_savedata_evidence_gap.json", {}),
        load_json(args.out_dir / "savedata_sample_deltas.json", {}),
        load_json(args.out_dir / "savedata_slot_scan.json", {}),
        load_json(args.out_dir / "reciprocal_transition_candidates.json", {}),
        load_json(args.out_dir / "runtime_title_start_context.json", {}),
        load_json(args.out_dir / "strict_event_source_coverage.json", {}),
        load_json(args.out_dir / "strict_source_hotspot_external_review_packet.json", {}),
        load_json(args.out_dir / "route_promotion_external_proof_handoff.json", {}),
    )
    write_outputs(summary, args.out_dir)
    print(f"wrote web playtest route -> {args.out_dir / 'web_playtest_route.html'}")


if __name__ == "__main__":
    main()
