#!/usr/bin/env python3
"""Audit route-exit candidates against original CNS layer1 collision flags."""
from __future__ import annotations

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

from summarize_map_tiles import load_maps


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
ROUTE_SOURCE = "map1_01a"
ROUTE_TARGET = "map2_02d"
SIDE_ORDER = {"top": 0, "bottom": 1, "left": 2, "right": 3}
SIDE_VECTOR = {
    "top": (0, -1),
    "bottom": (0, 1),
    "left": (-1, 0),
    "right": (1, 0),
}
OPPOSITE_SIDE = {"top": "bottom", "bottom": "top", "left": "right", "right": "left"}
ORIGINAL_LAYER1_DIRECTION_BLOCK_BITS = {
    "down": 0x02,
    "up": 0x01,
    "left": 0x04,
    "right": 0x08,
}
FAILED_ORIGINAL_COLLISION_ROUTE_GATE_IDS = [
    "strict-source-hotspot",
    "selected-root-execution",
    "real-selector-2-0-savedata",
    "runtime-trace-or-equivalent-control-flow",
]
ORIGINAL_COLLISION_ROUTE_MISSING_EVIDENCE = [
    "strict source coordinate/hotspot",
    "selected-root execution proof",
    "real selector 2:0 captured save",
    "runtime trace or equivalent control-flow proof",
]
ORIGINAL_COLLISION_ROUTE_EVIDENCE_REFS = [
    {
        "path": "out/maps.js",
        "fields": [
            "map1_01a layer1 collision grid",
            "map2_02d layer1 collision grid",
        ],
    },
    {
        "path": "out/map_exit_candidates.json",
        "fields": [
            "exitCandidates",
            "targetHints",
            "collisionMode",
            "promotionStatus",
        ],
    },
    {
        "path": "out/runtime_movement.json",
        "fields": [
            "mapLoaderFunctionVaHex",
            "cnsLayer1CollisionGridVaHex",
            "collisionFlagTable",
            "directionBlockBits",
            "originalCollisionFlagTestsGrounded",
        ],
    },
]


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


def direction_bit(dx: int, dy: int) -> int:
    if dy > 0:
        return ORIGINAL_LAYER1_DIRECTION_BLOCK_BITS["down"]
    if dy < 0:
        return ORIGINAL_LAYER1_DIRECTION_BLOCK_BITS["up"]
    if dx < 0:
        return ORIGINAL_LAYER1_DIRECTION_BLOCK_BITS["left"]
    if dx > 0:
        return ORIGINAL_LAYER1_DIRECTION_BLOCK_BITS["right"]
    return 0x0F


def side_direction(side: str) -> tuple[int, int]:
    return SIDE_VECTOR.get(side, (0, 0))


def side_name_for_vector(dx: int, dy: int) -> str:
    if dy > 0:
        return "down"
    if dy < 0:
        return "up"
    if dx < 0:
        return "left"
    if dx > 0:
        return "right"
    return "none"


def tile_index(info: dict, tile_x: int, tile_y: int) -> int:
    return tile_y * info["width"] + tile_x


def tile_at(info: dict, tile_x: int, tile_y: int) -> tuple[int, int]:
    index = tile_index(info, tile_x, tile_y)
    return info["layers"][0][index], info["layers"][1][index]


def in_bounds(info: dict, tile_x: int, tile_y: int) -> bool:
    return 0 <= tile_x < info["width"] and 0 <= tile_y < info["height"]


def layer1_flag_passes(info: dict, tile_x: int, tile_y: int, dx: int = 0, dy: int = 0) -> bool:
    if not in_bounds(info, tile_x, tile_y):
        return False
    _, layer1 = tile_at(info, tile_x, tile_y)
    return (layer1 & direction_bit(dx, dy)) == 0


def layer1_tile_passable(info: dict, tile_x: int, tile_y: int) -> bool:
    return layer1_flag_passes(info, tile_x, tile_y)


def footprint_tiles_for_tile(info: dict, tile_x: int, tile_y: int) -> list[tuple[int, int]]:
    tile_size = info["tileSize"]
    pixel_x = tile_x * tile_size - 16
    pixel_y = tile_y * tile_size - 16
    return [
        ((pixel_x + 8) // tile_size, (pixel_y + 18) // tile_size),
        ((pixel_x + 23) // tile_size, (pixel_y + 18) // tile_size),
        ((pixel_x + 8) // tile_size, (pixel_y + 31) // tile_size),
        ((pixel_x + 23) // tile_size, (pixel_y + 31) // tile_size),
    ]


def footprint_checks(info: dict, tile_x: int, tile_y: int, dx: int = 0, dy: int = 0) -> list[dict]:
    checks = []
    bit = direction_bit(dx, dy)
    for foot_x, foot_y in footprint_tiles_for_tile(info, tile_x, tile_y):
        row: dict[str, int | str | bool | None] = {
            "x": foot_x,
            "y": foot_y,
            "inBounds": in_bounds(info, foot_x, foot_y),
            "directionBitHex": f"0x{bit:02x}",
        }
        if row["inBounds"]:
            layer0, layer1 = tile_at(info, foot_x, foot_y)
            row.update({
                "layer0": layer0,
                "layer1": layer1,
                "layer1Hex": f"0x{layer1:04x}",
                "lowNibbleHex": f"0x{layer1 & 0x0F:01x}",
                "passes": (layer1 & bit) == 0,
            })
        else:
            row.update({
                "layer0": None,
                "layer1": None,
                "layer1Hex": None,
                "lowNibbleHex": None,
                "passes": False,
            })
        checks.append(row)
    return checks


def can_stand_at(info: dict, tile_x: int, tile_y: int, dx: int = 0, dy: int = 0) -> bool:
    return all(row["passes"] is True for row in footprint_checks(info, tile_x, tile_y, dx, dy))


def can_move_from_tile(info: dict, tile_x: int, tile_y: int, dx: int, dy: int) -> bool:
    target_x = tile_x + dx
    target_y = tile_y + dy
    if not in_bounds(info, target_x, target_y):
        return False
    return can_stand_at(info, target_x, target_y, dx, dy)


def connected_components(info: dict) -> list[list[tuple[int, int]]]:
    seen: set[tuple[int, int]] = set()
    components: list[list[tuple[int, int]]] = []
    for y in range(info["height"]):
        for x in range(info["width"]):
            start = (x, y)
            if start in seen or not layer1_tile_passable(info, x, y):
                continue
            seen.add(start)
            cells = []
            queue = deque([start])
            while queue:
                cell_x, cell_y = queue.popleft()
                cells.append((cell_x, cell_y))
                for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)):
                    next_cell = (cell_x + dx, cell_y + dy)
                    if next_cell in seen or not layer1_tile_passable(info, next_cell[0], next_cell[1]):
                        continue
                    seen.add(next_cell)
                    queue.append(next_cell)
            components.append(cells)
    components.sort(key=len, reverse=True)
    return components


def layer1_stats(info: dict) -> dict:
    layer1 = info["layers"][1]
    low_nibble_counts: dict[str, int] = {}
    for value in layer1:
        key = f"0x{value & 0x0F:01x}"
        low_nibble_counts[key] = low_nibble_counts.get(key, 0) + 1
    direction_counts = {
        name: sum(1 for value in layer1 if value & bit)
        for name, bit in ORIGINAL_LAYER1_DIRECTION_BLOCK_BITS.items()
    }
    components = connected_components(info)
    footprint_standable = sum(
        1
        for y in range(info["height"])
        for x in range(info["width"])
        if can_stand_at(info, x, y)
    )
    return {
        "map": info["name"],
        "width": info["width"],
        "height": info["height"],
        "tileSize": info["tileSize"],
        "layerTilesets": info.get("layerTilesets") or [info.get("tileset")],
        "tileCount": info["width"] * info["height"],
        "tileLevelPassableCount": sum(1 for y in range(info["height"]) for x in range(info["width"]) if layer1_tile_passable(info, x, y)),
        "tileLevelBlockedCount": sum(1 for value in layer1 if value & 0x0F),
        "footprintStandableTileCount": footprint_standable,
        "componentCount": len(components),
        "largestComponentSize": len(components[0]) if components else 0,
        "lowNibbleCounts": dict(sorted(low_nibble_counts.items())),
        "directionBitBlockedTileCounts": direction_counts,
    }


def source_row(map_exit_candidates: list[dict], name: str) -> dict:
    return next((row for row in map_exit_candidates if row.get("map") == name), {})


def candidate_target_hint(candidate: dict, target: str) -> dict:
    for hint in candidate.get("targetHints") or []:
        if hint.get("target") == target:
            return hint
    return {}


def candidate_url(name: str, x: int, y: int, target: str | None = None) -> str:
    params = {
        "map": name,
        "startTile": f"{x},{y}",
        "focusTile": f"{x},{y}",
        "collision": "1",
        "collisionMode": "originalLayer1Flags",
        "overview": "1",
    }
    if target:
        params["trialTransitions"] = "routeAssist"
        params["transitionTarget"] = target
    return web_href(params)


def audit_candidate(source_info: dict, target_info: dict, candidate: dict, target: str) -> dict:
    sample = candidate.get("sample") or {}
    source_x = int(sample.get("x"))
    source_y = int(sample.get("y"))
    layer0, layer1 = tile_at(source_info, source_x, source_y)
    dx, dy = side_direction(candidate.get("side") or "")
    direction_name = side_name_for_vector(dx, dy)
    target_hint = candidate_target_hint(candidate, target)
    target_x = target_hint.get("x")
    target_y = target_hint.get("y")
    target_spawn: dict[str, object] | None = None
    if isinstance(target_x, int) and isinstance(target_y, int) and in_bounds(target_info, target_x, target_y):
        target_layer0, target_layer1 = tile_at(target_info, target_x, target_y)
        inward_dx, inward_dy = side_direction(OPPOSITE_SIDE.get(target_hint.get("side") or "", ""))
        target_spawn = {
            "map": target,
            "side": target_hint.get("side"),
            "x": target_x,
            "y": target_y,
            "layer0": target_layer0,
            "layer1": target_layer1,
            "layer1Hex": f"0x{target_layer1:04x}",
            "lowNibbleHex": f"0x{target_layer1 & 0x0F:01x}",
            "originalStandable": can_stand_at(target_info, target_x, target_y),
            "inwardDirection": side_name_for_vector(inward_dx, inward_dy),
            "inwardMoveAllowed": can_move_from_tile(target_info, target_x, target_y, inward_dx, inward_dy),
            "reviewUrl": candidate_url(target, target_x, target_y),
        }
    return {
        "side": candidate.get("side"),
        "x": source_x,
        "y": source_y,
        "edgeDistance": candidate.get("edgeDistance"),
        "autoTrigger": candidate.get("edgeDistance") == 0 and sample.get("standable") is True,
        "tileClassStandable": sample.get("standable"),
        "layer0": layer0,
        "layer1": layer1,
        "layer1Hex": f"0x{layer1:04x}",
        "lowNibbleHex": f"0x{layer1 & 0x0F:01x}",
        "outwardDirection": direction_name,
        "originalStandable": can_stand_at(source_info, source_x, source_y),
        "outwardFootprintDirectionClear": can_stand_at(source_info, source_x, source_y, dx, dy),
        "outwardMoveWouldStayInBounds": in_bounds(source_info, source_x + dx, source_y + dy),
        "outwardMoveAllowedInsideMap": can_move_from_tile(source_info, source_x, source_y, dx, dy),
        "footprint": footprint_checks(source_info, source_x, source_y),
        "outwardFootprint": footprint_checks(source_info, source_x, source_y, dx, dy),
        "targetHint": target_hint,
        "targetSpawnOriginalLayer1Flags": target_spawn,
        "reviewUrl": candidate_url(source_info["name"], source_x, source_y),
        "routeAssistUrl": candidate_url(source_info["name"], source_x, source_y, target),
    }


def route_candidates(source_exit_row: dict, source_info: dict, target_info: dict, target: str) -> list[dict]:
    candidates = [
        candidate
        for candidate in source_exit_row.get("exitCandidates") or []
        if target in (candidate.get("blockedTargetCandidates") or [])
        or target in (candidate.get("selectorTargetCandidates") or [])
        or any((hint.get("target") == target) for hint in (candidate.get("targetHints") or []))
    ]
    rows = [audit_candidate(source_info, target_info, candidate, target) for candidate in candidates]
    rows.sort(key=lambda row: (SIDE_ORDER.get(str(row.get("side")), 99), row.get("edgeDistance") or 0, row.get("x") or 0, row.get("y") or 0))
    return rows


def build_summary(
    maps: dict[str, dict],
    map_exit_candidates: list[dict],
    source: str = ROUTE_SOURCE,
    target: str = ROUTE_TARGET,
) -> dict:
    source_info = maps[source]
    target_info = maps[target]
    source_exit = source_row(map_exit_candidates, source)
    target_exit = source_row(map_exit_candidates, target)
    candidates = route_candidates(source_exit, source_info, target_info, target)
    source_original_standable = sum(1 for row in candidates if row.get("originalStandable") is True)
    target_original_standable = sum(
        1
        for row in candidates
        if (row.get("targetSpawnOriginalLayer1Flags") or {}).get("originalStandable") is True
    )
    return {
        "title": "Original Collision Route Audit",
        "route": {
            "source": source,
            "target": target,
        },
        "collisionMode": "originalLayer1Flags",
        "promotionStatus": "diagnostic-only",
        "promotionAllowed": False,
        "proofFound": False,
        "originalCollisionRouteProofFound": False,
        "failedOriginalCollisionRouteGateIds": list(FAILED_ORIGINAL_COLLISION_ROUTE_GATE_IDS),
        "reason": (
            "CNS layer1 collision flags can recheck geometry candidates, but they do not provide "
            "a strict source hotspot, selected-root execution proof, real selector 2:0 save, or runtime trace."
        ),
        "runtimeMapping": {
            "source": "out/runtime_movement.*",
            "mapLoaderFunctionVaHex": "0x0042449c",
            "collisionHelperFunctionVaHex": "0x004319f8",
            "cnsLayer1CollisionGridVaHex": "0x0058d7d0",
            "standabilityBlockMaskHex": "0x0f",
            "directionBlockBits": {name: f"0x{bit:02x}" for name, bit in ORIGINAL_LAYER1_DIRECTION_BLOCK_BITS.items()},
        },
        "sourceTileClassExitSummary": {
            "collisionMode": source_exit.get("collisionMode"),
            "passableTileCount": source_exit.get("passableTileCount"),
            "componentCount": source_exit.get("componentCount"),
            "exitCandidateCount": len(source_exit.get("exitCandidates") or []),
            "priority": source_exit.get("priority"),
            "promotionStatus": source_exit.get("promotionStatus"),
        },
        "targetTileClassExitSummary": {
            "collisionMode": target_exit.get("collisionMode"),
            "passableTileCount": target_exit.get("passableTileCount"),
            "componentCount": target_exit.get("componentCount"),
            "exitCandidateCount": len(target_exit.get("exitCandidates") or []),
            "promotionStatus": target_exit.get("promotionStatus"),
        },
        "originalLayer1MapStats": {
            source: layer1_stats(source_info),
            target: layer1_stats(target_info),
        },
        "routeCandidateCount": len(candidates),
        "sourceOriginalStandableCandidateCount": source_original_standable,
        "targetOriginalStandableSpawnCount": target_original_standable,
        "allSourceCandidatesOriginalStandable": source_original_standable == len(candidates) and bool(candidates),
        "allTargetSpawnsOriginalStandable": target_original_standable == len(candidates) and bool(candidates),
        "routeCandidates": candidates,
        "missingPromotionEvidence": list(ORIGINAL_COLLISION_ROUTE_MISSING_EVIDENCE),
        "missingEvidence": list(ORIGINAL_COLLISION_ROUTE_MISSING_EVIDENCE),
        "evidenceRefs": list(ORIGINAL_COLLISION_ROUTE_EVIDENCE_REFS),
        "evidenceRefCount": len(ORIGINAL_COLLISION_ROUTE_EVIDENCE_REFS),
        "conclusion": (
            f"{source} -> {target} geometry candidates can be opened with collisionMode=originalLayer1Flags "
            "for collision review, but this is diagnostic only and keeps normal-route promotion blocked."
        ),
    }


def markdown(summary: dict) -> str:
    route = summary["route"]
    source = route["source"]
    target = route["target"]
    source_stats = summary["originalLayer1MapStats"][source]
    target_stats = summary["originalLayer1MapStats"][target]
    lines = [
        "# Original Collision Route Audit",
        "",
        f"Route: `{source} -> {target}`",
        "",
        (
            "`originalLayer1Flags` rechecks the existing geometry-only exit candidates against the "
            "CNS layer1 collision flag grid identified in `out/runtime_movement.*`. "
            "This remains diagnostic-only and does not promote the route."
        ),
        "",
        "## Gate",
        "",
        f"- promotionAllowed: `{summary['promotionAllowed']}`",
        f"- promotionStatus: `{summary['promotionStatus']}`",
        f"- proofFound: `{summary['proofFound']}`",
        f"- originalCollisionRouteProofFound: `{summary['originalCollisionRouteProofFound']}`",
        f"- failed original-collision route gates: `{', '.join(summary['failedOriginalCollisionRouteGateIds'])}`",
        f"- missingEvidenceCount: `{len(summary['missingEvidence'])}`",
        f"- evidenceRefs: `{summary['evidenceRefCount']}`",
        f"- reason: {summary['reason']}",
        f"- missing: {', '.join(summary['missingPromotionEvidence'])}",
        "",
        "## Missing Evidence",
        "",
        *[f"- {item}" for item in summary["missingEvidence"]],
        "",
        "## Evidence Refs",
        "",
        *[
            f"- `{ref['path']}`: {', '.join(ref['fields'])}"
            for ref in summary["evidenceRefs"]
        ],
        "",
        "## Map Collision Stats",
        "",
        "| map | size | layer1 passable tiles | footprint-standable tiles | components | largest component | low-nibble blocked |",
        "| --- | ---: | ---: | ---: | ---: | ---: | ---: |",
    ]
    for stats in (source_stats, target_stats):
        lines.append(
            f"| {stats['map']} | {stats['width']}x{stats['height']} | "
            f"{stats['tileLevelPassableCount']} | {stats['footprintStandableTileCount']} | "
            f"{stats['componentCount']} | {stats['largestComponentSize']} | {stats['tileLevelBlockedCount']} |"
        )
    lines.extend([
        "",
        "## Route Candidates",
        "",
        "| side | source tile | tileClass standable | original standable | outward flags clear | inside move | target spawn | target original standable | links |",
        "| --- | --- | ---: | ---: | ---: | ---: | --- | ---: | --- |",
    ])
    for row in summary["routeCandidates"]:
        target_spawn = row.get("targetSpawnOriginalLayer1Flags") or {}
        target_label = (
            f"{target_spawn.get('side')} {target_spawn.get('x')},{target_spawn.get('y')}"
            if target_spawn else "-"
        )
        links = f"[review]({row['reviewUrl']}), [routeAssist]({row['routeAssistUrl']})"
        lines.append(
            f"| {row['side']} | {row['x']},{row['y']} L1={row['layer1Hex']} | "
            f"{row['tileClassStandable']} | {row['originalStandable']} | "
            f"{row['outwardFootprintDirectionClear']} | {row['outwardMoveAllowedInsideMap']} | "
            f"{target_label} | {target_spawn.get('originalStandable', '-')} | {links} |"
        )
    lines.extend([
        "",
        "## Conclusion",
        "",
        summary["conclusion"],
        "",
    ])
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    route = summary["route"]
    source = route["source"]
    target = route["target"]
    missing_items = [f"    <li>{html.escape(item)}</li>" for item in summary["missingEvidence"]]
    evidence_ref_items = [
        (
            f"    <li><code>{html.escape(ref['path'])}</code>: "
            f"{html.escape(', '.join(ref['fields']))}</li>"
        )
        for ref in summary["evidenceRefs"]
    ]
    stats_rows = []
    for stats in (summary["originalLayer1MapStats"][source], summary["originalLayer1MapStats"][target]):
        stats_rows.append(
            "\n".join([
                "<tr>",
                f"  <td>{html.escape(stats['map'])}</td>",
                f"  <td>{stats['width']}x{stats['height']}</td>",
                f"  <td>{stats['tileLevelPassableCount']}</td>",
                f"  <td>{stats['footprintStandableTileCount']}</td>",
                f"  <td>{stats['componentCount']}</td>",
                f"  <td>{stats['largestComponentSize']}</td>",
                f"  <td>{stats['tileLevelBlockedCount']}</td>",
                "</tr>",
            ])
        )
    candidate_rows = []
    for row in summary["routeCandidates"]:
        target_spawn = row.get("targetSpawnOriginalLayer1Flags") or {}
        target_label = (
            f"{target_spawn.get('side')} {target_spawn.get('x')},{target_spawn.get('y')}"
            if target_spawn else "-"
        )
        candidate_rows.append(
            "\n".join([
                "<tr>",
                f"  <td>{html.escape(str(row['side']))}</td>",
                f"  <td>{row['x']},{row['y']} L1={html.escape(row['layer1Hex'])}</td>",
                f"  <td>{row['tileClassStandable']}</td>",
                f"  <td>{row['originalStandable']}</td>",
                f"  <td>{row['outwardFootprintDirectionClear']}</td>",
                f"  <td>{row['outwardMoveAllowedInsideMap']}</td>",
                f"  <td>{html.escape(target_label)}</td>",
                f"  <td>{html.escape(str(target_spawn.get('originalStandable', '-')))}</td>",
                f'  <td><a href="{html.escape(row["reviewUrl"])}">review</a>, <a href="{html.escape(row["routeAssistUrl"])}">routeAssist</a></td>',
                "</tr>",
            ])
        )
    return "\n".join([
        "<!doctype html>",
        '<html lang="en">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>Original Collision Route Audit</title>",
        "  <style>",
        "    :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #101010; color: #eee; }",
        "    body { margin: 0; padding: 24px; }",
        "    h1 { margin: 0 0 8px; font-size: 24px; }",
        "    h2 { margin: 26px 0 10px; font-size: 18px; }",
        "    p { margin: 0 0 14px; color: #bbb; max-width: 980px; line-height: 1.45; }",
        "    table { width: 100%; border-collapse: collapse; margin: 0 0 16px; font-size: 13px; }",
        "    th, td { border-bottom: 1px solid #303030; padding: 7px 8px; text-align: left; vertical-align: top; }",
        "    th { position: sticky; top: 0; background: #181818; z-index: 1; color: #ddd; }",
        "    code { color: #d8d8d8; }",
        "    a { color: #8fd0ff; text-decoration: none; }",
        "    a:hover { text-decoration: underline; }",
        "  </style>",
        "</head>",
        "<body>",
        "  <h1>Original Collision Route Audit</h1>",
        f"  <p>Route <code>{html.escape(source)} -&gt; {html.escape(target)}</code>. The audit rechecks geometry-only exit candidates with <code>originalLayer1Flags</code> and keeps promotion diagnostic-only.</p>",
        f"  <p>promotionAllowed=<code>{summary['promotionAllowed']}</code>, promotionStatus=<code>{html.escape(summary['promotionStatus'])}</code>. {html.escape(summary['reason'])}</p>",
        (
            f"  <p>proofFound=<code>{summary['proofFound']}</code>, "
            f"originalCollisionRouteProofFound=<code>{summary['originalCollisionRouteProofFound']}</code>, "
            f"failed original-collision route gates=<code>{html.escape(','.join(summary['failedOriginalCollisionRouteGateIds']))}</code>, "
            f"missingEvidenceCount=<code>{len(summary['missingEvidence'])}</code>, "
            f"evidenceRefs=<code>{summary['evidenceRefCount']}</code>.</p>"
        ),
        "  <h2>Missing Evidence</h2>",
        "  <ul>",
        *missing_items,
        "  </ul>",
        "  <h2>Evidence Refs</h2>",
        "  <ul>",
        *evidence_ref_items,
        "  </ul>",
        "  <h2>Map Collision Stats</h2>",
        "  <table>",
        "    <thead><tr><th>map</th><th>size</th><th>layer1 passable</th><th>footprint standable</th><th>components</th><th>largest</th><th>blocked</th></tr></thead>",
        "    <tbody>",
        *stats_rows,
        "    </tbody>",
        "  </table>",
        "  <h2>Route Candidates</h2>",
        "  <table>",
        "    <thead><tr><th>side</th><th>source tile</th><th>tileClass</th><th>original</th><th>outward flags</th><th>inside move</th><th>target spawn</th><th>target original</th><th>links</th></tr></thead>",
        "    <tbody>",
        *candidate_rows,
        "    </tbody>",
        "  </table>",
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        "</body>",
        "</html>",
        "",
    ])


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


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--maps", type=Path, default=OUT / "maps.js")
    parser.add_argument("--map-exit-candidates", type=Path, default=OUT / "map_exit_candidates.json")
    parser.add_argument("--source", default=ROUTE_SOURCE)
    parser.add_argument("--target", default=ROUTE_TARGET)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(
        load_maps(args.maps),
        json.loads(args.map_exit_candidates.read_text(encoding="utf-8")),
        source=args.source,
        target=args.target,
    )
    write_outputs(summary, args.out_dir)
    print(f"wrote original collision route audit -> {args.out_dir / 'original_collision_route_audit.html'}")


if __name__ == "__main__":
    main()
