#!/usr/bin/env python3
"""Summarize the next active-nearest transition review components."""
from __future__ import annotations

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

from transition_reviews import bounds_text, component_groups, patch_file_stem, review_key


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


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


def patch_exists(row: dict, out_dir: Path = OUT) -> bool:
    rel = row.get("confirmNearestPatch")
    return bool(isinstance(rel, str) and (out_dir / rel).exists())


def markdown_patch_cell(row: dict, out_dir: Path = OUT) -> str:
    rel = row.get("confirmNearestPatch")
    if not isinstance(rel, str):
        return "-"
    if patch_exists(row, out_dir):
        return f"[confirm nearest]({rel})"
    return f"`{rel}` (not generated)"


def markdown_dry_run_cell(row: dict, out_dir: Path = OUT) -> str:
    command = row.get("dryRunCommand")
    if not command:
        return "-"
    if not patch_exists(row, out_dir):
        return "patch preview only"
    return f"`{command}`"


def html_patch_cell(row: dict, out_dir: Path = OUT) -> str:
    rel = row.get("confirmNearestPatch")
    if not isinstance(rel, str):
        return "-"
    escaped = html.escape(rel)
    if patch_exists(row, out_dir):
        return f'<a href="{escaped}">confirm nearest</a>'
    return f'<code>{escaped}</code><br><span class="missing">not generated</span>'


def html_dry_run_cell(row: dict, out_dir: Path = OUT) -> str:
    command = row.get("dryRunCommand")
    if not command:
        return "-"
    if not patch_exists(row, out_dir):
        return '<span class="missing">patch preview only</span>'
    return f"<code>{html.escape(command)}</code>"


def patch_record(component: dict) -> dict | None:
    points = component.get("activeReviewPoints") or []
    if not points:
        return None
    x, y = points[0]
    key = review_key(
        component["source"],
        x,
        y,
        component["target"],
        component.get("recordVaHex") or component.get("recordVa") or "record",
    )
    return {
        key: {
            "source": component["source"],
            "x": x,
            "y": y,
            "target": component["target"],
            "state": "confirmed",
            **{
                field: component[field]
                for field in ["recordVa", "recordVaHex", "sceneId", "sceneIdHex", "eventKind", "targetSceneId", "targetSceneIdHex"]
                if component.get(field) is not None
            },
        }
    }


def root_index(field_map_roots: dict | None) -> dict[str, dict]:
    index = {}
    for cluster in (field_map_roots or {}).get("clusters") or []:
        for event in cluster.get("eventRecords") or []:
            record_va = event.get("recordVaHex")
            if record_va:
                index[record_va] = cluster
    return index


def render_mismatches(cluster: dict | None) -> list[dict]:
    rows = []
    for record in (cluster or {}).get("manifestRecords") or []:
        render = record.get("render") or {}
        accepted = render.get("acceptedTilesets") or []
        current = render.get("recordTilesets") or []
        if accepted and render.get("matchesAccepted") is False:
            rows.append({
                "map": record.get("map"),
                "recordVaHex": record.get("recordVaHex"),
                "recordTilesets": current,
                "acceptedTilesets": accepted,
            })
    return rows


def row_priority(row: dict) -> tuple:
    active_distance = row.get("activeDistance")
    return (
        row.get("rootClassification") != "strict event-linked cluster",
        row.get("rootRenderMismatchCount", 999),
        9999 if active_distance is None else active_distance,
        -row.get("unreviewedStandableCount", 0),
        row.get("source") or "",
        row.get("recordVaHex") or "",
        row.get("target") or "",
    )


def build_rows(gap_rows: list[dict], limit: int = 24, field_map_roots: dict | None = None) -> list[dict]:
    rows = []
    roots_by_record = root_index(field_map_roots)
    for component in component_groups(gap_rows):
        if component.get("unreviewedStandableCount", 0) <= 0:
            continue
        if not component.get("activeReviewPoints"):
            continue
        x, y = component["activeReviewPoints"][0]
        record = component.get("recordVaHex") or str(component.get("recordVa") or "record")
        root = roots_by_record.get(component.get("recordVaHex"))
        mismatches = render_mismatches(root)
        rows.append({
            "source": component["source"],
            "target": component["target"],
            "recordVaHex": component.get("recordVaHex"),
            "sceneIdHex": component.get("sceneIdHex"),
            "eventKind": component.get("eventKind"),
            "targetSceneIdHex": component.get("targetSceneIdHex"),
            "bounds": {
                "minX": component["bounds"][0],
                "minY": component["bounds"][1],
                "maxX": component["bounds"][2],
                "maxY": component["bounds"][3],
            },
            "activeDistance": component.get("activeDistance"),
            "standableCount": component.get("standableCount", 0),
            "unreviewedStandableCount": component.get("unreviewedStandableCount", 0),
            "rootClusterStartHex": root.get("clusterStartHex") if root else None,
            "rootClusterEndHex": root.get("clusterEndHex") if root else None,
            "rootClassification": root.get("classification") if root else None,
            "rootManifestMaps": root.get("manifestMaps") if root else [],
            "rootEventSources": root.get("eventSources") if root else [],
            "rootRenderMismatchCount": len(mismatches),
            "rootRenderMismatches": mismatches,
            "activeReviewPoint": {"x": x, "y": y},
            "reviewUrl": web_href({
                "map": component["source"],
                "events": "1",
                "overview": "1",
                "startTile": f"{x},{y}",
                "focusTile": f"{x},{y}",
                "transitionTarget": component["target"],
                "transitionRecord": record,
            }),
            "trialUrl": web_href({
                "map": component["source"],
                "trialTransitions": "activeNearest",
                "startTile": f"{x},{y}",
                "transitionTarget": component["target"],
                "transitionRecord": record,
            }),
            "confirmNearestPatch": f"transition_review_patches/{patch_file_stem(component, 'confirmed', True)}.json",
            "dryRunCommand": (
                "python3 tools/transition_reviews.py --merge "
                f"out/transition_review_patches/{patch_file_stem(component, 'confirmed', True)}.json --dry-run"
            ),
            "confirmNearestPatchPreview": patch_record(component),
        })
    return sorted(rows, key=row_priority)[:limit]


def active_point(row: dict) -> dict:
    point = row.get("activeReviewPoint") or {}
    return {
        "x": point.get("x"),
        "y": point.get("y"),
    }


def duplicate_source_target_point_groups(rows: list[dict]) -> list[dict]:
    grouped: dict[tuple, list[dict]] = defaultdict(list)
    for row in rows:
        point = active_point(row)
        grouped[(row.get("source"), row.get("target"), point.get("x"), point.get("y"))].append(row)
    duplicate_groups = []
    for (source, target, x, y), group_rows in grouped.items():
        if len(group_rows) <= 1:
            continue
        duplicate_groups.append({
            "source": source,
            "target": target,
            "activeReviewPoint": {"x": x, "y": y},
            "rowCount": len(group_rows),
            "records": sorted(
                record
                for record in {row.get("recordVaHex") for row in group_rows}
                if record
            ),
            "rootClusterStarts": sorted(
                root
                for root in {row.get("rootClusterStartHex") for row in group_rows}
                if root
            ),
        })
    return sorted(
        duplicate_groups,
        key=lambda group: (
            -group["rowCount"],
            group["source"] or "",
            group["target"] or "",
            group["activeReviewPoint"].get("y") if group["activeReviewPoint"].get("y") is not None else -1,
            group["activeReviewPoint"].get("x") if group["activeReviewPoint"].get("x") is not None else -1,
        ),
    )


def patch_preview_checks(rows: list[dict], out_dir: Path) -> list[dict]:
    checks = []
    for row in rows:
        rel = row.get("confirmNearestPatch")
        patch_path = out_dir / rel if isinstance(rel, str) else None
        patch_exists = bool(patch_path and patch_path.exists())
        preview_matches_patch = False
        if patch_path and patch_exists:
            patch = json.loads(patch_path.read_text(encoding="utf-8"))
            preview_matches_patch = patch == row.get("confirmNearestPatchPreview")
        point = active_point(row)
        checks.append({
            "source": row.get("source"),
            "target": row.get("target"),
            "recordVaHex": row.get("recordVaHex"),
            "activeReviewPoint": point,
            "patch": rel,
            "patchExists": patch_exists,
            "previewMatchesPatch": preview_matches_patch,
        })
    return checks


def build_audit(rows: list[dict], out_dir: Path = OUT) -> dict:
    duplicate_groups = duplicate_source_target_point_groups(rows)
    patch_checks = patch_preview_checks(rows, out_dir)
    unique_record_groups = {
        (row.get("source"), row.get("recordVaHex"))
        for row in rows
    }
    unique_targets = {row.get("target") for row in rows if row.get("target")}
    unique_sources = {row.get("source") for row in rows if row.get("source")}
    zero_render_mismatch_rows = sum(1 for row in rows if row.get("rootRenderMismatchCount") == 0)
    strict_rows = sum(1 for row in rows if row.get("rootClassification") == "strict event-linked cluster")
    active_zero_rows = sum(1 for row in rows if row.get("activeDistance") == 0)
    patch_exists = sum(1 for row in patch_checks if row.get("patchExists"))
    preview_matches = sum(1 for row in patch_checks if row.get("patchExists") and row.get("previewMatchesPatch"))
    preview_mismatches = sum(1 for row in patch_checks if row.get("patchExists") and not row.get("previewMatchesPatch"))
    missing_target_scene = sum(1 for row in rows if not row.get("targetSceneIdHex"))
    first = rows[0] if rows else {}
    return {
        "objective": "root-aware active-nearest transition review shortlist audit",
        "rowCount": len(rows),
        "sourceCount": len(unique_sources),
        "targetCount": len(unique_targets),
        "recordGroupCount": len(unique_record_groups),
        "strictEventLinkedRows": strict_rows,
        "zeroRenderMismatchRows": zero_render_mismatch_rows,
        "activeDistanceZeroRows": active_zero_rows,
        "missingTargetSceneRows": missing_target_scene,
        "duplicateSourceTargetPointGroupCount": len(duplicate_groups),
        "duplicateSourceTargetPointGroups": duplicate_groups,
        "patchPreviewCheckCount": len(patch_checks),
        "patchPreviewFilesPresent": patch_exists,
        "patchPreviewMissingFileCount": len(patch_checks) - patch_exists,
        "patchPreviewMatches": preview_matches,
        "patchPreviewMismatchCount": preview_mismatches,
        "firstCandidate": {
            "source": first.get("source"),
            "target": first.get("target"),
            "recordVaHex": first.get("recordVaHex"),
            "activeReviewPoint": active_point(first) if first else None,
            "rootClusterStartHex": first.get("rootClusterStartHex"),
            "rootClassification": first.get("rootClassification"),
            "rootRenderMismatchCount": first.get("rootRenderMismatchCount"),
            "dryRunCommand": first.get("dryRunCommand"),
        } if first else None,
        "reviewMode": "manual-review-only",
        "promotionStatus": "not-auto-confirmed",
        "normalRouteImpact": "none until a reviewer merges a confirmed transition review into data/transition_reviews.json",
        "cautions": [
            "Shortlist rows are generated from unreviewed standable components and are not normal-route proof.",
            "Duplicate source/target/active-point groups mean multiple EXE records still need manual discrimination.",
            "Confirm-nearest patches are dry-run merge inputs; they do not change data/transition_reviews.json unless explicitly merged.",
        ],
    }


def audit_markdown(audit: dict) -> str:
    lines = [
        "# Transition Review Shortlist Audit",
        "",
        f"- objective: {audit['objective']}",
        f"- review mode: `{audit['reviewMode']}`",
        f"- promotion status: `{audit['promotionStatus']}`",
        f"- normal route impact: {audit['normalRouteImpact']}",
        "",
        "| metric | value |",
        "| --- | ---: |",
    ]
    for key in [
        "rowCount",
        "sourceCount",
        "targetCount",
        "recordGroupCount",
        "strictEventLinkedRows",
        "zeroRenderMismatchRows",
        "activeDistanceZeroRows",
        "missingTargetSceneRows",
        "duplicateSourceTargetPointGroupCount",
        "patchPreviewCheckCount",
        "patchPreviewFilesPresent",
        "patchPreviewMissingFileCount",
        "patchPreviewMatches",
        "patchPreviewMismatchCount",
    ]:
        lines.append(f"| {key} | {audit.get(key, 0)} |")
    first = audit.get("firstCandidate") or {}
    point = first.get("activeReviewPoint") or {}
    lines.extend([
        "",
        "## First Candidate",
        "",
        f"- source/target: `{first.get('source')}` -> `{first.get('target')}`",
        f"- active point: `{point.get('x')},{point.get('y')}`",
        f"- record/root: `{first.get('recordVaHex')}` / `{first.get('rootClusterStartHex')}`",
        f"- root: {first.get('rootClassification')} with render mismatches {first.get('rootRenderMismatchCount')}",
        f"- dry-run: `{first.get('dryRunCommand')}`",
        "",
        "## Cautions",
        "",
    ])
    lines.extend(f"- {item}" for item in audit.get("cautions") or [])
    lines.extend([
        "",
        "## Duplicate Source/Target/Active-Point Groups",
        "",
        "| source | target | active point | rows | records | roots |",
        "| --- | --- | --- | ---: | --- | --- |",
    ])
    for group in audit.get("duplicateSourceTargetPointGroups") or []:
        point = group["activeReviewPoint"]
        lines.append(
            f"| {group['source']} | {group['target']} | {point['x']},{point['y']} | "
            f"{group['rowCount']} | {', '.join(group['records']) or '-'} | "
            f"{', '.join(group['rootClusterStarts']) or '-'} |"
        )
    if not audit.get("duplicateSourceTargetPointGroups"):
        lines.append("| - | - | - | - | - | - |")
    return "\n".join(lines) + "\n"


def audit_html_page(audit: dict) -> str:
    metric_rows = "\n".join(
        f"<tr><td>{html.escape(key)}</td><td>{audit.get(key, 0)}</td></tr>"
        for key in [
            "rowCount",
            "sourceCount",
            "targetCount",
            "recordGroupCount",
            "strictEventLinkedRows",
            "zeroRenderMismatchRows",
            "activeDistanceZeroRows",
            "missingTargetSceneRows",
            "duplicateSourceTargetPointGroupCount",
            "patchPreviewCheckCount",
            "patchPreviewFilesPresent",
            "patchPreviewMissingFileCount",
            "patchPreviewMatches",
            "patchPreviewMismatchCount",
        ]
    )
    first = audit.get("firstCandidate") or {}
    point = first.get("activeReviewPoint") or {}
    caution_items = "\n".join(f"<li>{html.escape(item)}</li>" for item in audit.get("cautions") or [])
    duplicate_rows = "\n".join(
        "<tr>"
        f"<td>{html.escape(group['source'])}</td>"
        f"<td>{html.escape(group['target'])}</td>"
        f"<td>{group['activeReviewPoint']['x']},{group['activeReviewPoint']['y']}</td>"
        f"<td>{group['rowCount']}</td>"
        f"<td>{html.escape(', '.join(group['records']) or '-')}</td>"
        f"<td>{html.escape(', '.join(group['rootClusterStarts']) or '-')}</td>"
        "</tr>"
        for group in audit.get("duplicateSourceTargetPointGroups") or []
    )
    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 Shortlist Audit</title>",
        "  <style>body{font-family:system-ui,sans-serif;background:#111;color:#ddd;margin:24px;max-width:1200px}a{color:#8fd3ff}table{border-collapse:collapse;width:100%;margin:12px 0 20px}td,th{border:1px solid #333;padding:6px 8px;text-align:left;vertical-align:top}th{background:#1f1f1f}code{color:#f5d76e}</style>",
        "</head>",
        "<body>",
        "  <h1>Transition Review Shortlist Audit</h1>",
        f"  <p>{html.escape(audit['objective'])}. Review mode: <code>{html.escape(audit['reviewMode'])}</code>. Promotion status: <code>{html.escape(audit['promotionStatus'])}</code>.</p>",
        f"  <p>normal route impact: {html.escape(audit['normalRouteImpact'])}</p>",
        "  <table><thead><tr><th>metric</th><th>value</th></tr></thead><tbody>",
        metric_rows,
        "  </tbody></table>",
        "  <h2>First Candidate</h2>",
        "  <ul>",
        f"    <li>source/target: <code>{html.escape(str(first.get('source')))} -&gt; {html.escape(str(first.get('target')))}</code></li>",
        f"    <li>active point: <code>{point.get('x')},{point.get('y')}</code></li>",
        f"    <li>record/root: <code>{html.escape(str(first.get('recordVaHex')))} / {html.escape(str(first.get('rootClusterStartHex')))}</code></li>",
        f"    <li>root: {html.escape(str(first.get('rootClassification')))} with render mismatches {first.get('rootRenderMismatchCount')}</li>",
        f"    <li>dry-run: <code>{html.escape(str(first.get('dryRunCommand')))}</code></li>",
        "  </ul>",
        "  <h2>Cautions</h2>",
        f"  <ul>{caution_items}</ul>",
        "  <h2>Duplicate Source/Target/Active-Point Groups</h2>",
        "  <table><thead><tr><th>source</th><th>target</th><th>active point</th><th>rows</th><th>records</th><th>roots</th></tr></thead><tbody>",
        duplicate_rows or '<tr><td colspan="6">No duplicate groups.</td></tr>',
        "  </tbody></table>",
        "</body>",
        "</html>",
        "",
    ])


def markdown(rows: list[dict], audit: dict | None = None) -> str:
    lines = [
        "# Transition Review Shortlist",
        "",
        "Active-nearest, source-standable transition components to inspect next. These are review targets, not auto-confirmed transitions.",
        "",
        "| source | target | active point | dist | standable | root | render mismatches | scene | target scene | record | review | trial | patch | dry-run |",
        "| --- | --- | --- | ---: | ---: | --- | ---: | --- | --- | --- | --- | --- | --- | --- |",
    ]
    for row in rows:
        point = row["activeReviewPoint"]
        lines.append(
            f"| {row['source']} | {row['target']} | {point['x']},{point['y']} | "
            f"{row.get('activeDistance') if row.get('activeDistance') is not None else '-'} | "
            f"{row.get('unreviewedStandableCount', 0)} | "
            f"{row.get('rootClassification') or '-'} `{row.get('rootClusterStartHex') or '-'}` | "
            f"{row.get('rootRenderMismatchCount', 0)} | {row.get('sceneIdHex') or '-'} | "
            f"{row.get('targetSceneIdHex') or '-'} | `{row.get('recordVaHex') or '-'}` | "
            f"[review]({row['reviewUrl']}) | [trial]({row['trialUrl']}) | "
            f"{markdown_patch_cell(row)} | {markdown_dry_run_cell(row)} |"
        )
    if not rows:
        lines.append("| - | - | - | - | - | - | - | - | - | - | - | - | - | - |")
    if audit:
        lines.extend([
            "",
            "## Shortlist Audit",
            "",
            f"- review mode: `{audit['reviewMode']}`",
            f"- promotion status: `{audit['promotionStatus']}`",
            f"- rows/sources/targets/records: {audit['rowCount']}/{audit['sourceCount']}/{audit['targetCount']}/{audit['recordGroupCount']}",
            f"- strict/zero-render/active-zero rows: {audit['strictEventLinkedRows']}/{audit['zeroRenderMismatchRows']}/{audit['activeDistanceZeroRows']}",
            f"- duplicate source-target active-point groups: {audit['duplicateSourceTargetPointGroupCount']}",
            f"- patch previews matching generated patch files: {audit['patchPreviewMatches']}/{audit['patchPreviewFilesPresent']} present; missing files {audit['patchPreviewMissingFileCount']}",
            "- normal route impact: none until a reviewer merges a confirmed transition review into `data/transition_reviews.json`.",
            "",
            "[full audit](transition_review_shortlist_audit.html)",
        ])
    return "\n".join(lines) + "\n"


def html_page(rows: list[dict], audit: dict | None = None, out_dir: Path = OUT) -> str:
    body = []
    for row in rows:
        point = row["activeReviewPoint"]
        body.append(
            "<tr>"
            f"<td>{html.escape(row['source'])}</td>"
            f"<td>{html.escape(row['target'])}</td>"
            f"<td>{point['x']},{point['y']}</td>"
            f"<td>{row.get('activeDistance') if row.get('activeDistance') is not None else '-'}</td>"
            f"<td>{row.get('unreviewedStandableCount', 0)}</td>"
            f"<td>{html.escape(row.get('rootClassification') or '-')}<br><code>{html.escape(row.get('rootClusterStartHex') or '-')}</code></td>"
            f"<td>{row.get('rootRenderMismatchCount', 0)}</td>"
            f"<td>{html.escape(row.get('sceneIdHex') or '-')}</td>"
            f"<td>{html.escape(row.get('targetSceneIdHex') or '-')}</td>"
            f"<td><code>{html.escape(row.get('recordVaHex') or '-')}</code></td>"
            f'<td><a href="{html.escape(row["reviewUrl"])}">review</a></td>'
            f'<td><a href="{html.escape(row["trialUrl"])}">trial</a></td>'
            f"<td>{html_patch_cell(row, out_dir)}</td>"
            f"<td>{html_dry_run_cell(row, out_dir)}</td>"
            "</tr>"
        )
    audit_html = ""
    if audit:
        audit_html = "\n".join([
            "  <h2>Shortlist Audit</h2>",
            f"  <p>Review mode: <code>{html.escape(audit['reviewMode'])}</code>. Promotion status: <code>{html.escape(audit['promotionStatus'])}</code>. Normal route impact: none until a confirmed review is merged into <code>data/transition_reviews.json</code>.</p>",
            "  <ul>",
            f"    <li>rows/sources/targets/records: {audit['rowCount']}/{audit['sourceCount']}/{audit['targetCount']}/{audit['recordGroupCount']}</li>",
            f"    <li>strict/zero-render/active-zero rows: {audit['strictEventLinkedRows']}/{audit['zeroRenderMismatchRows']}/{audit['activeDistanceZeroRows']}</li>",
            f"    <li>duplicate source-target active-point groups: {audit['duplicateSourceTargetPointGroupCount']}</li>",
            f"    <li>patch previews matching generated patch files: {audit['patchPreviewMatches']}/{audit['patchPreviewFilesPresent']} present; missing files {audit['patchPreviewMissingFileCount']}</li>",
            '    <li><a href="transition_review_shortlist_audit.html">full audit</a></li>',
            "  </ul>",
        ])
    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 Shortlist</title>",
        "  <style>body{font-family:system-ui,sans-serif;background:#111;color:#ddd;margin:24px}a{color:#8fd3ff}table{border-collapse:collapse;width:100%}td,th{border:1px solid #333;padding:6px 8px;text-align:left;vertical-align:top}th{background:#1f1f1f}code{color:#f5d76e}.missing{color:#999}</style>",
        "</head>",
        "<body>",
        "  <h1>Transition Review Shortlist</h1>",
        "  <p>Active-nearest, source-standable transition components to inspect next. These are review targets, not auto-confirmed transitions.</p>",
        audit_html,
        "  <table><thead><tr><th>source</th><th>target</th><th>active point</th><th>dist</th><th>standable</th><th>root</th><th>render mismatches</th><th>scene</th><th>target scene</th><th>record</th><th>review</th><th>trial</th><th>patch</th><th>dry-run</th></tr></thead>",
        f"  <tbody>{''.join(body) or '<tr><td colspan=\"14\">No shortlist rows.</td></tr>'}</tbody></table>",
        "</body>",
        "</html>",
        "",
    ])


def write_outputs(rows: list[dict], out_dir: Path = OUT, html_out: Path | None = None) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    audit = build_audit(rows, out_dir)
    (out_dir / "transition_review_shortlist.json").write_text(
        json.dumps(rows, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    if html_out is not None:
        html_out.parent.mkdir(parents=True, exist_ok=True)
        html_out.write_text(html_page(rows, audit, out_dir), encoding="utf-8")
    (out_dir / "transition_review_shortlist_audit.json").write_text(
        json.dumps(audit, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    (out_dir / "transition_review_shortlist_audit.html").write_text(audit_html_page(audit), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--gaps", type=Path, default=OUT / "transition_review_gaps.json")
    parser.add_argument("--field-map-roots", type=Path, default=OUT / "field_map_record_roots.json")
    parser.add_argument("--limit", type=int, default=24)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--html-out", type=Path, default=None, help="Optional shortlist HTML output path.")
    args = parser.parse_args()
    roots = json.loads(args.field_map_roots.read_text(encoding="utf-8")) if args.field_map_roots.exists() else None
    if args.gaps.exists():
        rows = build_rows(json.loads(args.gaps.read_text(encoding="utf-8")), args.limit, roots)
    elif (args.out_dir / "transition_review_shortlist.json").exists():
        rows = json.loads((args.out_dir / "transition_review_shortlist.json").read_text(encoding="utf-8"))
    else:
        rows = []
    write_outputs(rows, args.out_dir, args.html_out)
    print(f"wrote {len(rows)} transition review shortlist rows -> {args.out_dir / 'transition_review_shortlist.json'}")


if __name__ == "__main__":
    main()
