#!/usr/bin/env python3
"""Validate, merge, and export reviewed field-map transition candidates."""
from __future__ import annotations

import argparse
import html
import json
import shutil
from pathlib import Path
from urllib.parse import urlencode

from decode_cns import write_png
from map_thumbnail import render_thumbnail
from summarize_map_tiles import load_maps


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

STATES = {"confirmed", "rejected"}


def record_key(record: dict) -> str:
    value = record.get("recordVaHex") or record.get("recordVa")
    return str(value) if value is not None else "record"


def active_distance(x: int, y: int, active_points: list[dict]) -> int | None:
    distances = [
        abs(x - point["x"]) + abs(y - point["y"])
        for point in active_points
        if isinstance(point.get("x"), int) and isinstance(point.get("y"), int)
    ]
    return min(distances) if distances else None


def review_key(source: str, x: int, y: int, target: str, record: str | int | None = None) -> str:
    record_part = str(record) if record is not None else "record"
    return f"{source}@{record_part}:{x},{y}->{target}"


def transition_index(transitions: list[dict]) -> dict[str, dict]:
    index = {}
    for row in transitions:
        source = row["map"]
        targets = set(row.get("targets") or [])
        target_scene_ids = {}
        for choice in row.get("conditionChoices") or []:
            for target, scene_info in (choice.get("targetSceneIds") or {}).items():
                target_scene_ids[target] = scene_info
        for point in row.get("points") or []:
            x = point.get("x")
            y = point.get("y")
            if not isinstance(x, int) or not isinstance(y, int):
                continue
            active_points = row.get("activePoints") or []
            for target in targets:
                index[review_key(source, x, y, target, record_key(row))] = {
                    "source": source,
                    "x": x,
                    "y": y,
                    "target": target,
                    "recordVa": row.get("recordVa"),
                    "recordVaHex": row.get("recordVaHex"),
                    "sceneId": row.get("sceneId"),
                    "sceneIdHex": row.get("sceneIdHex"),
                    "eventKind": row.get("eventKind"),
                    "targetSceneId": target_scene_ids.get(target, {}).get("sceneId"),
                    "targetSceneIdHex": target_scene_ids.get(target, {}).get("sceneIdHex"),
                    "activePoints": active_points,
                    "activeDistance": active_distance(x, y, active_points),
                }
    return index


def normalize(data: object, valid: dict[str, dict]) -> dict[str, dict]:
    if not isinstance(data, dict):
        raise ValueError("transition review data must be an object")
    normalized = {}
    for key, value in data.items():
        if not isinstance(key, str):
            raise ValueError("transition review keys must be strings")
        if key not in valid:
            raise ValueError(f"{key}: review does not match an event transition candidate")
        if not isinstance(value, dict):
            raise ValueError(f"{key}: review entry must be an object")
        state = value.get("state")
        if state not in STATES:
            raise ValueError(f"{key}: state must be one of {sorted(STATES)}")
        expected = valid[key]
        for field in ["source", "target", "x", "y"]:
            if value.get(field) != expected[field]:
                raise ValueError(f"{key}: {field} must be {expected[field]!r}")
        for field in ["recordVa", "recordVaHex"]:
            if expected.get(field) is not None and value.get(field) != expected[field]:
                raise ValueError(f"{key}: {field} must be {expected[field]!r}")
        normalized[key] = {
            "source": expected["source"],
            "x": expected["x"],
            "y": expected["y"],
            "target": expected["target"],
            "state": state,
        }
        has_spawn_x = "spawnX" in value
        has_spawn_y = "spawnY" in value
        if has_spawn_x != has_spawn_y:
            raise ValueError(f"{key}: spawnX and spawnY must be provided together")
        if has_spawn_x:
            spawn_x = value["spawnX"]
            spawn_y = value["spawnY"]
            if not isinstance(spawn_x, int) or not isinstance(spawn_y, int):
                raise ValueError(f"{key}: spawnX/spawnY must be integers")
            normalized[key]["spawnX"] = spawn_x
            normalized[key]["spawnY"] = spawn_y
        for field in ["recordVa", "recordVaHex", "sceneId", "sceneIdHex", "eventKind", "targetSceneId", "targetSceneIdHex"]:
            if expected.get(field) is not None:
                normalized[key][field] = expected[field]
    return dict(sorted(normalized.items()))


def load_reviews(path: Path, valid: dict[str, dict]) -> dict[str, dict]:
    if not path.exists():
        return {}
    return normalize(json.loads(path.read_text(encoding="utf-8")), valid)


def load_transitions(path: Path) -> list[dict]:
    data = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(data, list):
        raise ValueError("event transitions must be a list")
    return data


def load_tile_classes(path: Path) -> dict:
    if not path.exists():
        return {}
    data = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(data, dict):
        raise ValueError("tile class data must be an object")
    return data


def tile_passable(map_info: dict, tile_x: int, tile_y: int, tile_classes: dict) -> bool:
    if tile_x < 0 or tile_y < 0 or tile_x >= map_info["width"] or tile_y >= map_info["height"]:
        return False
    index = tile_y * map_info["width"] + tile_x
    layer0 = map_info["layers"][0][index]
    layer1 = map_info["layers"][1][index]
    entry = tile_classes.get(map_info.get("tileset"), {})
    if [layer0, layer1] in entry.get("passPairs", []):
        return True
    if [layer0, layer1] in entry.get("blockPairs", []):
        return False
    if layer0 in entry.get("pass", []):
        return True
    if layer0 in entry.get("block", []):
        return False
    return layer0 > 0


def standable_tile(map_info: dict, tile_x: int, tile_y: int, tile_classes: dict) -> bool:
    tile_size = map_info["tileSize"]
    pixel_x = tile_x * tile_size
    pixel_y = tile_y * tile_size - 16
    points = [
        ((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),
    ]
    return all(tile_passable(map_info, x, y, tile_classes) for x, y in points)


def annotate_source_standability(rows: list[dict], maps: dict, tile_classes: dict) -> None:
    for row in rows:
        source = maps.get(row["source"])
        row["sourceStandable"] = (
            bool(source)
            and standable_tile(source, row["x"], row["y"], tile_classes)
        )


def preferred_row(rows: list[dict]) -> dict:
    return next((row for row in rows if row.get("sourceStandable")), rows[0])


def validate_maps(reviews: dict[str, dict], maps: dict) -> None:
    for key, review in reviews.items():
        source = review["source"]
        target = review["target"]
        if source not in maps:
            raise ValueError(f"{key}: source map is missing from maps.js")
        if target not in maps:
            raise ValueError(f"{key}: target map is missing from maps.js")
        info = maps[source]
        x = review["x"]
        y = review["y"]
        if x < 0 or y < 0 or x >= info["width"] or y >= info["height"]:
            raise ValueError(f"{key}: source point is outside map bounds")
        if "spawnX" in review or "spawnY" in review:
            if "spawnX" not in review or "spawnY" not in review:
                raise ValueError(f"{key}: spawnX and spawnY must be provided together")
            target_info = maps[target]
            spawn_x = review["spawnX"]
            spawn_y = review["spawnY"]
            if spawn_x < 0 or spawn_y < 0 or spawn_x >= target_info["width"] or spawn_y >= target_info["height"]:
                raise ValueError(f"{key}: spawn point is outside target map bounds")


def merge(base: dict[str, dict], patch: dict[str, dict]) -> dict[str, dict]:
    merged = dict(base)
    merged.update(patch)
    return dict(sorted(merged.items()))


def merge_summary(base: dict[str, dict], patch: dict[str, dict]) -> dict[str, object]:
    added = [key for key in patch if key not in base]
    changed = [
        key
        for key, review in patch.items()
        if key in base and base[key].get("state") != review.get("state")
    ]
    unchanged = [
        key
        for key, review in patch.items()
        if key in base and base[key].get("state") == review.get("state")
    ]
    states = {state: 0 for state in sorted(STATES)}
    sources = set()
    targets = set()
    for review in patch.values():
        states[review["state"]] += 1
        sources.add(review["source"])
        targets.add(review["target"])
    return {
        "patchRecords": len(patch),
        "addedRecords": len(added),
        "changedRecords": len(changed),
        "unchangedRecords": len(unchanged),
        "states": states,
        "sources": sorted(sources),
        "targets": sorted(targets),
    }


def print_merge_summary(summary: dict[str, object], merged_count: int) -> None:
    states = summary["states"]
    print(
        "merge summary: "
        f"patch records {summary['patchRecords']}, "
        f"added {summary['addedRecords']}, "
        f"changed {summary['changedRecords']}, "
        f"unchanged {summary['unchangedRecords']}, "
        f"merged total {merged_count}"
    )
    print(
        "states: "
        + ", ".join(f"{state}={count}" for state, count in states.items())
    )
    print("sources: " + (", ".join(summary["sources"]) or "-"))
    print("targets: " + (", ".join(summary["targets"]) or "-"))


def markdown(reviews: dict[str, dict]) -> str:
    lines = [
        "# Transition Reviews",
        "",
        "Generated from `data/transition_reviews.json` via `tools/transition_reviews.py`.",
        "",
        "| source | point | target | state | spawn | scene | kind | record | open |",
        "| --- | --- | --- | --- | --- | --- | ---: | --- | --- |",
    ]
    for review in reviews.values():
        source = review["source"]
        x = review["x"]
        y = review["y"]
        target = review["target"]
        state = review["state"]
        spawn = (
            f"{review['spawnX']},{review['spawnY']}"
            if "spawnX" in review and "spawnY" in review
            else "-"
        )
        scene = review.get("sceneIdHex") or "-"
        kind = review.get("eventKind")
        record = review.get("recordVaHex") or (
            f"0x{review['recordVa']:08x}" if review.get("recordVa") is not None else "-"
        )
        link = f"../web/game.html?map={source}&startTile={x},{y}&focusTile={x},{y}&events=1&overview=1"
        lines.append(
            f"| {source} | {x},{y} | {target} | {state} | {spawn} | {scene} | "
            f"{kind if kind is not None else '-'} | `{record}` | [open]({link}) |"
        )
    if not reviews:
        lines.append("| - | - | - | - | - | - | - | - | - |")
    lines.append("")
    return "\n".join(lines)


def gap_rows(valid: dict[str, dict], reviews: dict[str, dict]) -> list[dict]:
    rows = []
    for key, candidate in sorted(valid.items()):
        review = reviews.get(key)
        rows.append({
            **candidate,
            "key": key,
            "state": review["state"] if review else "unreviewed",
        })
    return rows


def gap_summary(rows: list[dict]) -> dict[str, int]:
    summary = {"confirmed": 0, "rejected": 0, "unreviewed": 0}
    for row in rows:
        summary[row["state"]] = summary.get(row["state"], 0) + 1
    return summary


def record_group_key(row: dict) -> tuple:
    return (
        row["source"],
        row.get("recordVaHex") or row.get("recordVa") or "",
        row.get("sceneIdHex") or row.get("sceneId") or "",
        row.get("eventKind") if row.get("eventKind") is not None else "",
    )


def record_groups(rows: list[dict]) -> list[dict]:
    grouped: dict[tuple, dict] = {}
    for row in rows:
        key = record_group_key(row)
        group = grouped.setdefault(
            key,
            {
                "source": row["source"],
                "recordVa": row.get("recordVa"),
                "recordVaHex": row.get("recordVaHex"),
                "sceneId": row.get("sceneId"),
                "sceneIdHex": row.get("sceneIdHex"),
                "eventKind": row.get("eventKind"),
                "rows": [],
                "targets": set(),
                "points": set(),
            },
        )
        group["rows"].append(row)
        group["targets"].add(row["target"])
        group["points"].add((row["x"], row["y"]))

    groups = []
    for group in grouped.values():
        states = gap_summary(group["rows"])
        points = sorted(group["points"], key=lambda point: (point[1], point[0]))
        targets = sorted(group["targets"])
        first = preferred_row(group["rows"])
        standable_count = sum(1 for row in group["rows"] if row.get("sourceStandable"))
        groups.append(
            {
                "source": group["source"],
                "recordVa": group.get("recordVa"),
                "recordVaHex": group.get("recordVaHex"),
                "sceneId": group.get("sceneId"),
                "sceneIdHex": group.get("sceneIdHex"),
                "eventKind": group.get("eventKind"),
                "rowCount": len(group["rows"]),
                "pointCount": len(points),
                "standableCount": standable_count,
                "targetCount": len(targets),
                "confirmed": states.get("confirmed", 0),
                "rejected": states.get("rejected", 0),
                "unreviewed": states.get("unreviewed", 0),
                "targets": targets,
                "points": points,
                "first": first,
            }
        )
    return sorted(
        groups,
        key=lambda group: (
            group["standableCount"] == 0,
            group["targetCount"],
            -group["standableCount"],
            -group["unreviewed"],
            -group["rowCount"],
            group["source"],
            group.get("recordVaHex") or "",
        ),
    )


def target_groups(rows: list[dict]) -> list[dict]:
    grouped: dict[tuple, dict] = {}
    for row in rows:
        key = (*record_group_key(row), row["target"])
        group = grouped.setdefault(
            key,
            {
                "source": row["source"],
                "target": row["target"],
                "recordVa": row.get("recordVa"),
                "recordVaHex": row.get("recordVaHex"),
                "sceneId": row.get("sceneId"),
                "sceneIdHex": row.get("sceneIdHex"),
                "eventKind": row.get("eventKind"),
                "targetSceneId": row.get("targetSceneId"),
                "targetSceneIdHex": row.get("targetSceneIdHex"),
                "rows": [],
                "points": set(),
            },
        )
        group["rows"].append(row)
        group["points"].add((row["x"], row["y"]))

    groups = []
    for group in grouped.values():
        states = gap_summary(group["rows"])
        points = sorted(group["points"], key=lambda point: (point[1], point[0]))
        first = preferred_row(group["rows"])
        standable_count = sum(1 for row in group["rows"] if row.get("sourceStandable"))
        groups.append(
            {
                "source": group["source"],
                "target": group["target"],
                "recordVa": group.get("recordVa"),
                "recordVaHex": group.get("recordVaHex"),
                "sceneId": group.get("sceneId"),
                "sceneIdHex": group.get("sceneIdHex"),
                "eventKind": group.get("eventKind"),
                "targetSceneId": group.get("targetSceneId"),
                "targetSceneIdHex": group.get("targetSceneIdHex"),
                "rowCount": len(group["rows"]),
                "pointCount": len(points),
                "standableCount": standable_count,
                "confirmed": states.get("confirmed", 0),
                "rejected": states.get("rejected", 0),
                "unreviewed": states.get("unreviewed", 0),
                "points": points,
                "first": first,
            }
        )
    return sorted(
        groups,
        key=lambda group: (
            group["standableCount"] == 0,
            -group["standableCount"],
            -group["unreviewed"],
            -group["rowCount"],
            group["source"],
            group.get("recordVaHex") or "",
            group["target"],
        ),
    )


def connected_components(points: set[tuple[int, int]]) -> list[list[tuple[int, int]]]:
    remaining = set(points)
    components = []
    while remaining:
        start = min(remaining, key=lambda point: (point[1], point[0]))
        remaining.remove(start)
        stack = [start]
        component = [start]
        while stack:
            x, y = stack.pop()
            for neighbor in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
                if neighbor not in remaining:
                    continue
                remaining.remove(neighbor)
                stack.append(neighbor)
                component.append(neighbor)
        components.append(sorted(component, key=lambda point: (point[1], point[0])))
    return sorted(components, key=lambda component: (component[0][1], component[0][0], len(component)))


def component_bounds(points: list[tuple[int, int]]) -> tuple[int, int, int, int]:
    xs = [point[0] for point in points]
    ys = [point[1] for point in points]
    return min(xs), min(ys), max(xs), max(ys)


def row_active_distance(row: dict) -> int:
    value = row.get("activeDistance")
    return value if isinstance(value, int) else 9999


def component_groups(rows: list[dict]) -> list[dict]:
    grouped: dict[tuple, dict] = {}
    for row in rows:
        key = (*record_group_key(row), row["target"])
        group = grouped.setdefault(
            key,
            {
                "source": row["source"],
                "target": row["target"],
                "recordVa": row.get("recordVa"),
                "recordVaHex": row.get("recordVaHex"),
                "sceneId": row.get("sceneId"),
                "sceneIdHex": row.get("sceneIdHex"),
                "eventKind": row.get("eventKind"),
                "targetSceneId": row.get("targetSceneId"),
                "targetSceneIdHex": row.get("targetSceneIdHex"),
                "rowsByPoint": {},
                "points": set(),
            },
        )
        point = (row["x"], row["y"])
        group["rowsByPoint"].setdefault(point, []).append(row)
        group["points"].add(point)

    components = []
    for group in grouped.values():
        for points in connected_components(group["points"]):
            component_rows = [row for point in points for row in group["rowsByPoint"][point]]
            states = gap_summary(component_rows)
            min_x, min_y, max_x, max_y = component_bounds(points)
            first = preferred_row(component_rows)
            min_active_distance = min(row_active_distance(row) for row in component_rows)
            standable_count = sum(1 for row in component_rows if row.get("sourceStandable"))
            unreviewed_standable_count = sum(
                1
                for row in component_rows
                if row.get("sourceStandable") and row.get("state") == "unreviewed"
            )
            review_points = sorted(
                {
                    (row["x"], row["y"])
                    for row in component_rows
                    if row.get("sourceStandable")
                },
                key=lambda point: (point[1], point[0]),
            )
            nearest_standable_distance = min(
                (row_active_distance(row) for row in component_rows if row.get("sourceStandable")),
                default=9999,
            )
            active_review_points = sorted(
                {
                    (row["x"], row["y"])
                    for row in component_rows
                    if row.get("sourceStandable")
                    and row_active_distance(row) == nearest_standable_distance
                },
                key=lambda point: (point[1], point[0]),
            )
            components.append(
                {
                    "source": group["source"],
                    "target": group["target"],
                    "recordVa": group.get("recordVa"),
                    "recordVaHex": group.get("recordVaHex"),
                    "sceneId": group.get("sceneId"),
                    "sceneIdHex": group.get("sceneIdHex"),
                    "eventKind": group.get("eventKind"),
                    "targetSceneId": group.get("targetSceneId"),
                    "targetSceneIdHex": group.get("targetSceneIdHex"),
                    "rowCount": len(component_rows),
                    "pointCount": len(points),
                    "standableCount": standable_count,
                    "unreviewedStandableCount": unreviewed_standable_count,
                    "confirmed": states.get("confirmed", 0),
                    "rejected": states.get("rejected", 0),
                    "unreviewed": states.get("unreviewed", 0),
                    "bounds": (min_x, min_y, max_x, max_y),
                    "activeDistance": None if nearest_standable_distance >= 9999 else nearest_standable_distance,
                    "points": points,
                    "reviewPoints": review_points,
                    "activeReviewPoints": active_review_points,
                    "first": first,
                }
            )
    return sorted(
        components,
        key=lambda component: (
            component["standableCount"] == 0,
            row_active_distance(component),
            -component["standableCount"],
            -component["unreviewed"],
            component["source"],
            component.get("recordVaHex") or "",
            component["target"],
            component["bounds"][1],
            component["bounds"][0],
        ),
    )


def point_sample(points: list[tuple[int, int]], limit: int = 6) -> str:
    shown = [f"{x},{y}" for x, y in points[:limit]]
    if len(points) > limit:
        shown.append(f"+{len(points) - limit} more")
    return ", ".join(shown) if shown else "-"


def target_sample(targets: list[str], limit: int = 8) -> str:
    shown = targets[:limit]
    if len(targets) > limit:
        shown.append(f"+{len(targets) - limit} more")
    return ", ".join(shown) if shown else "-"


def bounds_text(bounds: tuple[int, int, int, int]) -> str:
    min_x, min_y, max_x, max_y = bounds
    if min_x == max_x and min_y == max_y:
        return f"{min_x},{min_y}"
    return f"{min_x},{min_y}-{max_x},{max_y}"


def review_link(
    web_prefix: str,
    source: str,
    x: int,
    y: int,
    target: str | None = None,
    record: str | None = None,
) -> str:
    params = {
        "map": source,
        "startTile": f"{x},{y}",
        "focusTile": f"{x},{y}",
        "events": "1",
        "overview": "1",
    }
    if target:
        params["transitionTarget"] = target
    if record:
        params["transitionRecord"] = record
    return f"{web_prefix}/game.html?{urlencode(params)}"


def spawn_review_link(web_prefix: str, component: dict) -> str:
    first = component["first"]
    params = {
        "map": component["target"],
        "overview": "1",
        "collision": "1",
        "spawnReviewSource": first["source"],
        "spawnReviewX": str(first["x"]),
        "spawnReviewY": str(first["y"]),
        "spawnReviewTarget": component["target"],
        "spawnReviewRecord": str(component.get("recordVaHex") or component.get("recordVa") or "record"),
    }
    return f"{web_prefix}/game.html?{urlencode(params)}"


def trial_transition_link(web_prefix: str, component: dict) -> str:
    points = component.get("activeReviewPoints") or component.get("reviewPoints") or component.get("points") or []
    first = points[0] if points else (component["first"]["x"], component["first"]["y"])
    tile_x, tile_y = first
    params = {
        "map": component["source"],
        "trialTransitions": "activeNearest",
        "startTile": f"{tile_x},{tile_y}",
        "transitionTarget": component["target"],
    }
    record = component.get("recordVaHex") or component.get("recordVa")
    if record:
        params["transitionRecord"] = str(record)
    return f"{web_prefix}/game.html?{urlencode(params)}"


def group_target_links(group: dict, web_prefix: str, html_links: bool = False) -> str:
    first = group["first"]
    links = []
    for target in group["targets"]:
        href = review_link(web_prefix, first["source"], first["x"], first["y"], target, first.get("recordVaHex"))
        if html_links:
            links.append(f'<a href="{html.escape(href)}">{html.escape(target)}</a>')
        else:
            links.append(f"[{target}]({href})")
    separator = " " if html_links else "<br>"
    return separator.join(links) if links else "-"


def gap_markdown(rows: list[dict]) -> str:
    summary = gap_summary(rows)
    groups = record_groups(rows)
    targets = target_groups(rows)
    components = component_groups(rows)
    lines = [
        "# Transition Review Gaps",
        "",
        "Generated from `out/event_transitions.json` and `data/transition_reviews.json`.",
        "",
        f"Confirmed: {summary.get('confirmed', 0)}. "
        f"Rejected: {summary.get('rejected', 0)}. "
        f"Unreviewed: {summary.get('unreviewed', 0)}.",
        "",
        f"Record groups: {len(groups)}.",
        "",
        f"Target groups: {len(targets)}.",
        "",
        f"Connected components: {len(components)}.",
        "",
        "| source | rows | points | standable | targets | state | scene | kind | record | open |",
        "| --- | ---: | ---: | ---: | --- | --- | --- | ---: | --- | --- |",
    ]
    for group in groups:
        first = group["first"]
        target = group["targets"][0] if group["targetCount"] == 1 else None
        link = review_link("../web", first["source"], first["x"], first["y"], target, first.get("recordVaHex"))
        state = (
            f"{group['confirmed']} confirmed, {group['rejected']} rejected, "
            f"{group['unreviewed']} unreviewed"
        )
        lines.append(
            f"| {group['source']} | {group['rowCount']} | {group['pointCount']} | {group['standableCount']} | "
            f"{group_target_links(group, '../web')} | {state} | {group.get('sceneIdHex') or '-'} | "
            f"{group.get('eventKind') if group.get('eventKind') is not None else '-'} | "
            f"`{group.get('recordVaHex') or '-'}` | [open]({link}) |"
        )
    if not groups:
        lines.append("| - | - | - | - | - | - | - | - | - |")
    lines.extend(
        [
        "",
        "| source | rows | points | standable | target | state | scene | kind | record | open |",
        "| --- | ---: | ---: | ---: | --- | --- | --- | ---: | --- | --- |",
        ]
    )
    for group in targets:
        first = group["first"]
        link = review_link("../web", first["source"], first["x"], first["y"], group["target"], first.get("recordVaHex"))
        state = (
            f"{group['confirmed']} confirmed, {group['rejected']} rejected, "
            f"{group['unreviewed']} unreviewed"
        )
        lines.append(
            f"| {group['source']} | {group['rowCount']} | {group['pointCount']} | {group['standableCount']} | "
            f"[{group['target']}]({link}) {group.get('targetSceneIdHex') or ''} | {state} | {group.get('sceneIdHex') or '-'} | "
            f"{group.get('eventKind') if group.get('eventKind') is not None else '-'} | "
            f"`{group.get('recordVaHex') or '-'}` | [open]({link}) |"
        )
    if not targets:
        lines.append("| - | - | - | - | - | - | - | - | - |")
    lines.extend(
        [
        "",
        "| source | rows | points | standable | bounds | target | state | scene | kind | record | open |",
        "| --- | ---: | ---: | ---: | --- | --- | --- | --- | ---: | --- | --- |",
        ]
    )
    for component in components:
        first = component["first"]
        link = review_link(
            "../web",
            first["source"],
            first["x"],
            first["y"],
            component["target"],
            first.get("recordVaHex"),
        )
        state = (
            f"{component['confirmed']} confirmed, {component['rejected']} rejected, "
            f"{component['unreviewed']} unreviewed"
        )
        lines.append(
            f"| {component['source']} | {component['rowCount']} | {component['pointCount']} | {component['standableCount']} | "
            f"{bounds_text(component['bounds'])} | [{component['target']}]({link}) {component.get('targetSceneIdHex') or ''} | {state} | "
            f"{component.get('sceneIdHex') or '-'} | "
            f"{component.get('eventKind') if component.get('eventKind') is not None else '-'} | "
            f"`{component.get('recordVaHex') or '-'}` | [open]({link}) |"
        )
    if not components:
        lines.append("| - | - | - | - | - | - | - | - | - | - |")
    lines.extend(
        [
        "",
        "| source | point | target | state | scene | kind | record | open |",
        "| --- | --- | --- | --- | --- | ---: | --- | --- |",
        ]
    )
    for row in rows:
        source = row["source"]
        x = row["x"]
        y = row["y"]
        link = review_link("../web", source, x, y, row["target"], row.get("recordVaHex"))
        lines.append(
            f"| {source} | {x},{y} | {row['target']} | {row['state']} | "
            f"{row.get('sceneIdHex') or '-'} | {row.get('eventKind') if row.get('eventKind') is not None else '-'} | "
            f"`{row.get('recordVaHex') or '-'}` | [open]({link}) |"
        )
    if not rows:
        lines.append("| - | - | - | - | - | - | - | - |")
    lines.append("")
    return "\n".join(lines)


def rows_by_source(rows: list[dict]) -> dict[str, list[dict]]:
    grouped: dict[str, list[dict]] = {}
    for row in rows:
        grouped.setdefault(row["source"], []).append(row)
    return {source: grouped[source] for source in sorted(grouped)}


def render_source_overlay(source: str, rows: list[dict], maps: dict, out_dir: Path) -> Path | None:
    info = maps.get(source)
    if not info:
        return None
    width, height, rgb = render_thumbnail(info, 480, 320, "layer1UnderSceneTilesets")
    canvas = bytearray(rgb)
    tile_size = info["tileSize"]
    scale_x = width / (info["width"] * tile_size)
    scale_y = height / (info["height"] * tile_size)
    points = sorted({(row["x"], row["y"]) for row in rows}, key=lambda point: (point[1], point[0]))

    def set_pixel(x: int, y: int, color: tuple[int, int, int]) -> None:
        if x < 0 or y < 0 or x >= width or y >= height:
            return
        offset = (y * width + x) * 3
        canvas[offset : offset + 3] = bytes(color)

    for tile_x, tile_y in points:
        x0 = int(tile_x * tile_size * scale_x)
        y0 = int(tile_y * tile_size * scale_y)
        x1 = max(x0 + 2, int((tile_x + 1) * tile_size * scale_x))
        y1 = max(y0 + 2, int((tile_y + 1) * tile_size * scale_y))
        for y in range(y0, y1):
            for x in range(x0, x1):
                border = x in {x0, x1 - 1} or y in {y0, y1 - 1}
                if border or (x + y) % 3 == 0:
                    set_pixel(x, y, (255, 92, 200) if border else (255, 212, 71))

    for component in component_groups(rows):
        min_x, min_y, max_x, max_y = component["bounds"]
        x0 = int(min_x * tile_size * scale_x)
        y0 = int(min_y * tile_size * scale_y)
        x1 = max(x0 + 3, int((max_x + 1) * tile_size * scale_x))
        y1 = max(y0 + 3, int((max_y + 1) * tile_size * scale_y))
        for x in range(x0, x1):
            set_pixel(x, y0, (72, 245, 255))
            set_pixel(x, y1 - 1, (72, 245, 255))
        for y in range(y0, y1):
            set_pixel(x0, y, (72, 245, 255))
            set_pixel(x1 - 1, y, (72, 245, 255))

    path = out_dir / "transition_review_gaps" / f"{source}.png"
    path.parent.mkdir(parents=True, exist_ok=True)
    write_png(path, width, height, bytes(canvas))
    return path


def component_preview_name(component: dict) -> str:
    min_x, min_y, max_x, max_y = component["bounds"]
    record = str(component.get("recordVaHex") or component.get("recordVa") or "record").replace("0x", "")
    return (
        f"component_{component['source']}_{component['target']}_{record}_"
        f"{min_x}_{min_y}_{max_x}_{max_y}.png"
    )


def component_review_patch(component: dict, state: str, nearest: bool = False) -> dict[str, dict]:
    patch = {}
    if nearest:
        points = component.get("activeReviewPoints", [])
    else:
        points = component["reviewPoints"] if state == "confirmed" else component["points"]
    for x, y in points:
        key = review_key(
            component["source"],
            x,
            y,
            component["target"],
            record_key(component),
        )
        review = {
            "source": component["source"],
            "x": x,
            "y": y,
            "target": component["target"],
            "state": state,
        }
        for field in ["recordVa", "recordVaHex", "sceneId", "sceneIdHex", "eventKind", "targetSceneId", "targetSceneIdHex"]:
            if component.get(field) is not None:
                review[field] = component[field]
        patch[key] = review
    return dict(sorted(patch.items()))


def patch_file_stem(component: dict, state: str, nearest: bool = False) -> str:
    min_x, min_y, max_x, max_y = component["bounds"]
    record = str(component.get("recordVaHex") or component.get("recordVa") or "record").replace("0x", "")
    suffix = f"{state}_nearest" if nearest else state
    return (
        f"{component['source']}_{component['target']}_{record}_"
        f"{min_x}_{min_y}_{max_x}_{max_y}_{suffix}"
    )


def write_review_patch_outputs(rows: list[dict], out_dir: Path) -> list[dict]:
    patch_dir = out_dir / "transition_review_patches"
    if patch_dir.exists():
        shutil.rmtree(patch_dir)
    patch_dir.mkdir(parents=True, exist_ok=True)

    index = []
    for component in component_groups(rows):
        for state, nearest in (("confirmed", True), ("confirmed", False), ("rejected", False)):
            if state == "confirmed" and not component.get("reviewPoints"):
                continue
            if nearest and not component.get("activeReviewPoints"):
                continue
            patch = component_review_patch(component, state, nearest)
            if not patch:
                continue
            filename = f"{patch_file_stem(component, state, nearest)}.json"
            (patch_dir / filename).write_text(
                json.dumps(patch, ensure_ascii=False, indent=2) + "\n",
                encoding="utf-8",
            )
            index.append({
                "path": f"transition_review_patches/{filename}",
                "source": component["source"],
                "target": component["target"],
                "state": state,
                "scope": "active-nearest" if nearest else "component",
                "recordVaHex": component.get("recordVaHex"),
                "sceneIdHex": component.get("sceneIdHex"),
                "eventKind": component.get("eventKind"),
                "bounds": {
                    "minX": component["bounds"][0],
                    "minY": component["bounds"][1],
                    "maxX": component["bounds"][2],
                    "maxY": component["bounds"][3],
                },
                "records": len(patch),
                "standableCount": component.get("standableCount", 0),
                "activeDistance": component.get("activeDistance"),
                "unreviewedStandableCount": component.get("unreviewedStandableCount", 0),
            })

    index.sort(key=lambda row: (
        row["source"],
        row["target"],
        row.get("recordVaHex") or "",
        row["bounds"]["minY"],
        row["bounds"]["minX"],
        row["state"],
    ))
    (out_dir / "transition_review_patches.json").write_text(
        json.dumps(index, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    lines = [
        "# Transition Review Patches",
        "",
        "Generated component-level JSON patches for `tools/transition_reviews.py --merge ... --dry-run`.",
        "",
        "| source | target | state | scope | bounds | records | standable | active dist | patch |",
        "| --- | --- | --- | --- | --- | ---: | ---: | ---: | --- |",
    ]
    for row in index:
        bounds = row["bounds"]
        bounds_label = bounds_text((bounds["minX"], bounds["minY"], bounds["maxX"], bounds["maxY"]))
        lines.append(
            f"| {row['source']} | {row['target']} | {row['state']} | {row.get('scope', '-')} | {bounds_label} | "
            f"{row['records']} | {row['standableCount']} | {row.get('activeDistance') if row.get('activeDistance') is not None else '-'} | "
            f"[{Path(row['path']).name}]({row['path']}) |"
        )
    return index


def component_review_button(component: dict, state: str, with_spawn: bool = False, nearest: bool = False) -> str:
    point_key = "activeReviewPoints" if nearest else "reviewPoints"
    if state == "confirmed" and not component.get(point_key):
        label = "no standable confirm"
        return f'<button type="button" disabled>{html.escape(label)}</button>'
    patch = html.escape(json.dumps(component_review_patch(component, state, nearest), ensure_ascii=False), quote=True)
    if with_spawn:
        label = "add confirm+spawn JSON"
        done = "added confirm+spawn JSON"
    elif nearest:
        label = "add confirm nearest JSON"
        done = "added confirm nearest JSON"
    else:
        label = "add confirm JSON" if state == "confirmed" else "add reject JSON"
        done = "added confirm JSON" if state == "confirmed" else "added reject JSON"
    spawn_attr = ' data-spawn="1"' if with_spawn else ""
    return (
        f'<button type="button" class="review-copy" data-done-label="{html.escape(done)}" '
        f'data-review-patch="{patch}"{spawn_attr}>{html.escape(label)}</button>'
    )


def render_component_previews(source: str, rows: list[dict], maps: dict, out_dir: Path) -> None:
    info = maps.get(source)
    if not info:
        return
    width, height, rgb = render_thumbnail(info, 960, 640, "layer1UnderSceneTilesets")
    tile_size = info["tileSize"]
    scale_x = width / (info["width"] * tile_size)
    scale_y = height / (info["height"] * tile_size)
    preview_dir = out_dir / "transition_review_gaps"
    preview_dir.mkdir(parents=True, exist_ok=True)

    def crop_bounds(bounds: tuple[int, int, int, int]) -> tuple[int, int, int, int]:
        min_x, min_y, max_x, max_y = bounds
        x0 = int(min_x * tile_size * scale_x)
        y0 = int(min_y * tile_size * scale_y)
        x1 = max(x0 + 3, int((max_x + 1) * tile_size * scale_x))
        y1 = max(y0 + 3, int((max_y + 1) * tile_size * scale_y))
        margin = 42
        cx0 = max(0, x0 - margin)
        cy0 = max(0, y0 - margin)
        cx1 = min(width, x1 + margin)
        cy1 = min(height, y1 + margin)
        if cx1 - cx0 < 120:
            extra = (120 - (cx1 - cx0) + 1) // 2
            cx0 = max(0, cx0 - extra)
            cx1 = min(width, cx1 + extra)
        if cy1 - cy0 < 120:
            extra = (120 - (cy1 - cy0) + 1) // 2
            cy0 = max(0, cy0 - extra)
            cy1 = min(height, cy1 + extra)
        return cx0, cy0, cx1, cy1

    for component in component_groups(rows):
        cx0, cy0, cx1, cy1 = crop_bounds(component["bounds"])
        crop_width = max(1, cx1 - cx0)
        crop_height = max(1, cy1 - cy0)
        crop = bytearray()
        for y in range(cy0, cy1):
            start = (y * width + cx0) * 3
            end = start + crop_width * 3
            crop.extend(rgb[start:end])

        def set_pixel(x: int, y: int, color: tuple[int, int, int]) -> None:
            if x < 0 or y < 0 or x >= crop_width or y >= crop_height:
                return
            offset = (y * crop_width + x) * 3
            crop[offset : offset + 3] = bytes(color)

        for tile_x, tile_y in component["points"]:
            x0 = int(tile_x * tile_size * scale_x) - cx0
            y0 = int(tile_y * tile_size * scale_y) - cy0
            x1 = max(x0 + 2, int((tile_x + 1) * tile_size * scale_x) - cx0)
            y1 = max(y0 + 2, int((tile_y + 1) * tile_size * scale_y) - cy0)
            for y in range(y0, y1):
                for x in range(x0, x1):
                    border = x in {x0, x1 - 1} or y in {y0, y1 - 1}
                    if border or (x + y) % 3 == 0:
                        set_pixel(x, y, (72, 245, 255) if border else (255, 212, 71))

        min_x, min_y, max_x, max_y = component["bounds"]
        bx0 = int(min_x * tile_size * scale_x) - cx0
        by0 = int(min_y * tile_size * scale_y) - cy0
        bx1 = max(bx0 + 3, int((max_x + 1) * tile_size * scale_x) - cx0)
        by1 = max(by0 + 3, int((max_y + 1) * tile_size * scale_y) - cy0)
        for x in range(bx0, bx1):
            set_pixel(x, by0, (255, 92, 200))
            set_pixel(x, by1 - 1, (255, 92, 200))
        for y in range(by0, by1):
            set_pixel(bx0, y, (255, 92, 200))
            set_pixel(bx1 - 1, y, (255, 92, 200))

        write_png(preview_dir / component_preview_name(component), crop_width, crop_height, bytes(crop))


def render_target_previews(rows: list[dict], maps: dict, out_dir: Path) -> None:
    target_dir = out_dir / "transition_review_gaps"
    target_dir.mkdir(parents=True, exist_ok=True)
    for target in sorted({row["target"] for row in rows}):
        info = maps.get(target)
        if not info:
            continue
        width, height, rgb = render_thumbnail(info, 240, 160, "layer1UnderSceneTilesets")
        write_png(target_dir / f"target_{target}.png", width, height, rgb)


def target_preview_cards(rows: list[dict], maps: dict, web_prefix: str) -> str:
    cards = []
    for target in sorted({row["target"] for row in rows}):
        if target not in maps:
            continue
        href = f"{web_prefix}/game.html?{urlencode({'map': target, 'overview': '1', 'collision': '1'})}"
        cards.append(
            "\n".join(
                [
                    '<a class="target-card" href="' + html.escape(href) + '">',
                    f'  <img class="target-preview" src="target_{html.escape(target)}.png" alt="{html.escape(target)} preview">',
                    f"  <span>{html.escape(target)}</span>",
                    "</a>",
                ]
            )
        )
    if not cards:
        return '<p class="muted">No target map previews available.</p>'
    return "\n".join(cards)


def source_summary(rows: list[dict]) -> list[dict]:
    summary = []
    for source, source_rows in rows_by_source(rows).items():
        states = gap_summary(source_rows)
        summary.append({
            "source": source,
            "rows": len(source_rows),
            "confirmed": states.get("confirmed", 0),
            "rejected": states.get("rejected", 0),
            "unreviewed": states.get("unreviewed", 0),
        })
    return sorted(summary, key=lambda row: (-row["unreviewed"], row["source"]))


def priority_review_components(groups: list[dict], components: list[dict]) -> list[dict]:
    target_counts = {
        (
            group["source"],
            group.get("recordVaHex") or group.get("recordVa") or "",
            group.get("sceneIdHex") or group.get("sceneId") or "",
            group.get("eventKind") if group.get("eventKind") is not None else "",
        ): group["targetCount"]
        for group in groups
    }
    rows = []
    for component in components:
        key = (
            component["source"],
            component.get("recordVaHex") or component.get("recordVa") or "",
            component.get("sceneIdHex") or component.get("sceneId") or "",
            component.get("eventKind") if component.get("eventKind") is not None else "",
        )
        if component.get("unreviewedStandableCount", 0) <= 0:
            continue
        item = dict(component)
        item["targetCount"] = target_counts.get(key, 99)
        rows.append(item)
    return sorted(
        rows,
        key=lambda component: (
            row_active_distance(component),
            component["targetCount"],
            -component["unreviewedStandableCount"],
            -component["unreviewed"],
            component["source"],
            component.get("recordVaHex") or "",
            component["target"],
            component["bounds"][1],
            component["bounds"][0],
        ),
    )


def gap_html(rows: list[dict], web_prefix: str = "../web") -> str:
    summary = gap_summary(rows)
    source_rows = source_summary(rows)
    groups = record_groups(rows)
    targets = target_groups(rows)
    components = component_groups(rows)
    priority_components = priority_review_components(groups, components)
    group_cards = []
    for group in groups:
        first = group["first"]
        target = group["targets"][0] if group["targetCount"] == 1 else None
        open_href = review_link(web_prefix, first["source"], first["x"], first["y"], target, first.get("recordVaHex"))
        source_href = f"transition_review_gaps/{group['source']}.html"
        group_cards.append(
            "\n".join(
                [
                    "<tr>",
                    f'  <td><a href="{html.escape(source_href)}">{html.escape(group["source"])}</a></td>',
                    f"  <td>{group['rowCount']}</td>",
                    f"  <td>{group['pointCount']}</td>",
                    f"  <td>{group['standableCount']}</td>",
                    f'  <td class="target-links">{group_target_links(group, web_prefix, True)}</td>',
                    f"  <td>{group['confirmed']}</td>",
                    f"  <td>{group['rejected']}</td>",
                    f"  <td>{group['unreviewed']}</td>",
                    f"  <td>{html.escape(group.get('sceneIdHex') or '-')}</td>",
                    f"  <td>{group.get('eventKind') if group.get('eventKind') is not None else '-'}</td>",
                    f"  <td><code>{html.escape(group.get('recordVaHex') or '-')}</code></td>",
                    f'  <td><a href="{html.escape(open_href)}">open</a></td>',
                    "</tr>",
                ]
            )
        )
    source_cards = []
    for row in source_rows:
        href = f"transition_review_gaps/{row['source']}.html"
        source_cards.append(
            "\n".join(
                [
                    '<tr>',
                    f'  <td><a href="{html.escape(href)}">{html.escape(row["source"])}</a></td>',
                    f"  <td>{row['rows']}</td>",
                    f"  <td>{row['confirmed']}</td>",
                    f"  <td>{row['rejected']}</td>",
                    f"  <td>{row['unreviewed']}</td>",
                    "</tr>",
                ]
            )
        )
    target_cards = []
    for group in targets:
        first = group["first"]
        open_href = review_link(web_prefix, first["source"], first["x"], first["y"], group["target"], first.get("recordVaHex"))
        target_cards.append(
            "\n".join(
                [
                    "<tr>",
                    f'  <td><a href="transition_review_gaps/{html.escape(group["source"])}.html">{html.escape(group["source"])}</a></td>',
                    f"  <td>{group['rowCount']}</td>",
                    f"  <td>{group['pointCount']}</td>",
                    f"  <td>{group['standableCount']}</td>",
                    f'  <td><a href="{html.escape(open_href)}">{html.escape(group["target"])}</a></td>',
                    f"  <td>{html.escape(group.get('targetSceneIdHex') or '-')}</td>",
                    f"  <td>{group['confirmed']}</td>",
                    f"  <td>{group['rejected']}</td>",
                    f"  <td>{group['unreviewed']}</td>",
                    f"  <td>{html.escape(group.get('sceneIdHex') or '-')}</td>",
                    f"  <td>{group.get('eventKind') if group.get('eventKind') is not None else '-'}</td>",
                    f"  <td><code>{html.escape(group.get('recordVaHex') or '-')}</code></td>",
                    f'  <td><a href="{html.escape(open_href)}">open</a></td>',
                    "</tr>",
                ]
            )
        )
    component_cards = []
    for component in components:
        first = component["first"]
        open_href = review_link(
            web_prefix,
            first["source"],
            first["x"],
            first["y"],
            component["target"],
            first.get("recordVaHex"),
        )
        spawn_href = spawn_review_link(web_prefix, component)
        trial_href = trial_transition_link(web_prefix, component)
        component_cards.append(
            "\n".join(
                [
                    "<tr>",
                    f'  <td><a href="transition_review_gaps/{html.escape(component["source"])}.html">{html.escape(component["source"])}</a></td>',
                    f"  <td>{component['rowCount']}</td>",
                    f"  <td>{component['pointCount']}</td>",
                    f"  <td>{component['standableCount']}</td>",
                    f"  <td>{component.get('unreviewedStandableCount', 0)}</td>",
                    f"  <td>{component.get('activeDistance') if component.get('activeDistance') is not None else '-'}</td>",
                    f"  <td>{html.escape(bounds_text(component['bounds']))}</td>",
                    f'  <td><a href="{html.escape(open_href)}">{html.escape(component["target"])}</a></td>',
                    f"  <td>{html.escape(component.get('targetSceneIdHex') or '-')}</td>",
                    f"  <td>{component['confirmed']}</td>",
                    f"  <td>{component['rejected']}</td>",
                    f"  <td>{component['unreviewed']}</td>",
                    f"  <td>{html.escape(component.get('sceneIdHex') or '-')}</td>",
                    f"  <td>{component.get('eventKind') if component.get('eventKind') is not None else '-'}</td>",
                    f"  <td><code>{html.escape(component.get('recordVaHex') or '-')}</code></td>",
                    f'  <td><a href="{html.escape(open_href)}">open</a><br><a href="{html.escape(trial_href)}">trial active-nearest</a><br><a href="{html.escape(spawn_href)}">choose spawn</a></td>',
                    "</tr>",
                ]
            )
        )
    priority_cards = []
    for component in priority_components[:40]:
        first = component["first"]
        open_href = review_link(
            web_prefix,
            first["source"],
            first["x"],
            first["y"],
            component["target"],
            first.get("recordVaHex"),
        )
        spawn_href = spawn_review_link(web_prefix, component)
        trial_href = trial_transition_link(web_prefix, component)
        source_href = f"transition_review_gaps/{component['source']}.html"
        priority_cards.append(
            "\n".join(
                [
                    "<tr>",
                    f'  <td><img class="component-preview" src="transition_review_gaps/{html.escape(component_preview_name(component))}" alt="{html.escape(component["source"])} {html.escape(bounds_text(component["bounds"]))} preview"></td>',
                    f'  <td><a href="{html.escape(source_href)}">{html.escape(component["source"])}</a></td>',
                    f"  <td>{component['rowCount']}</td>",
                    f"  <td>{component['pointCount']}</td>",
                    f"  <td>{component.get('targetCount', '-')}</td>",
                    f"  <td>{component['standableCount']}</td>",
                    f"  <td>{component.get('unreviewedStandableCount', 0)}</td>",
                    f"  <td>{component.get('activeDistance') if component.get('activeDistance') is not None else '-'}</td>",
                    f"  <td>{html.escape(bounds_text(component['bounds']))}</td>",
                    f'  <td><a href="{html.escape(open_href)}">{html.escape(component["target"])}</a></td>',
                    f"  <td>{html.escape(component.get('targetSceneIdHex') or '-')}</td>",
                    f"  <td>{component['unreviewed']}</td>",
                    f"  <td>{html.escape(component.get('sceneIdHex') or '-')}</td>",
                    f"  <td><code>{html.escape(component.get('recordVaHex') or '-')}</code></td>",
                    f'  <td><a href="{html.escape(open_href)}">open</a><br><a href="{html.escape(trial_href)}">trial active-nearest</a><br><a href="{html.escape(spawn_href)}">choose spawn</a></td>',
                    f'  <td class="review-actions">{component_review_button(component, "confirmed", nearest=True)} {component_review_button(component, "confirmed")} {component_review_button(component, "confirmed", True)} {component_review_button(component, "rejected")}</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>Transition Review Gaps</title>",
            "  <style>",
            "    :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #111; color: #eee; }",
            "    body { margin: 0; padding: 24px; }",
            "    h1 { margin: 0 0 8px; font-size: 24px; }",
            "    p { margin: 0 0 16px; color: #bbb; max-width: 980px; line-height: 1.45; }",
            "    .summary { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0 18px; }",
            "    .metric { border: 1px solid #333; background: #181818; border-radius: 6px; padding: 9px 11px; }",
            "    .metric strong { display: block; font-size: 18px; }",
            "    .metric span { color: #aaa; font-size: 12px; }",
            "    h2 { margin: 26px 0 10px; font-size: 18px; }",
            "    table { width: 100%; border-collapse: collapse; font-size: 13px; }",
            "    th, td { border-bottom: 1px solid #303030; padding: 7px 8px; text-align: left; }",
            "    th { background: #181818; color: #ddd; }",
            "    td:nth-child(n+2) { white-space: nowrap; }",
            "    .component-preview { image-rendering: pixelated; width: 128px; height: 96px; object-fit: contain; background: #080808; border: 1px solid #333; }",
            "    .review-actions { white-space: normal; min-width: 160px; }",
            "    .review-copy { display: block; width: 100%; margin: 0 0 5px; padding: 5px 7px; border: 1px solid #3a3a3a; border-radius: 5px; background: #202020; color: #ddd; cursor: pointer; }",
            "    .review-copy:hover { background: #2a2a2a; }",
            "    .basket-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 8px; }",
            "    .basket-actions button { padding: 6px 9px; border: 1px solid #3a3a3a; border-radius: 5px; background: #202020; color: #ddd; cursor: pointer; }",
            "    #reviewPatchBox { width: min(100%, 960px); min-height: 160px; margin: 0 0 18px; background: #080808; color: #eee; border: 1px solid #333; border-radius: 6px; padding: 10px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }",
            "    a { color: #7cc7ff; text-decoration: none; }",
            "    a:hover { text-decoration: underline; }",
            "  </style>",
            "</head>",
            "<body>",
            "  <h1>Transition Review Gaps</h1>",
            "  <p>Candidate field-map transitions grouped by source map. Open a source page to inspect coordinates and mark reviews in the web runtime.</p>",
            '  <section class="summary">',
            f'    <div class="metric"><strong>{summary.get("confirmed", 0)}</strong><span>confirmed</span></div>',
            f'    <div class="metric"><strong>{summary.get("rejected", 0)}</strong><span>rejected</span></div>',
            f'    <div class="metric"><strong>{summary.get("unreviewed", 0)}</strong><span>unreviewed</span></div>',
            f'    <div class="metric"><strong>{len(groups)}</strong><span>record groups</span></div>',
            f'    <div class="metric"><strong>{len(targets)}</strong><span>target groups</span></div>',
            f'    <div class="metric"><strong>{len(components)}</strong><span>components</span></div>',
            f'    <div class="metric"><strong>{len(priority_components)}</strong><span>priority components</span></div>',
            f'    <div class="metric"><strong>{len(source_rows)}</strong><span>source maps</span></div>',
            "  </section>",
            '  <div class="basket-actions">',
            '    <button id="copyBasket" type="button" hidden>copy review basket</button>',
            '    <button id="clearBasket" type="button" hidden>clear basket</button>',
            "  </div>",
            '  <textarea id="reviewPatchBox" hidden readonly aria-label="transition review JSON patch"></textarea>',
            "  <h2>Priority Queue</h2>",
            "  <p>These connected components still have unreviewed standable source tiles. Components nearest the scene active point are listed first, then records with fewer destination maps.</p>",
            "  <table>",
            "    <thead><tr><th>preview</th><th>source</th><th>rows</th><th>points</th><th>targets</th><th>standable</th><th>unreviewed standable</th><th>active dist</th><th>bounds</th><th>target</th><th>target scene</th><th>unreviewed</th><th>scene</th><th>record</th><th>open</th><th>review JSON</th></tr></thead>",
            "    <tbody>",
            "\n".join(priority_cards) if priority_cards else '<tr><td colspan="16">No unreviewed standable components.</td></tr>',
            "    </tbody>",
            "  </table>",
            "  <h2>Record Groups</h2>",
            "  <p>Start here: these rows collapse repeated coordinate and target permutations into source event records.</p>",
            "  <table>",
            "    <thead><tr><th>source</th><th>rows</th><th>points</th><th>standable</th><th>targets</th><th>confirmed</th><th>rejected</th><th>unreviewed</th><th>scene</th><th>kind</th><th>record</th><th>open</th></tr></thead>",
            "    <tbody>",
            "\n".join(group_cards),
            "    </tbody>",
            "  </table>",
            "  <h2>Target Groups</h2>",
            "  <p>Use this table when one source event record points at multiple destination maps.</p>",
            "  <table>",
            "    <thead><tr><th>source</th><th>rows</th><th>points</th><th>standable</th><th>target</th><th>target scene</th><th>confirmed</th><th>rejected</th><th>unreviewed</th><th>scene</th><th>kind</th><th>record</th><th>open</th></tr></thead>",
            "    <tbody>",
            "\n".join(target_cards),
            "    </tbody>",
            "  </table>",
            "  <h2>Connected Components</h2>",
            "  <p>Use this table to review each contiguous transition area, such as a doorway or edge strip.</p>",
            "  <table>",
            "    <thead><tr><th>source</th><th>rows</th><th>points</th><th>standable</th><th>unreviewed standable</th><th>active dist</th><th>bounds</th><th>target</th><th>target scene</th><th>confirmed</th><th>rejected</th><th>unreviewed</th><th>scene</th><th>kind</th><th>record</th><th>open</th></tr></thead>",
            "    <tbody>",
            "\n".join(component_cards),
            "    </tbody>",
            "  </table>",
            "  <h2>Source Maps</h2>",
            "  <table>",
            "    <thead><tr><th>source</th><th>rows</th><th>confirmed</th><th>rejected</th><th>unreviewed</th></tr></thead>",
            "    <tbody>",
            "\n".join(source_cards),
            "    </tbody>",
            "  </table>",
            "  <script>",
            "    (() => {",
            "      const box = document.getElementById('reviewPatchBox');",
            "      const copyBasket = document.getElementById('copyBasket');",
            "      const clearBasket = document.getElementById('clearBasket');",
            "      const basket = {};",
            "      const updateBasket = async (copy = false) => {",
            "        const count = Object.keys(basket).length;",
            "        box.value = JSON.stringify(basket, null, 2);",
            "        box.hidden = count === 0;",
            "        copyBasket.hidden = count === 0;",
            "        clearBasket.hidden = count === 0;",
            "        copyBasket.textContent = `copy review basket (${count})`;",
            "        if (count) { box.focus(); box.select(); }",
            "        if (copy && count) { try { await navigator.clipboard.writeText(box.value); } catch (error) {} }",
            "      };",
            "      for (const button of document.querySelectorAll('.review-copy')) {",
            "        const label = button.textContent;",
            "        button.addEventListener('click', async () => {",
            "          const patch = JSON.parse(button.dataset.reviewPatch || '{}');",
            "          if (button.dataset.spawn === '1') {",
            "            const text = prompt('target spawn tile as x,y');",
            "            if (!text) return;",
            "            const parts = text.split(',').map((part) => Number(part.trim()));",
            "            if (parts.length !== 2 || !parts.every(Number.isInteger)) { alert('spawn tile must be x,y'); return; }",
            "            for (const review of Object.values(patch)) { review.spawnX = parts[0]; review.spawnY = parts[1]; }",
            "          }",
            "          Object.assign(basket, patch);",
            "          await updateBasket(true);",
            "          button.textContent = button.dataset.doneLabel || 'added';",
            "          setTimeout(() => { button.textContent = label; }, 1200);",
            "        });",
            "      }",
            "      copyBasket.addEventListener('click', () => updateBasket(true));",
            "      clearBasket.addEventListener('click', () => {",
            "        for (const key of Object.keys(basket)) delete basket[key];",
            "        updateBasket(false);",
            "      });",
            "    })();",
            "  </script>",
            "</body>",
            "</html>",
            "",
        ]
    )


def source_gap_html(source: str, rows: list[dict], maps: dict, web_prefix: str = "../../web") -> str:
    summary = gap_summary(rows)
    groups = record_groups(rows)
    targets = target_groups(rows)
    components = component_groups(rows)
    group_body = []
    for group in groups:
        first = group["first"]
        target = group["targets"][0] if group["targetCount"] == 1 else None
        link = review_link(web_prefix, first["source"], first["x"], first["y"], target, first.get("recordVaHex"))
        group_body.append(
            "\n".join(
                [
                    "<tr>",
                    f"  <td>{group['rowCount']}</td>",
                    f"  <td>{group['pointCount']}</td>",
                    f"  <td>{group['standableCount']}</td>",
                    f"  <td>{html.escape(point_sample(group['points']))}</td>",
                    f'  <td class="target-links">{group_target_links(group, web_prefix, True)}</td>',
                    f"  <td>{group['confirmed']}</td>",
                    f"  <td>{group['rejected']}</td>",
                    f"  <td>{group['unreviewed']}</td>",
                    f"  <td>{html.escape(group.get('sceneIdHex') or '-')}</td>",
                    f"  <td>{group.get('eventKind') if group.get('eventKind') is not None else '-'}</td>",
                    f"  <td><code>{html.escape(group.get('recordVaHex') or '-')}</code></td>",
                    f'  <td><a href="{html.escape(link)}">open</a></td>',
                    "</tr>",
                ]
            )
        )
    target_body = []
    for group in targets:
        first = group["first"]
        link = review_link(web_prefix, first["source"], first["x"], first["y"], group["target"], first.get("recordVaHex"))
        target_body.append(
            "\n".join(
                [
                    "<tr>",
                    f"  <td>{group['rowCount']}</td>",
                    f"  <td>{group['pointCount']}</td>",
                    f"  <td>{group['standableCount']}</td>",
                    f"  <td>{html.escape(point_sample(group['points']))}</td>",
                    f'  <td><a href="{html.escape(link)}">{html.escape(group["target"])}</a></td>',
                    f"  <td>{html.escape(group.get('targetSceneIdHex') or '-')}</td>",
                    f"  <td>{group['confirmed']}</td>",
                    f"  <td>{group['rejected']}</td>",
                    f"  <td>{group['unreviewed']}</td>",
                    f"  <td>{html.escape(group.get('sceneIdHex') or '-')}</td>",
                    f"  <td>{group.get('eventKind') if group.get('eventKind') is not None else '-'}</td>",
                    f"  <td><code>{html.escape(group.get('recordVaHex') or '-')}</code></td>",
                    f'  <td><a href="{html.escape(link)}">open</a></td>',
                    "</tr>",
                ]
            )
        )
    component_body = []
    for component in components:
        first = component["first"]
        link = review_link(
            web_prefix,
            first["source"],
            first["x"],
            first["y"],
            component["target"],
            first.get("recordVaHex"),
        )
        spawn_href = spawn_review_link(web_prefix, component)
        trial_href = trial_transition_link(web_prefix, component)
        component_body.append(
            "\n".join(
                [
                    "<tr>",
                    f'  <td><img class="component-preview" src="{html.escape(component_preview_name(component))}" alt="{html.escape(source)} {html.escape(bounds_text(component["bounds"]))} preview"></td>',
                    f"  <td>{component['rowCount']}</td>",
                    f"  <td>{component['pointCount']}</td>",
                    f"  <td>{component['standableCount']}</td>",
                    f"  <td>{component.get('unreviewedStandableCount', 0)}</td>",
                    f"  <td>{component.get('activeDistance') if component.get('activeDistance') is not None else '-'}</td>",
                    f"  <td>{html.escape(bounds_text(component['bounds']))}</td>",
                    f"  <td>{html.escape(point_sample(component['points']))}</td>",
                    f'  <td><a href="{html.escape(link)}">{html.escape(component["target"])}</a></td>',
                    f"  <td>{html.escape(component.get('targetSceneIdHex') or '-')}</td>",
                    f"  <td>{component['confirmed']}</td>",
                    f"  <td>{component['rejected']}</td>",
                    f"  <td>{component['unreviewed']}</td>",
                    f"  <td>{html.escape(component.get('sceneIdHex') or '-')}</td>",
                    f"  <td>{component.get('eventKind') if component.get('eventKind') is not None else '-'}</td>",
                    f"  <td><code>{html.escape(component.get('recordVaHex') or '-')}</code></td>",
                    f'  <td><a href="{html.escape(link)}">open</a><br><a href="{html.escape(trial_href)}">trial active-nearest</a><br><a href="{html.escape(spawn_href)}">choose spawn</a></td>',
                    f'  <td class="review-actions">{component_review_button(component, "confirmed", nearest=True)} {component_review_button(component, "confirmed")} {component_review_button(component, "confirmed", True)} {component_review_button(component, "rejected")}</td>',
                    "</tr>",
                ]
            )
        )
    body = []
    for row in rows:
        link = review_link(web_prefix, row["source"], row["x"], row["y"], row["target"], row.get("recordVaHex"))
        body.append(
            "\n".join(
                [
                    "<tr>",
                    f"  <td>{row['x']},{row['y']}</td>",
                    f"  <td>{html.escape(row['target'])}</td>",
                    f'  <td class="{html.escape(row["state"])}">{html.escape(row["state"])}</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(link)}">open</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">',
            f"  <title>{html.escape(source)} Transition Review Gaps</title>",
            "  <style>",
            "    :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #111; color: #eee; }",
            "    body { margin: 0; padding: 24px; }",
            "    h1 { margin: 0 0 8px; font-size: 24px; }",
            "    p { margin: 0 0 16px; color: #bbb; max-width: 980px; line-height: 1.45; }",
            "    h2 { margin: 26px 0 10px; font-size: 18px; }",
            "    table { width: 100%; border-collapse: collapse; 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; color: #ddd; }",
            "    .map-preview { image-rendering: pixelated; max-width: min(100%, 480px); height: auto; border: 1px solid #333; background: #080808; }",
            "    .target-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px; margin: 10px 0 18px; max-width: 1120px; }",
            "    .target-card { display: block; border: 1px solid #303030; border-radius: 6px; background: #181818; padding: 8px; color: #ddd; }",
            "    .target-card span { display: block; margin-top: 6px; font-size: 12px; color: #bbb; }",
            "    .target-preview { image-rendering: pixelated; width: 100%; height: 110px; object-fit: contain; background: #080808; }",
            "    .component-preview { image-rendering: pixelated; width: 128px; height: 96px; object-fit: contain; background: #080808; border: 1px solid #333; }",
            "    .review-actions { white-space: normal; min-width: 160px; }",
            "    .review-copy { display: block; width: 100%; margin: 0 0 5px; padding: 5px 7px; border: 1px solid #3a3a3a; border-radius: 5px; background: #202020; color: #ddd; cursor: pointer; }",
            "    .review-copy:hover { background: #2a2a2a; }",
            "    .basket-actions { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 8px; }",
            "    .basket-actions button { padding: 6px 9px; border: 1px solid #3a3a3a; border-radius: 5px; background: #202020; color: #ddd; cursor: pointer; }",
            "    #reviewPatchBox { width: min(100%, 960px); min-height: 160px; margin: 0 0 18px; background: #080808; color: #eee; border: 1px solid #333; border-radius: 6px; padding: 10px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }",
            "    .confirmed { color: #9ef0b8; } .rejected { color: #ffb0b0; } .unreviewed { color: #ffe2a8; }",
            "    .muted { color: #888; }",
            "    a { color: #7cc7ff; text-decoration: none; } a:hover { text-decoration: underline; }",
            "  </style>",
            "</head>",
            "<body>",
            f"  <h1>{html.escape(source)} Transition Review Gaps</h1>",
            f"  <p>Confirmed {summary.get('confirmed', 0)}, rejected {summary.get('rejected', 0)}, unreviewed {summary.get('unreviewed', 0)}. Start with record groups, then inspect individual rows only when needed.</p>",
            '  <p><a href="../transition_review_gaps.html">index</a></p>',
            f'  <p><img class="map-preview" src="{html.escape(source)}.png" alt="{html.escape(source)} transition candidate overlay"></p>',
            "  <h2>Target Map Previews</h2>",
            '  <div class="target-grid">',
            target_preview_cards(rows, maps, web_prefix),
            "  </div>",
            '  <div class="basket-actions">',
            '    <button id="copyBasket" type="button" hidden>copy review basket</button>',
            '    <button id="clearBasket" type="button" hidden>clear basket</button>',
            "  </div>",
            '  <textarea id="reviewPatchBox" hidden readonly aria-label="transition review JSON patch"></textarea>',
            "  <h2>Record Groups</h2>",
            "  <table>",
            "    <thead><tr><th>rows</th><th>points</th><th>standable</th><th>sample points</th><th>targets</th><th>confirmed</th><th>rejected</th><th>unreviewed</th><th>scene</th><th>kind</th><th>record</th><th>open</th></tr></thead>",
            "    <tbody>",
            "\n".join(group_body),
            "    </tbody>",
            "  </table>",
            "  <h2>Target Groups</h2>",
            "  <table>",
            "    <thead><tr><th>rows</th><th>points</th><th>standable</th><th>sample points</th><th>target</th><th>target scene</th><th>confirmed</th><th>rejected</th><th>unreviewed</th><th>scene</th><th>kind</th><th>record</th><th>open</th></tr></thead>",
            "    <tbody>",
            "\n".join(target_body),
            "    </tbody>",
            "  </table>",
            "  <h2>Connected Components</h2>",
            "  <table>",
            "    <thead><tr><th>preview</th><th>rows</th><th>points</th><th>standable</th><th>unreviewed standable</th><th>active dist</th><th>bounds</th><th>sample points</th><th>target</th><th>target scene</th><th>confirmed</th><th>rejected</th><th>unreviewed</th><th>scene</th><th>kind</th><th>record</th><th>open</th><th>review JSON</th></tr></thead>",
            "    <tbody>",
            "\n".join(component_body),
            "    </tbody>",
            "  </table>",
            "  <h2>Rows</h2>",
            "  <table>",
            "    <thead><tr><th>point</th><th>target</th><th>state</th><th>scene</th><th>kind</th><th>record</th><th>open</th></tr></thead>",
            "    <tbody>",
            "\n".join(body),
            "    </tbody>",
            "  </table>",
            "  <script>",
            "    (() => {",
            "      const box = document.getElementById('reviewPatchBox');",
            "      const copyBasket = document.getElementById('copyBasket');",
            "      const clearBasket = document.getElementById('clearBasket');",
            "      const basket = {};",
            "      const updateBasket = async (copy = false) => {",
            "        const count = Object.keys(basket).length;",
            "        box.value = JSON.stringify(basket, null, 2);",
            "        box.hidden = count === 0;",
            "        copyBasket.hidden = count === 0;",
            "        clearBasket.hidden = count === 0;",
            "        copyBasket.textContent = `copy review basket (${count})`;",
            "        if (count) { box.focus(); box.select(); }",
            "        if (copy && count) { try { await navigator.clipboard.writeText(box.value); } catch (error) {} }",
            "      };",
            "      for (const button of document.querySelectorAll('.review-copy')) {",
            "        const label = button.textContent;",
            "        button.addEventListener('click', async () => {",
            "          const patch = JSON.parse(button.dataset.reviewPatch || '{}');",
            "          if (button.dataset.spawn === '1') {",
            "            const text = prompt('target spawn tile as x,y');",
            "            if (!text) return;",
            "            const parts = text.split(',').map((part) => Number(part.trim()));",
            "            if (parts.length !== 2 || !parts.every(Number.isInteger)) { alert('spawn tile must be x,y'); return; }",
            "            for (const review of Object.values(patch)) { review.spawnX = parts[0]; review.spawnY = parts[1]; }",
            "          }",
            "          Object.assign(basket, patch);",
            "          await updateBasket(true);",
            "          button.textContent = button.dataset.doneLabel || 'copied';",
            "          setTimeout(() => { button.textContent = label; }, 1200);",
            "        });",
            "      }",
            "      copyBasket.addEventListener('click', () => updateBasket(true));",
            "      clearBasket.addEventListener('click', () => {",
            "        for (const key of Object.keys(basket)) delete basket[key];",
            "        updateBasket(false);",
            "      });",
            "    })();",
            "  </script>",
            "</body>",
            "</html>",
            "",
        ]
    )


def write_gap_html_outputs(rows: list[dict], out_dir: Path, maps: dict) -> None:
    (out_dir / "transition_review_gaps.html").write_text(gap_html(rows), encoding="utf-8")
    split_dir = out_dir / "transition_review_gaps"
    split_dir.mkdir(parents=True, exist_ok=True)
    render_target_previews(rows, maps, out_dir)
    for source, source_rows in rows_by_source(rows).items():
        render_source_overlay(source, source_rows, maps, out_dir)
        render_component_previews(source, source_rows, maps, out_dir)
        (split_dir / f"{source}.html").write_text(source_gap_html(source, source_rows, maps), encoding="utf-8")


def write_outputs(reviews: dict[str, dict], out_dir: Path) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "transition_reviews.json").write_text(
        json.dumps(reviews, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "transition_reviews.js").write_text(
        "window.HWANSE_TRANSITION_REVIEWS = "
        + json.dumps(reviews, ensure_ascii=False, separators=(",", ":"))
        + ";\n",
        encoding="utf-8",
    )


def write_gap_outputs(valid: dict[str, dict], reviews: dict[str, dict], out_dir: Path, maps: dict) -> list[dict]:
    rows = gap_rows(valid, reviews)
    annotate_source_standability(rows, maps, load_tile_classes(out_dir / "tile_classes.json"))
    (out_dir / "transition_review_gaps.json").write_text(
        json.dumps(rows, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    write_gap_html_outputs(rows, out_dir, maps)
    write_review_patch_outputs(rows, out_dir)
    return rows


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--src", type=Path, default=DATA / "transition_reviews.json")
    parser.add_argument("--transitions", type=Path, default=OUT / "event_transitions.json")
    parser.add_argument("--maps", type=Path, default=OUT / "maps.js")
    parser.add_argument("--out", type=Path, default=OUT)
    parser.add_argument("--merge", type=Path, help="merge browser-exported transition review JSON into --src")
    parser.add_argument("--write-src", action="store_true", help="write merged reviews back to --src")
    parser.add_argument("--dry-run", action="store_true", help="validate --merge and print its effect without writing files")
    args = parser.parse_args()

    valid = transition_index(load_transitions(args.transitions))
    reviews = load_reviews(args.src, valid)
    summary = None
    if args.merge:
        patch = load_reviews(args.merge, valid)
        summary = merge_summary(reviews, patch)
        reviews = normalize(merge(reviews, patch), valid)
    maps = load_maps(args.maps)
    validate_maps(reviews, maps)
    if args.dry_run:
        if summary is None:
            print("dry-run ok: no merge patch supplied")
        else:
            print_merge_summary(summary, len(reviews))
            print("dry-run ok: no files written")
        return
    if args.merge and args.write_src:
        args.src.write_text(json.dumps(reviews, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        print(f"updated source transition reviews -> {args.src}")
    if summary is not None:
        print_merge_summary(summary, len(reviews))
    write_outputs(reviews, args.out)
    write_gap_outputs(valid, reviews, args.out, maps)
    print(f"wrote {len(reviews)} transition reviews -> {args.out}")


if __name__ == "__main__":
    main()
