#!/usr/bin/env python3
"""Summarize currently playable map progression from confirmed transitions."""
from __future__ import annotations

import argparse
import html
import json
from collections import defaultdict, deque
from pathlib import Path
from urllib.parse import urlencode


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"


def web_href(web_prefix: str, params: dict[str, str]) -> str:
    return f"{web_prefix}/game.html?{urlencode(params)}"


def out_artifact_exists(relative_href: str) -> bool:
    return (OUT / relative_href).exists()


def markdown_optional_link(label: str, relative_href: str) -> str:
    if out_artifact_exists(relative_href):
        return f"[{label}]({relative_href})"
    return f"{label} not generated"


def html_optional_link(label: str, relative_href: str) -> str:
    if out_artifact_exists(relative_href):
        return f'<a href="{html.escape(relative_href)}">{html.escape(label)}</a>'
    return f'<span class="missing">{html.escape(label)} not generated</span>'


def reachable_from(graph: dict[str, set[str]], maps: dict, start_map: str) -> list[str]:
    reachable = []
    seen = set()
    queue = deque([start_map] if start_map in maps else [])
    while queue:
        source = queue.popleft()
        if source in seen:
            continue
        seen.add(source)
        reachable.append(source)
        for target in sorted(graph.get(source, [])):
            if target not in seen:
                queue.append(target)
    return reachable


def entry_routes_for_graph(graph: dict[str, set[str]], maps: dict, edge_points: dict[tuple[str, str], list[dict]]) -> list[dict]:
    routes = []
    for source in sorted(graph):
        start_points = [
            point
            for edge_source, _target in sorted(edge_points)
            if edge_source == source
            for point in edge_points[(edge_source, _target)]
        ]
        start_points.sort(key=lambda point: (point["y"], point["x"], point["target"]))
        routes.append({
            "startMap": source,
            "reachable": reachable_from(graph, maps, source),
            "startTile": {"x": start_points[0]["x"], "y": start_points[0]["y"]} if start_points else None,
        })
    return sorted(routes, key=lambda row: (-len(row["reachable"]), row["startMap"]))


def unique(values: list[str]) -> list[str]:
    seen = set()
    result = []
    for value in values:
        if value in seen:
            continue
        seen.add(value)
        result.append(value)
    return result


def selector_candidate_edges(save_selector_scene_links: dict[str, dict] | None, maps: dict) -> list[dict]:
    edges = []
    for source, entry in sorted((save_selector_scene_links or {}).items()):
        if source not in maps:
            continue
        for target in entry.get("fieldMaps") or []:
            if target not in maps or target == source:
                continue
            records = [
                record
                for record in entry.get("records") or []
                if target in (record.get("targets") or record.get("fieldMaps") or [])
            ]
            edges.append({
                "source": source,
                "target": target,
                "selectors": sorted({record.get("selector") for record in records if record.get("selector")}),
                "leafPointers": sorted({record.get("leafPointerHex") for record in records if record.get("leafPointerHex")}),
                "recordCount": len(records),
            })
    return edges


def confirmed_graph(summary: dict) -> tuple[dict[str, set[str]], dict[tuple[str, str], list[dict]]]:
    graph: dict[str, set[str]] = defaultdict(set)
    edge_points: dict[tuple[str, str], list[dict]] = defaultdict(list)
    for edge in summary.get("confirmedEdges") or []:
        source = edge.get("source")
        target = edge.get("target")
        if not isinstance(source, str) or not isinstance(target, str):
            continue
        graph[source].add(target)
        for point in edge.get("points") or []:
            edge_points[(source, target)].append({
                "x": point.get("x", 0),
                "y": point.get("y", 0),
                "target": target,
            })
    return graph, edge_points


def map_exit_candidate_edges(map_exit_candidates: list[dict] | None, maps: dict) -> list[dict]:
    edges = []
    for row in map_exit_candidates or []:
        source = row.get("map")
        if source not in maps:
            continue
        for candidate in row.get("exitCandidates") or []:
            if candidate.get("promotionStatus") != "manual-review-only":
                continue
            sample = candidate.get("sample") or {}
            x = sample.get("x")
            y = sample.get("y")
            if not isinstance(x, int) or not isinstance(y, int):
                continue
            targets = unique([
                *(candidate.get("blockedTargetCandidates") or []),
                *(candidate.get("selectorTargetCandidates") or []),
            ])
            for target in targets:
                if target == source or target not in maps:
                    continue
                edges.append({
                    "source": source,
                    "target": target,
                    "side": candidate.get("side"),
                    "x": x,
                    "y": y,
                    "standable": sample.get("standable"),
                    "status": candidate.get("promotionStatus"),
                    "reviewUrl": candidate.get("reviewUrl"),
                    "trialUrl": candidate.get("trialUrl"),
                })
    edges.sort(key=lambda item: (item["source"], item["target"], item.get("side") or "", item["y"], item["x"]))
    return edges


def annotate_map_exit_trial_routes(summary: dict, map_exit_candidates: list[dict] | None, maps: dict) -> dict:
    graph, edge_points = confirmed_graph(summary)
    exit_edges = map_exit_candidate_edges(map_exit_candidates, maps)
    for edge in exit_edges:
        graph[edge["source"]].add(edge["target"])
        edge_points[(edge["source"], edge["target"])].append({
            "x": edge["x"],
            "y": edge["y"],
            "target": edge["target"],
        })
    reachable = reachable_from(graph, maps, summary.get("startMap", ""))
    confirmed_reachable = set(summary.get("reachableFromStart") or [])
    frontier = [
        edge for edge in exit_edges
        if edge["source"] in confirmed_reachable and edge["target"] not in confirmed_reachable
    ]
    reachable_set = set(reachable)
    reachable_edges = [
        edge for edge in exit_edges
        if edge["source"] in reachable_set and edge["target"] in reachable_set
    ]
    summary.update({
        "mapExitTrialTransitionCount": len(exit_edges),
        "mapExitTrialEdgeCount": len({(edge["source"], edge["target"]) for edge in exit_edges}),
        "mapExitTrialReachableFromStart": reachable,
        "mapExitTrialReachableCount": len(reachable),
        "mapExitTrialEntryRoutes": entry_routes_for_graph(graph, maps, edge_points),
        "mapExitTrialFrontier": frontier,
        "mapExitTrialReachableEdges": reachable_edges,
    })
    return summary


def build_summary(
    maps: dict,
    transitions: list[dict],
    reviews: dict[str, dict],
    start_map: str = "map1_02b",
    transition_gap_rows: list[dict] | None = None,
    save_selector_scene_links: dict[str, dict] | None = None,
) -> dict:
    confirmed = [review for review in reviews.values() if review.get("state") == "confirmed"]
    rejected = [review for review in reviews.values() if review.get("state") == "rejected"]
    graph: dict[str, set[str]] = defaultdict(set)
    confirmed_by_edge: dict[tuple[str, str], list[dict]] = defaultdict(list)
    for review in confirmed:
        source = review["source"]
        target = review["target"]
        graph[source].add(target)
        confirmed_by_edge[(source, target)].append(review)

    reachable = reachable_from(graph, maps, start_map)
    entry_routes = entry_routes_for_graph(graph, maps, confirmed_by_edge)

    gap_rows_by_record: dict[tuple[str, str], list[dict]] = defaultdict(list)
    for gap in transition_gap_rows or []:
        source = gap.get("source")
        record = gap.get("recordVaHex") or str(gap.get("recordVa") or "")
        if isinstance(source, str) and record:
            gap_rows_by_record[(source, record)].append(gap)

    trial_graph: dict[str, set[str]] = defaultdict(set)
    trial_by_edge: dict[tuple[str, str], list[dict]] = defaultdict(list)
    for (source, target), items in confirmed_by_edge.items():
        trial_graph[source].add(target)
        trial_by_edge[(source, target)].extend(items)
    gap_rows_by_target: dict[tuple[str, str, str], list[dict]] = defaultdict(list)
    for gap in transition_gap_rows or []:
        if (
            gap.get("state") == "unreviewed"
            and gap.get("sourceStandable")
            and isinstance(gap.get("activeDistance"), int)
            and gap.get("source") in maps
            and gap.get("target") in maps
        ):
            record = gap.get("recordVaHex") or str(gap.get("recordVa") or "")
            gap_rows_by_target[(gap["source"], record, gap["target"])].append(gap)
    for (_source, _record, _target), gaps in gap_rows_by_target.items():
        nearest = min(gap["activeDistance"] for gap in gaps)
        for gap in gaps:
            if gap["activeDistance"] != nearest:
                continue
            source = gap["source"]
            target = gap["target"]
            trial_graph[source].add(target)
            trial_by_edge[(source, target)].append({
                "x": gap["x"],
                "y": gap["y"],
                "target": target,
                "recordVaHex": gap.get("recordVaHex"),
                "activeDistance": gap.get("activeDistance"),
                "trial": "activeNearest",
            })
    trial_entry_routes = entry_routes_for_graph(trial_graph, maps, trial_by_edge)

    selector_graph: dict[str, set[str]] = defaultdict(set)
    selector_by_edge: dict[tuple[str, str], list[dict]] = defaultdict(list)
    for (source, target), items in confirmed_by_edge.items():
        selector_graph[source].add(target)
        selector_by_edge[(source, target)].extend(items)
    selector_edges = selector_candidate_edges(save_selector_scene_links, maps)
    for edge in selector_edges:
        source = edge["source"]
        target = edge["target"]
        selector_graph[source].add(target)
        selector_by_edge[(source, target)].append({
            "x": 0,
            "y": 0,
            "target": target,
            "trial": "saveSelectorSceneLink",
            "selectors": edge["selectors"],
            "leafPointers": edge["leafPointers"],
        })
    selector_reachable = reachable_from(selector_graph, maps, start_map)
    confirmed_reachable = set(reachable)
    selector_frontier = [
        edge
        for edge in selector_edges
        if edge["source"] in confirmed_reachable and edge["target"] not in confirmed_reachable
    ]
    selector_reachable_set = set(selector_reachable)
    selector_reachable_edges = [
        edge
        for edge in selector_edges
        if edge["source"] in selector_reachable_set and edge["target"] in selector_reachable_set
    ]
    selector_entry_routes = entry_routes_for_graph(selector_graph, maps, selector_by_edge)
    transitions_by_source: dict[str, list[dict]] = defaultdict(list)
    for row in transitions:
        transitions_by_source[row["map"]].append(row)
    selector_frontier_by_source: dict[str, list[dict]] = defaultdict(list)
    for edge in selector_frontier:
        selector_frontier_by_source[edge["source"]].append(edge)
    confirmed_route_blockers = []
    for source in reachable:
        if graph.get(source):
            continue
        event_rows = transitions_by_source.get(source, [])
        selector_rows = selector_frontier_by_source.get(source, [])
        confirmed_route_blockers.append({
            "map": source,
            "confirmedOutgoingEdges": 0,
            "eventTransitionRecords": len(event_rows),
            "eventTargets": sorted({target for row in event_rows for target in (row.get("targets") or [])}),
            "saveSelectorFrontierTargets": sorted({edge["target"] for edge in selector_rows}),
            "saveSelectorFrontierCount": len(selector_rows),
            "reason": (
                "no strict event transition records on the confirmed route"
                if not event_rows
                else "event transition records exist but none are confirmed"
            ),
        })

    candidate_rows = []
    next_review_candidates = []
    for row in transitions:
        source = row["map"]
        targets = row.get("targets") or []
        points = row.get("points") or []
        record = row.get("recordVaHex") or str(row.get("recordVa") or "")
        record_gap_rows = gap_rows_by_record.get((source, record), [])
        confirmed_targets = {
            review["target"]
            for review in confirmed
            if review["source"] == source
            and review.get("recordVaHex") == row.get("recordVaHex")
        }
        unconfirmed_targets = [target for target in targets if target not in confirmed_targets]
        if unconfirmed_targets:
            unconfirmed_standable = [
                gap
                for gap in record_gap_rows
                if gap.get("target") in unconfirmed_targets
                and gap.get("state") == "unreviewed"
                and gap.get("sourceStandable")
            ]
            first_standable = sorted(
                unconfirmed_standable,
                key=lambda gap: (
                    gap.get("activeDistance", 9999),
                    gap.get("target") or "",
                    gap.get("y", 0),
                    gap.get("x", 0),
                ),
            )[:1]
            candidate_rows.append({
                "source": source,
                "recordVaHex": row.get("recordVaHex"),
                "sceneIdHex": row.get("sceneIdHex"),
                "eventKind": row.get("eventKind"),
                "points": len(points),
                "targets": unconfirmed_targets,
                "standableUnreviewedPoints": len(unconfirmed_standable),
            })
            next_review_candidates.append({
                "source": source,
                "recordVaHex": row.get("recordVaHex"),
                "sceneIdHex": row.get("sceneIdHex"),
                "eventKind": row.get("eventKind"),
                "points": len(points),
                "targetCount": len(unconfirmed_targets),
                "targets": unconfirmed_targets,
                "standableUnreviewedPoints": len(unconfirmed_standable),
                "nearestActiveDistance": first_standable[0].get("activeDistance") if first_standable else None,
                "firstStandablePoint": (
                    {
                        "x": first_standable[0]["x"],
                        "y": first_standable[0]["y"],
                        "target": first_standable[0]["target"],
                        "activeDistance": first_standable[0].get("activeDistance"),
                    }
                    if first_standable
                    else None
                ),
            })
    next_review_candidates.sort(
        key=lambda row: (
            row.get("nearestActiveDistance") if row.get("nearestActiveDistance") is not None else 9999,
            row["targetCount"],
            -row["standableUnreviewedPoints"],
            row["source"],
            row.get("recordVaHex") or "",
        )
    )

    return {
        "startMap": start_map,
        "mapCount": len(maps),
        "confirmedTransitionCount": len(confirmed),
        "rejectedTransitionCount": len(rejected),
        "confirmedEdgeCount": len(confirmed_by_edge),
        "reachableFromStart": reachable,
        "reachableCount": len(reachable),
        "confirmedEntryRoutes": entry_routes,
        "trialActiveNearestTransitionCount": sum(1 for items in trial_by_edge.values() for item in items if item.get("trial") == "activeNearest"),
        "trialActiveNearestEdgeCount": len(trial_by_edge),
        "trialActiveNearestReachableFromStart": reachable_from(trial_graph, maps, start_map),
        "trialActiveNearestEntryRoutes": trial_entry_routes,
        "saveSelectorCandidateEdgeCount": len(selector_edges),
        "saveSelectorCandidateReachableFromStart": selector_reachable,
        "saveSelectorCandidateReachableCount": len(selector_reachable),
        "saveSelectorCandidateEntryRoutes": selector_entry_routes,
        "saveSelectorCandidateFrontier": selector_frontier,
        "saveSelectorCandidateReachableEdges": selector_reachable_edges,
        "confirmedEdges": [
            {
                "source": source,
                "target": target,
                "points": sorted(
                    [{"x": item["x"], "y": item["y"], "spawnX": item.get("spawnX"), "spawnY": item.get("spawnY")}
                    for item in items
                    ],
                    key=lambda item: (item["y"], item["x"]),
                ),
            }
            for (source, target), items in sorted(confirmed_by_edge.items())
        ],
        "confirmedRouteBlockers": confirmed_route_blockers,
        "unconfirmedTransitionRecords": candidate_rows,
        "nextReviewCandidates": next_review_candidates,
    }


def annotate_save_selector_frontier_risks(summary: dict, branch_rows: list[dict]) -> dict:
    risks = {
        (row.get("source"), row.get("target")): row
        for row in branch_rows
    }
    for edge in summary.get("saveSelectorCandidateFrontier") or []:
        risk = risks.get((edge.get("source"), edge.get("target")))
        if not risk:
            continue
        edge["promotionRisk"] = risk.get("promotionRisk")
        edge["promotionNote"] = risk.get("note")
        edge["branchGateCount"] = len(risk.get("branchSteps") or [])
        edge["sourceTilesetMatch"] = risk.get("sourceTilesetMatch")
        edge["targetTilesetMatch"] = risk.get("targetTilesetMatch")
        edge["branchConditions"] = [
            step.get("condition")
            for step in risk.get("branchSteps") or []
            if step.get("condition")
        ]
    risks_by_source: dict[str, list[dict]] = {}
    for edge in summary.get("saveSelectorCandidateFrontier") or []:
        risks_by_source.setdefault(edge["source"], []).append(edge)
    for blocker in summary.get("confirmedRouteBlockers") or []:
        source_risks = risks_by_source.get(blocker.get("map")) or []
        blocker["saveSelectorFrontierRisks"] = [
            {
                "target": edge.get("target"),
                "promotionRisk": edge.get("promotionRisk"),
                "branchConditions": edge.get("branchConditions") or [],
            }
            for edge in source_risks
        ]
    return summary


def markdown(summary: dict, web_prefix: str = "../web") -> str:
    lines = [
        "# Playable Progress",
        "",
        "Generated from confirmed transition reviews. Normal gameplay only uses confirmed transitions; unreviewed transitions require `events=1` debug mode.",
        "",
        f"Start map: `{summary['startMap']}`.",
        "",
        f"Maps in build: {summary['mapCount']}.",
        "",
        f"Confirmed transition points: {summary['confirmedTransitionCount']}.",
        "",
        f"Confirmed map edges: {summary['confirmedEdgeCount']}.",
        "",
        f"Reachable from start via confirmed transitions: {summary['reachableCount']}.",
        "",
        "## Reachable Maps",
        "",
    ]
    if summary["reachableFromStart"]:
        for name in summary["reachableFromStart"]:
            href = web_href(web_prefix, {"map": name})
            lines.append(f"- [{name}]({href})")
    else:
        lines.append("- -")
    lines.extend([
        "",
        "## Confirmed Edges",
        "",
        "| source | target | points | open |",
        "| --- | --- | --- | --- |",
    ])
    for edge in summary["confirmedEdges"]:
        first = edge["points"][0]
        href = web_href(web_prefix, {"map": edge["source"], "startTile": f"{first['x']},{first['y']}"})
        points = ", ".join(
            f"{point['x']},{point['y']} -> {point.get('spawnX')},{point.get('spawnY')}"
            for point in edge["points"]
        )
        lines.append(f"| {edge['source']} | {edge['target']} | {points} | [open]({href}) |")
    if not summary["confirmedEdges"]:
        lines.append("| - | - | - | - |")
    lines.extend([
        "",
        "## Confirmed Route Blockers",
        "",
        "| map | event records | event targets | save-selector frontier | reason |",
        "| --- | ---: | --- | --- | --- |",
    ])
    for blocker in summary.get("confirmedRouteBlockers", []):
        selector_risks = ", ".join(
            f"{item.get('target')} ({item.get('promotionRisk') or '-'})"
            for item in blocker.get("saveSelectorFrontierRisks") or []
        ) or ", ".join(blocker.get("saveSelectorFrontierTargets") or []) or "-"
        lines.append(
            f"| {blocker['map']} | {blocker.get('eventTransitionRecords', 0)} | "
            f"{', '.join(blocker.get('eventTargets') or []) or '-'} | "
            f"{selector_risks} | {blocker.get('reason') or '-'} |"
        )
    if not summary.get("confirmedRouteBlockers"):
        lines.append("| - | - | - | - | - |")
    lines.extend([
        "",
        "## Confirmed Entry Routes",
        "",
        "| start map | reachable maps | open |",
        "| --- | --- | --- |",
    ])
    for route in summary["confirmedEntryRoutes"]:
        params = {"map": route["startMap"]}
        if route.get("startTile"):
            params["startTile"] = f"{route['startTile']['x']},{route['startTile']['y']}"
        href = web_href(web_prefix, params)
        lines.append(
            f"| {route['startMap']} | {', '.join(route['reachable'])} | [open]({href}) |"
        )
    if not summary["confirmedEntryRoutes"]:
        lines.append("| - | - | - |")
    lines.extend([
        "",
        "## Active-Nearest Trial Routes",
        "",
        "`trialTransitions=activeNearest` keeps confirmed gameplay separate and lets the web runtime test one nearest standable unreviewed point per transition component.",
        "",
        f"Trial active-nearest transition points: {summary.get('trialActiveNearestTransitionCount', 0)}.",
        "",
        f"Trial active-nearest map edges: {summary.get('trialActiveNearestEdgeCount', 0)}.",
        "",
        f"Reachable from start with trial active-nearest: {len(summary.get('trialActiveNearestReachableFromStart', []))}.",
        "",
        "| start map | reachable maps | open trial |",
        "| --- | --- | --- |",
    ])
    for route in summary.get("trialActiveNearestEntryRoutes", [])[:20]:
        params = {"map": route["startMap"], "trialTransitions": "activeNearest"}
        if route.get("startTile"):
            params["startTile"] = f"{route['startTile']['x']},{route['startTile']['y']}"
        href = web_href(web_prefix, params)
        lines.append(
            f"| {route['startMap']} | {', '.join(route['reachable'])} | [open]({href}) |"
        )
    if not summary.get("trialActiveNearestEntryRoutes"):
        lines.append("| - | - | - |")
    lines.extend([
        "",
        "## Map-Exit Trial Routes",
        "",
        "`trialTransitions=mapExitCandidates` keeps geometry-only edge exits separate from confirmed gameplay. These candidates are for manual route testing and must not be treated as confirmed transitions. `trialTransitions=routeAssist` combines selector-only links and geometry-only exits so the web runtime can keep playtesting past blockers without promoting them to confirmed route evidence.",
        "",
        f"Map-exit trial transition points: {summary.get('mapExitTrialTransitionCount', 0)}.",
        "",
        f"Map-exit trial map edges: {summary.get('mapExitTrialEdgeCount', 0)}.",
        "",
        f"Reachable from start with map-exit trials: {summary.get('mapExitTrialReachableCount', 0)}.",
        "",
        "| frontier source | target | side | point | status | trial source | open target |",
        "| --- | --- | --- | --- | --- | --- | --- |",
    ])
    for edge in summary.get("mapExitTrialFrontier", [])[:20]:
        trial_params = {
            "map": edge["source"],
            "trialTransitions": "mapExitCandidates",
            "startTile": f"{edge['x']},{edge['y']}",
            "focusTile": f"{edge['x']},{edge['y']}",
        }
        target_params = {"map": edge["target"], "overview": "1"}
        lines.append(
            f"| {edge['source']} | {edge['target']} | {edge.get('side') or '-'} | "
            f"{edge['x']},{edge['y']} | {edge.get('status') or '-'} | "
            f"[trial]({web_href(web_prefix, trial_params)}) | [target]({web_href(web_prefix, target_params)}) |"
        )
    if not summary.get("mapExitTrialFrontier"):
        lines.append("| - | - | - | - | - | - | - |")
    lines.extend([
        "",
        "| start map | reachable maps | open exit trial |",
        "| --- | --- | --- |",
    ])
    for route in summary.get("mapExitTrialEntryRoutes", [])[:20]:
        params = {"map": route["startMap"], "trialTransitions": "mapExitCandidates"}
        if route.get("startTile"):
            params["startTile"] = f"{route['startTile']['x']},{route['startTile']['y']}"
        href = web_href(web_prefix, params)
        lines.append(
            f"| {route['startMap']} | {', '.join(route['reachable'])} | [open]({href}) |"
        )
    if not summary.get("mapExitTrialEntryRoutes"):
        lines.append("| - | - | - |")
    lines.extend([
        "",
        "## Save Selector Candidate Routes",
        "",
        "These routes add save-selector scene-link candidates on top of confirmed transitions. They are useful for review navigation, and `trialTransitions=routeAssist` can combine them with map-exit candidates, but they are not confirmed tile transitions.",
        "",
        f"Save-selector candidate edges: {summary.get('saveSelectorCandidateEdgeCount', 0)}.",
        "",
        f"Reachable from start with save-selector candidates: {summary.get('saveSelectorCandidateReachableCount', 0)}.",
        "",
        "| frontier source | target | risk | branch gates | selector | leaf pointers | open source | trial source | open target |",
        "| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
    ])
    for edge in summary.get("saveSelectorCandidateFrontier", [])[:20]:
        source_href = web_href(web_prefix, {"map": edge["source"], "events": "1", "overview": "1"})
        trial_href = web_href(web_prefix, {"map": edge["source"], "trialTransitions": "routeAssist"})
        target_href = web_href(web_prefix, {"map": edge["target"], "events": "1", "overview": "1"})
        branch_gates = ", ".join(edge.get("branchConditions") or []) or "-"
        lines.append(
            f"| {edge['source']} | {edge['target']} | {edge.get('promotionRisk') or '-'} | {branch_gates} | "
            f"{', '.join(edge.get('selectors') or []) or '-'} | "
            f"{', '.join(edge.get('leafPointers') or []) or '-'} | [source]({source_href}) | "
            f"[trial]({trial_href}) | [target]({target_href}) |"
        )
    if not summary.get("saveSelectorCandidateFrontier"):
        lines.append("| - | - | - | - | - | - | - | - | - |")
    lines.extend([
        "",
        "| start map | reachable maps | open candidate route |",
        "| --- | --- | --- |",
    ])
    for route in summary.get("saveSelectorCandidateEntryRoutes", [])[:20]:
        params = {"map": route["startMap"], "events": "1", "overview": "1", "trialTransitions": "routeAssist"}
        href = web_href(web_prefix, params)
        lines.append(
            f"| {route['startMap']} | {', '.join(route['reachable'])} | [open]({href}) |"
        )
    if not summary.get("saveSelectorCandidateEntryRoutes"):
        lines.append("| - | - | - |")
    lines.extend([
        "",
        "## Next Review Candidates",
        "",
        "| source | targets | standable unreviewed | active dist | first point | scene | kind | record | review | debug open | trial open |",
        "| --- | ---: | ---: | ---: | --- | --- | ---: | --- | --- | --- | --- |",
    ])
    for row in summary.get("nextReviewCandidates", [])[:20]:
        first = row.get("firstStandablePoint")
        params = {"map": row["source"], "events": "1", "overview": "1"}
        trial_params = {"map": row["source"], "trialTransitions": "activeNearest"}
        if first:
            params["startTile"] = f"{first['x']},{first['y']}"
            params["focusTile"] = f"{first['x']},{first['y']}"
            params["transitionTarget"] = first["target"]
            trial_params["startTile"] = f"{first['x']},{first['y']}"
            trial_params["transitionTarget"] = first["target"]
        if row.get("recordVaHex"):
            params["transitionRecord"] = row["recordVaHex"]
            trial_params["transitionRecord"] = row["recordVaHex"]
        debug_href = web_href(web_prefix, params)
        trial_href = web_href(web_prefix, trial_params)
        review_href = f"transition_review_gaps/{row['source']}.html"
        review_cell = markdown_optional_link("review", review_href)
        first_text = f"{first['x']},{first['y']} -> {first['target']}" if first else "-"
        active_distance = first.get("activeDistance") if first else None
        lines.append(
            f"| {row['source']} | {row['targetCount']} | {row['standableUnreviewedPoints']} | "
            f"{active_distance if active_distance is not None else '-'} | "
            f"{first_text} | {row.get('sceneIdHex') or '-'} | "
            f"{row.get('eventKind') if row.get('eventKind') is not None else '-'} | "
            f"`{row.get('recordVaHex') or '-'}` | {review_cell} | [debug]({debug_href}) | [trial]({trial_href}) |"
        )
    if not summary.get("nextReviewCandidates"):
        lines.append("| - | - | - | - | - | - | - | - | - | - | - |")
    lines.extend([
        "",
        "## Remaining Transition Records",
        "",
        "| source | points | targets | standable unreviewed | scene | kind | record | debug open |",
        "| --- | ---: | --- | ---: | --- | ---: | --- | --- |",
    ])
    for row in summary["unconfirmedTransitionRecords"]:
        href = web_href(web_prefix, {"map": row["source"], "events": "1", "overview": "1"})
        lines.append(
            f"| {row['source']} | {row['points']} | {', '.join(row['targets'])} | "
            f"{row.get('standableUnreviewedPoints', 0)} | "
            f"{row.get('sceneIdHex') or '-'} | {row.get('eventKind') if row.get('eventKind') is not None else '-'} | "
            f"`{row.get('recordVaHex') or '-'}` | [debug]({href}) |"
        )
    if not summary["unconfirmedTransitionRecords"]:
        lines.append("| - | - | - | - | - | - | - | - |")
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict, web_prefix: str = "../web") -> str:
    rows = []
    for edge in summary["confirmedEdges"]:
        first = edge["points"][0]
        href = web_href(web_prefix, {"map": edge["source"], "startTile": f"{first['x']},{first['y']}"})
        points = ", ".join(
            f"{point['x']},{point['y']} -> {point.get('spawnX')},{point.get('spawnY')}"
            for point in edge["points"]
        )
        rows.append(
            "<tr>"
            f"<td>{html.escape(edge['source'])}</td>"
            f"<td>{html.escape(edge['target'])}</td>"
            f"<td>{html.escape(points)}</td>"
            f'<td><a href="{html.escape(href)}">open</a></td>'
            "</tr>"
        )
    next_rows = []
    for row in summary.get("nextReviewCandidates", [])[:20]:
        first = row.get("firstStandablePoint")
        params = {"map": row["source"], "events": "1", "overview": "1"}
        trial_params = {"map": row["source"], "trialTransitions": "activeNearest"}
        if first:
            params["startTile"] = f"{first['x']},{first['y']}"
            params["focusTile"] = f"{first['x']},{first['y']}"
            params["transitionTarget"] = first["target"]
            trial_params["startTile"] = f"{first['x']},{first['y']}"
            trial_params["transitionTarget"] = first["target"]
        if row.get("recordVaHex"):
            params["transitionRecord"] = row["recordVaHex"]
            trial_params["transitionRecord"] = row["recordVaHex"]
        debug_href = web_href(web_prefix, params)
        trial_href = web_href(web_prefix, trial_params)
        review_href = f"transition_review_gaps/{row['source']}.html"
        review_cell = html_optional_link("review", review_href)
        first_text = f"{first['x']},{first['y']} -> {first['target']}" if first else "-"
        active_distance = first.get("activeDistance") if first else None
        next_rows.append(
            "<tr>"
            f"<td>{html.escape(row['source'])}</td>"
            f"<td>{row['targetCount']}</td>"
            f"<td>{row['standableUnreviewedPoints']}</td>"
            f"<td>{active_distance if active_distance is not None else '-'}</td>"
            f"<td>{html.escape(first_text)}</td>"
            f"<td>{html.escape(row.get('sceneIdHex') or '-')}</td>"
            f"<td>{row.get('eventKind') if row.get('eventKind') is not None else '-'}</td>"
            f"<td><code>{html.escape(row.get('recordVaHex') or '-')}</code></td>"
            f"<td>{review_cell}</td>"
            f'<td><a href="{html.escape(debug_href)}">debug</a></td>'
            f'<td><a href="{html.escape(trial_href)}">trial</a></td>'
            "</tr>"
        )
    remaining = []
    for row in summary["unconfirmedTransitionRecords"]:
        href = web_href(web_prefix, {"map": row["source"], "events": "1", "overview": "1"})
        remaining.append(
            "<tr>"
            f"<td>{html.escape(row['source'])}</td>"
            f"<td>{row['points']}</td>"
            f"<td>{html.escape(', '.join(row['targets']))}</td>"
            f"<td>{row.get('standableUnreviewedPoints', 0)}</td>"
            f"<td>{html.escape(row.get('sceneIdHex') or '-')}</td>"
            f"<td>{row.get('eventKind') if row.get('eventKind') is not None else '-'}</td>"
            f"<td><code>{html.escape(row.get('recordVaHex') or '-')}</code></td>"
            f'<td><a href="{html.escape(href)}">debug</a></td>'
            "</tr>"
        )
    reachable = "\n".join(
        f'<li><a href="{html.escape(web_href(web_prefix, {"map": name}))}">{html.escape(name)}</a></li>'
        for name in summary["reachableFromStart"]
    ) or "<li>-</li>"
    entry_rows = []
    for route in summary["confirmedEntryRoutes"]:
        params = {"map": route["startMap"]}
        if route.get("startTile"):
            params["startTile"] = f"{route['startTile']['x']},{route['startTile']['y']}"
        href = web_href(web_prefix, params)
        entry_rows.append(
            "<tr>"
            f"<td>{html.escape(route['startMap'])}</td>"
            f"<td>{html.escape(', '.join(route['reachable']))}</td>"
            f'<td><a href="{html.escape(href)}">open</a></td>'
            "</tr>"
        )
    blocker_rows = []
    for blocker in summary.get("confirmedRouteBlockers", []):
        selector_risks = ", ".join(
            f"{item.get('target')} ({item.get('promotionRisk') or '-'})"
            for item in blocker.get("saveSelectorFrontierRisks") or []
        ) or ", ".join(blocker.get("saveSelectorFrontierTargets") or []) or "-"
        blocker_rows.append(
            "<tr>"
            f"<td>{html.escape(blocker['map'])}</td>"
            f"<td>{blocker.get('eventTransitionRecords', 0)}</td>"
            f"<td>{html.escape(', '.join(blocker.get('eventTargets') or []) or '-')}</td>"
            f"<td>{html.escape(selector_risks)}</td>"
            f"<td>{html.escape(blocker.get('reason') or '-')}</td>"
            "</tr>"
        )
    trial_rows = []
    for route in summary.get("trialActiveNearestEntryRoutes", [])[:20]:
        params = {"map": route["startMap"], "trialTransitions": "activeNearest"}
        if route.get("startTile"):
            params["startTile"] = f"{route['startTile']['x']},{route['startTile']['y']}"
        href = web_href(web_prefix, params)
        trial_rows.append(
            "<tr>"
            f"<td>{html.escape(route['startMap'])}</td>"
            f"<td>{html.escape(', '.join(route['reachable']))}</td>"
            f'<td><a href="{html.escape(href)}">open trial</a></td>'
            "</tr>"
        )
    map_exit_frontier_rows = []
    for edge in summary.get("mapExitTrialFrontier", [])[:20]:
        trial_href = web_href(web_prefix, {
            "map": edge["source"],
            "trialTransitions": "mapExitCandidates",
            "startTile": f"{edge['x']},{edge['y']}",
            "focusTile": f"{edge['x']},{edge['y']}",
        })
        target_href = web_href(web_prefix, {"map": edge["target"], "overview": "1"})
        map_exit_frontier_rows.append(
            "<tr>"
            f"<td>{html.escape(edge['source'])}</td>"
            f"<td>{html.escape(edge['target'])}</td>"
            f"<td>{html.escape(edge.get('side') or '-')}</td>"
            f"<td>{edge['x']},{edge['y']}</td>"
            f"<td>{html.escape(edge.get('status') or '-')}</td>"
            f'<td><a href="{html.escape(trial_href)}">trial</a></td>'
            f'<td><a href="{html.escape(target_href)}">target</a></td>'
            "</tr>"
        )
    map_exit_route_rows = []
    for route in summary.get("mapExitTrialEntryRoutes", [])[:20]:
        params = {"map": route["startMap"], "trialTransitions": "mapExitCandidates"}
        if route.get("startTile"):
            params["startTile"] = f"{route['startTile']['x']},{route['startTile']['y']}"
        href = web_href(web_prefix, params)
        map_exit_route_rows.append(
            "<tr>"
            f"<td>{html.escape(route['startMap'])}</td>"
            f"<td>{html.escape(', '.join(route['reachable']))}</td>"
            f'<td><a href="{html.escape(href)}">open</a></td>'
            "</tr>"
        )
    selector_frontier_rows = []
    for edge in summary.get("saveSelectorCandidateFrontier", [])[:20]:
        source_href = web_href(web_prefix, {"map": edge["source"], "events": "1", "overview": "1"})
        trial_href = web_href(web_prefix, {"map": edge["source"], "trialTransitions": "routeAssist"})
        target_href = web_href(web_prefix, {"map": edge["target"], "events": "1", "overview": "1"})
        selector_frontier_rows.append(
            "<tr>"
            f"<td>{html.escape(edge['source'])}</td>"
            f"<td>{html.escape(edge['target'])}</td>"
            f"<td>{html.escape(edge.get('promotionRisk') or '-')}</td>"
            f"<td>{html.escape(', '.join(edge.get('branchConditions') or []) or '-')}</td>"
            f"<td>{html.escape(', '.join(edge.get('selectors') or []) or '-')}</td>"
            f"<td>{html.escape(', '.join(edge.get('leafPointers') or []) or '-')}</td>"
            f'<td><a href="{html.escape(source_href)}">source</a></td>'
            f'<td><a href="{html.escape(trial_href)}">trial</a></td>'
            f'<td><a href="{html.escape(target_href)}">target</a></td>'
            "</tr>"
        )
    selector_route_rows = []
    for route in summary.get("saveSelectorCandidateEntryRoutes", [])[:20]:
        href = web_href(web_prefix, {"map": route["startMap"], "events": "1", "overview": "1", "trialTransitions": "routeAssist"})
        selector_route_rows.append(
            "<tr>"
            f"<td>{html.escape(route['startMap'])}</td>"
            f"<td>{html.escape(', '.join(route['reachable']))}</td>"
            f'<td><a href="{html.escape(href)}">open</a></td>'
            "</tr>"
        )
    return "\n".join([
        "<!doctype html>",
        '<meta charset="utf-8">',
        "<title>Playable Progress</title>",
        "<style>body{font-family:system-ui,sans-serif;background:#111;color:#ddd;margin:24px}a{color:#8fd3ff}table{border-collapse:collapse;width:100%;margin:16px 0}td,th{border:1px solid #333;padding:6px 8px;text-align:left}th{background:#1f1f1f}.metrics{display:flex;gap:12px;flex-wrap:wrap}.metric{background:#1b1b1b;border:1px solid #333;padding:10px 12px}.metric strong{display:block;font-size:22px}.missing{color:#999}</style>",
        "<h1>Playable Progress</h1>",
        "<p>Normal gameplay only uses confirmed transitions. Unreviewed transitions require <code>events=1</code> debug mode.</p>",
        '<div class="metrics">',
        f'<div class="metric"><strong>{summary["mapCount"]}</strong><span>maps</span></div>',
        f'<div class="metric"><strong>{summary["confirmedTransitionCount"]}</strong><span>confirmed points</span></div>',
        f'<div class="metric"><strong>{summary["confirmedEdgeCount"]}</strong><span>confirmed edges</span></div>',
        f'<div class="metric"><strong>{summary["reachableCount"]}</strong><span>reachable from {html.escape(summary["startMap"])}</span></div>',
        "</div>",
        f"<p>Reachable from start via confirmed transitions: {summary['reachableCount']}.</p>",
        "<h2>Reachable Maps</h2>",
        f"<ul>{reachable}</ul>",
        "<h2>Confirmed Edges</h2>",
        "<table><thead><tr><th>source</th><th>target</th><th>points</th><th>open</th></tr></thead><tbody>",
        "\n".join(rows) or '<tr><td colspan="4">No confirmed edges.</td></tr>',
        "</tbody></table>",
        "<h2>Confirmed Route Blockers</h2>",
        "<table><thead><tr><th>map</th><th>event records</th><th>event targets</th><th>save-selector frontier</th><th>reason</th></tr></thead><tbody>",
        "\n".join(blocker_rows) or '<tr><td colspan="5">No confirmed route blockers.</td></tr>',
        "</tbody></table>",
        "<h2>Confirmed Entry Routes</h2>",
        "<table><thead><tr><th>start map</th><th>reachable maps</th><th>open</th></tr></thead><tbody>",
        "\n".join(entry_rows) or '<tr><td colspan="3">No confirmed entry routes.</td></tr>',
        "</tbody></table>",
        "<h2>Active-Nearest Trial Routes</h2>",
        "<p><code>trialTransitions=activeNearest</code> keeps confirmed gameplay separate and lets the web runtime test one nearest standable unreviewed point per transition component.</p>",
        '<div class="metrics">',
        f'<div class="metric"><strong>{summary.get("trialActiveNearestTransitionCount", 0)}</strong><span>trial points</span></div>',
        f'<div class="metric"><strong>{summary.get("trialActiveNearestEdgeCount", 0)}</strong><span>trial edges</span></div>',
        f'<div class="metric"><strong>{len(summary.get("trialActiveNearestReachableFromStart", []))}</strong><span>trial reachable from {html.escape(summary["startMap"])}</span></div>',
        "</div>",
        "<table><thead><tr><th>start map</th><th>reachable maps</th><th>open trial</th></tr></thead><tbody>",
        "\n".join(trial_rows) or '<tr><td colspan="3">No trial routes.</td></tr>',
        "</tbody></table>",
        "<h2>Map-Exit Trial Routes</h2>",
        "<p><code>trialTransitions=mapExitCandidates</code> keeps geometry-only edge exits separate from confirmed gameplay. These candidates are for manual route testing and must not be treated as confirmed transitions. <code>trialTransitions=routeAssist</code> combines selector-only links and geometry-only exits so the web runtime can keep playtesting past blockers without promoting them to confirmed route evidence.</p>",
        '<div class="metrics">',
        f'<div class="metric"><strong>{summary.get("mapExitTrialTransitionCount", 0)}</strong><span>map-exit trial points</span></div>',
        f'<div class="metric"><strong>{summary.get("mapExitTrialEdgeCount", 0)}</strong><span>map-exit trial edges</span></div>',
        f'<div class="metric"><strong>{summary.get("mapExitTrialReachableCount", 0)}</strong><span>map-exit reachable from {html.escape(summary["startMap"])}</span></div>',
        "</div>",
        "<table><thead><tr><th>frontier source</th><th>target</th><th>side</th><th>point</th><th>status</th><th>trial source</th><th>open target</th></tr></thead><tbody>",
        "\n".join(map_exit_frontier_rows) or '<tr><td colspan="7">No map-exit frontier.</td></tr>',
        "</tbody></table>",
        "<table><thead><tr><th>start map</th><th>reachable maps</th><th>open exit trial</th></tr></thead><tbody>",
        "\n".join(map_exit_route_rows) or '<tr><td colspan="3">No map-exit routes.</td></tr>',
        "</tbody></table>",
        "<h2>Save Selector Candidate Routes</h2>",
        "<p>These routes add save-selector scene-link candidates on top of confirmed transitions. They are useful for review navigation, and <code>trialTransitions=routeAssist</code> can combine them with map-exit candidates, but they are not confirmed tile transitions.</p>",
        '<div class="metrics">',
        f'<div class="metric"><strong>{summary.get("saveSelectorCandidateEdgeCount", 0)}</strong><span>save-selector candidate edges</span></div>',
        f'<div class="metric"><strong>{summary.get("saveSelectorCandidateReachableCount", 0)}</strong><span>candidate reachable from {html.escape(summary["startMap"])}</span></div>',
        "</div>",
        "<table><thead><tr><th>frontier source</th><th>target</th><th>risk</th><th>branch gates</th><th>selector</th><th>leaf pointers</th><th>open source</th><th>trial source</th><th>open target</th></tr></thead><tbody>",
        "\n".join(selector_frontier_rows) or '<tr><td colspan="9">No save-selector frontier.</td></tr>',
        "</tbody></table>",
        "<table><thead><tr><th>start map</th><th>reachable maps</th><th>open candidate route</th></tr></thead><tbody>",
        "\n".join(selector_route_rows) or '<tr><td colspan="3">No save-selector routes.</td></tr>',
        "</tbody></table>",
        "<h2>Next Review Candidates</h2>",
        "<table><thead><tr><th>source</th><th>targets</th><th>standable unreviewed</th><th>active dist</th><th>first point</th><th>scene</th><th>kind</th><th>record</th><th>review</th><th>debug open</th><th>trial open</th></tr></thead><tbody>",
        "\n".join(next_rows) or '<tr><td colspan="11">No next review candidates.</td></tr>',
        "</tbody></table>",
        "<h2>Remaining Transition Records</h2>",
        "<table><thead><tr><th>source</th><th>points</th><th>targets</th><th>standable unreviewed</th><th>scene</th><th>kind</th><th>record</th><th>debug open</th></tr></thead><tbody>",
        "\n".join(remaining) or '<tr><td colspan="8">No remaining records.</td></tr>',
        "</tbody></table>",
    ])


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


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--maps", type=Path, default=OUT / "maps.js")
    parser.add_argument("--transitions", type=Path, default=OUT / "event_transitions.json")
    parser.add_argument("--reviews", type=Path, default=OUT / "transition_reviews.json")
    parser.add_argument("--transition-gaps", type=Path, default=OUT / "transition_review_gaps.json")
    parser.add_argument("--save-selector-links", type=Path, default=OUT / "save_selector_scene_links.json")
    parser.add_argument("--save-selector-frontier-branches", type=Path, default=OUT / "save_selector_frontier_branches.json")
    parser.add_argument("--map-exit-candidates", type=Path, default=OUT / "map_exit_candidates.json")
    parser.add_argument("--start-map", default="map1_02b")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()

    maps_text = args.maps.read_text(encoding="utf-8")
    prefix = "window.HWANSE_MAPS = "
    if not maps_text.startswith(prefix):
        raise SystemExit(f"{args.maps} is not a maps.js file")
    maps = json.loads(maps_text[len(prefix):].rstrip(";\n"))
    transitions = json.loads(args.transitions.read_text(encoding="utf-8"))
    reviews = json.loads(args.reviews.read_text(encoding="utf-8"))
    transition_gaps = json.loads(args.transition_gaps.read_text(encoding="utf-8")) if args.transition_gaps.exists() else []
    save_selector_links = json.loads(args.save_selector_links.read_text(encoding="utf-8")) if args.save_selector_links.exists() else {}
    save_selector_frontier_branches = (
        json.loads(args.save_selector_frontier_branches.read_text(encoding="utf-8"))
        if args.save_selector_frontier_branches.exists()
        else []
    )
    summary = build_summary(
        maps,
        transitions,
        reviews,
        args.start_map,
        transition_gap_rows=transition_gaps,
        save_selector_scene_links=save_selector_links,
    )
    annotate_save_selector_frontier_risks(summary, save_selector_frontier_branches)
    if args.map_exit_candidates.exists():
        annotate_map_exit_trial_routes(
            summary,
            json.loads(args.map_exit_candidates.read_text(encoding="utf-8")),
            maps,
        )
    write_outputs(summary, args.out_dir)
    print(f"wrote playable progress -> {args.out_dir / 'playable_progress.html'}")


if __name__ == "__main__":
    main()
