#!/usr/bin/env python3
"""Cluster unknown varying savedata ranges for local offset triage."""
from __future__ import annotations

import argparse
import html
import json
from pathlib import Path
from typing import Any

from summarize_savedata_sample_deltas import SAMPLE_FILES


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


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


def hex_range(start: int, end: int) -> str:
    if end - start <= 1:
        return f"0x{start:04x}"
    return f"0x{start:04x}-0x{end - 1:04x}"


def row_range(row: dict) -> tuple[int, int]:
    start = int(row.get("start", row.get("offset", 0)) or 0)
    end = int(row.get("endExclusive", start + int(row.get("size", 1) or 1)) or start)
    return start, max(start, end)


def field_record(field: dict) -> dict:
    start = int(field.get("offset", 0) or 0)
    end = int(field.get("endExclusive", start + int(field.get("size", 1) or 1)) or start)
    return {
        "category": field.get("category") or "unknown",
        "label": field.get("label") or "-",
        "source": field.get("source") or "-",
        "start": start,
        "endExclusive": max(start, end),
        "rangeHex": field.get("rangeHex") or hex_range(start, max(start, end)),
    }


def route_offset_record(row: dict) -> dict:
    start = int(row.get("offset", 0) or 0)
    size = int(row.get("size", 1) or 1)
    end = start + size
    return {
        "label": row.get("label") or "-",
        "offsetHex": row.get("offsetHex") or f"0x{start:04x}",
        "start": start,
        "endExclusive": end,
        "rangeHex": hex_range(start, end),
        "expectedPresentInPublicSamples": row.get("expectedPresentInPublicSamples"),
    }


def selector_from_data(data: bytes) -> str:
    if len(data) < 4:
        return "unknown"
    return f"{data[0x0002]}:{data[0x0003]}"


def bytes_hex(data: bytes) -> str:
    return " ".join(f"{value:02x}" for value in data)


def table_entries_by_key(text_tables: dict, key: str) -> list[dict]:
    for table in text_tables.get("tables") or []:
        if table.get("key") == key:
            return table.get("entries") or []
    return []


def decode_pair_value_rows(value_rows: list[dict], text_tables: dict) -> list[dict]:
    equipment_entries = table_entries_by_key(text_tables, "equipment")
    item_entries = table_entries_by_key(text_tables, "items")
    equipment_by_zero = {int(row["index"]): row for row in equipment_entries}
    equipment_by_one = {int(row["index"]) + 1: row for row in equipment_entries}
    item_by_zero = {int(row["index"]): row for row in item_entries}
    item_by_one = {int(row["index"]) + 1: row for row in item_entries}
    rows = []
    for value_row in value_rows:
        if value_row["size"] < 2 or value_row["size"] % 2:
            continue
        seen: set[tuple[str, int, int, str]] = set()
        decoded_pairs = []
        for sample in value_row["valuesBySample"]:
            raw = bytes.fromhex(sample["bytesHex"])
            for pair_index in range(0, len(raw), 2):
                item_id = raw[pair_index]
                quantity = raw[pair_index + 1]
                if item_id == 0 and quantity == 0:
                    continue
                key = (sample["selector"], item_id, quantity, sample["sampleId"])
                if key in seen:
                    continue
                seen.add(key)
                one_based_equipment = equipment_by_one.get(item_id)
                zero_based_equipment = equipment_by_zero.get(item_id)
                one_based_item = item_by_one.get(item_id)
                zero_based_item = item_by_zero.get(item_id)
                decoded_pairs.append({
                    "sampleId": sample["sampleId"],
                    "selector": sample["selector"],
                    "pairIndex": pair_index // 2,
                    "id": item_id,
                    "idHex": f"0x{item_id:02x}",
                    "quantity": quantity,
                    "quantityHex": f"0x{quantity:02x}",
                    "oneBasedEquipmentName": one_based_equipment.get("text") if one_based_equipment else "",
                    "zeroBasedEquipmentName": zero_based_equipment.get("text") if zero_based_equipment else "",
                    "oneBasedItemName": one_based_item.get("text") if one_based_item else "",
                    "zeroBasedItemName": zero_based_item.get("text") if zero_based_item else "",
                    "candidateBasis": "one-based equipment table" if one_based_equipment else "unmapped id",
                })
        unique_ids = sorted({pair["id"] for pair in decoded_pairs})
        one_based_hit_ids = sorted({pair["id"] for pair in decoded_pairs if pair["oneBasedEquipmentName"]})
        zero_based_hit_ids = sorted({pair["id"] for pair in decoded_pairs if pair["zeroBasedEquipmentName"]})
        one_based_item_hit_ids = sorted({pair["id"] for pair in decoded_pairs if pair["oneBasedItemName"]})
        zero_based_item_hit_ids = sorted({pair["id"] for pair in decoded_pairs if pair["zeroBasedItemName"]})
        rows.append({
            "rangeHex": value_row["rangeHex"],
            "size": value_row["size"],
            "pairCount": value_row["size"] // 2,
            "uniqueIdsHex": [f"0x{item_id:02x}" for item_id in unique_ids],
            "uniqueIdCount": len(unique_ids),
            "oneBasedEquipmentHitIdsHex": [f"0x{item_id:02x}" for item_id in one_based_hit_ids],
            "oneBasedEquipmentHitCount": len(one_based_hit_ids),
            "zeroBasedEquipmentHitIdsHex": [f"0x{item_id:02x}" for item_id in zero_based_hit_ids],
            "zeroBasedEquipmentHitCount": len(zero_based_hit_ids),
            "oneBasedItemHitIdsHex": [f"0x{item_id:02x}" for item_id in one_based_item_hit_ids],
            "oneBasedItemHitCount": len(one_based_item_hit_ids),
            "zeroBasedItemHitIdsHex": [f"0x{item_id:02x}" for item_id in zero_based_item_hit_ids],
            "zeroBasedItemHitCount": len(zero_based_item_hit_ids),
            "decodedPairs": decoded_pairs[:48],
            "promotionBoundary": "pair/id-name matches are candidates until original save writer and equipped-slot semantics are proven",
        })
    return rows


def sample_value_rows(range_rows: list[dict], bucket: str) -> list[dict]:
    samples = []
    for sample_id, path in SAMPLE_FILES:
        data = path.read_bytes()
        samples.append({
            "id": sample_id,
            "path": str(path.relative_to(ROOT)),
            "selector": selector_from_data(data),
            "data": data,
        })
    rows = []
    for row in range_rows:
        if row.get("bucket") != bucket:
            continue
        start = int(row["start"])
        end = int(row["endExclusive"])
        values_by_selector: dict[str, set[str]] = {}
        values_by_sample = []
        for sample in samples:
            value = bytes(sample["data"][start:end])
            value_text = bytes_hex(value)
            values_by_selector.setdefault(sample["selector"], set()).add(value_text)
            values_by_sample.append({
                "sampleId": sample["id"],
                "path": sample["path"],
                "selector": sample["selector"],
                "bytesHex": value_text,
            })
        selector_values = {
            selector: sorted(values)
            for selector, values in sorted(values_by_selector.items())
        }
        distinct_values = sorted({value for values in selector_values.values() for value in values})
        rows.append({
            "rangeHex": row["rangeHex"],
            "start": start,
            "endExclusive": end,
            "size": row["size"],
            "bucket": row["bucket"],
            "nearestKnownBefore": row.get("nearestKnownBefore"),
            "nearestKnownAfter": row.get("nearestKnownAfter"),
            "valuesBySelectorHex": selector_values,
            "valuesBySample": values_by_sample,
            "distinctValueCount": len(distinct_values),
            "allSamplesSame": len(distinct_values) == 1,
            "promotionBoundary": row["promotionBoundary"],
        })
    return rows


def overlaps(left_start: int, left_end: int, right_start: int, right_end: int) -> bool:
    return left_start < right_end and right_start < left_end


def nearest_fields(start: int, end: int, fields: list[dict]) -> tuple[dict | None, dict | None, int | None]:
    before: dict | None = None
    after: dict | None = None
    before_distance: int | None = None
    after_distance: int | None = None
    for field in fields:
        field_start = int(field["start"])
        field_end = int(field["endExclusive"])
        if field_end <= start:
            distance = start - field_end
            if before_distance is None or distance < before_distance:
                before = field
                before_distance = distance
        if field_start >= end:
            distance = field_start - end
            if after_distance is None or distance < after_distance:
                after = field
                after_distance = distance
    distances = [value for value in (before_distance, after_distance) if value is not None]
    return before, after, min(distances) if distances else None


def classify_range(
    start: int,
    end: int,
    before: dict | None,
    after: dict | None,
    route_overlaps: list[dict],
) -> tuple[str, str, str, str]:
    categories = {field.get("category") for field in (before, after) if field}
    if route_overlaps or start < 0x0020:
        return (
            "route-selector-control",
            "partial",
            "compare against captured selector 2:0 savedata and selected pointer 0x00540714",
            "route-adjacent bytes still require real current-selector save/runtime proof",
        )
    if start < 0x0078 or categories & {"items", "activeDescriptor", "money"}:
        return (
            "inventory-equipment-candidate",
            "candidate",
            "diff samples with item/equipment changes and cross-check EXE equipment labels",
            "item adjacency is not ownership/equipped-slot offset or writer proof",
        )
    if "skills" in categories:
        return (
            "skill-status-candidate",
            "candidate",
            "separate learned-skill bytes from poison/paralysis/fallen status flags",
            "skill adjacency is not original status flag mutation proof",
        )
    if "character" in categories or 0x0078 <= start < 0x0300:
        return (
            "character-status-candidate",
            "candidate",
            "group per-character stride-adjacent bytes and compare status/stat deltas",
            "stat adjacency is not original status flag or equipment effect proof",
        )
    if start >= 0x0300:
        return (
            "story-event-tail-candidate",
            "candidate",
            "cluster tail bytes against event/story progress samples",
            "tail variation is not original story/event writer proof",
        )
    return (
        "unclassified-gap",
        "missing",
        "collect more save deltas before assigning a system",
        "unclassified variation cannot be promoted",
    )


def build_summary(
    coverage: dict | None = None,
    deltas: dict | None = None,
    text_tables: dict | None = None,
) -> dict:
    coverage = coverage if coverage is not None else load_json(OUT / "savedata_sample_coverage.json", {})
    deltas = deltas if deltas is not None else load_json(OUT / "savedata_sample_deltas.json", {})
    text_tables = text_tables if text_tables is not None else load_json(OUT / "text_tables.json", {})
    semantic = coverage.get("semanticCoverage") or deltas.get("semanticCoverage") or {}
    fields = sorted(
        (field_record(row) for row in semantic.get("knownSemanticFields") or []),
        key=lambda row: (row["start"], row["endExclusive"], row["category"], row["label"]),
    )
    route_offsets = [route_offset_record(row) for row in deltas.get("routeCriticalOffsets") or []]
    range_rows = []
    for raw in semantic.get("unknownVaryingRanges") or []:
        start, end = row_range(raw)
        before, after, distance = nearest_fields(start, end, fields)
        route_overlaps = [
            item for item in route_offsets
            if overlaps(start, end, int(item["start"]), int(item["endExclusive"]))
        ]
        bucket, status, next_check, boundary = classify_range(start, end, before, after, route_overlaps)
        range_rows.append({
            "rangeHex": raw.get("rangeHex") or hex_range(start, end),
            "start": start,
            "endExclusive": end,
            "startHex": f"0x{start:04x}",
            "endExclusiveHex": f"0x{end:04x}",
            "size": end - start,
            "bucket": bucket,
            "status": status,
            "nearestKnownBefore": before,
            "nearestKnownAfter": after,
            "distanceToKnown": distance,
            "routeCriticalOverlaps": route_overlaps,
            "nextLocalCheck": next_check,
            "promotionBoundary": boundary,
        })

    buckets: dict[str, dict] = {}
    for row in range_rows:
        bucket = buckets.setdefault(row["bucket"], {
            "bucket": row["bucket"],
            "status": row["status"],
            "rangeCount": 0,
            "byteCount": 0,
            "sampleRanges": [],
            "nearestCategories": set(),
            "nextLocalCheck": row["nextLocalCheck"],
            "promotionBoundary": row["promotionBoundary"],
        })
        bucket["rangeCount"] += 1
        bucket["byteCount"] += row["size"]
        if len(bucket["sampleRanges"]) < 6:
            bucket["sampleRanges"].append(row["rangeHex"])
        for key in ("nearestKnownBefore", "nearestKnownAfter"):
            nearest = row.get(key) or {}
            category = nearest.get("category")
            if category:
                bucket["nearestCategories"].add(category)

    bucket_rows = []
    for bucket in sorted(buckets.values(), key=lambda row: (-row["rangeCount"], row["bucket"])):
        bucket_rows.append({
            **bucket,
            "nearestCategories": sorted(bucket["nearestCategories"]),
        })

    status_counts: dict[str, int] = {}
    for row in range_rows:
        status_counts[row["status"]] = status_counts.get(row["status"], 0) + 1

    top_ranges = sorted(range_rows, key=lambda row: (-row["size"], row["start"]))[:16]
    inventory_value_rows = sample_value_rows(range_rows, "inventory-equipment-candidate")
    route_value_rows = sample_value_rows(range_rows, "route-selector-control")
    pair_decode_rows = decode_pair_value_rows(inventory_value_rows, text_tables)
    conclusion = (
        "Unknown varying savedata ranges can be locally triaged by proximity to known scene/item/character/skill fields, "
        "but these clusters are candidates only. Equipment ownership, equipped-slot, status flag, and story/event writer "
        "offsets still need targeted save deltas or original runtime writer proof."
    )
    return {
        "source": "out/savedata_sample_coverage.json",
        "deltaSource": "out/savedata_sample_deltas.json",
        "knownSemanticByteCount": semantic.get("knownSemanticByteCount", 0),
        "knownSemanticFieldCount": semantic.get("knownFieldCount", len(fields)),
        "unknownByteCount": semantic.get("unknownByteCount", 0),
        "unknownVaryingByteCount": semantic.get("unknownVaryingByteCount", 0),
        "unknownVaryingRangeCount": len(range_rows),
        "routeCriticalOffsetCount": len(route_offsets),
        "bucketCount": len(bucket_rows),
        "statusCounts": status_counts,
        "bucketRows": bucket_rows,
        "rangeRows": range_rows,
        "topRangeRows": top_ranges,
        "valueMatrixSampleCount": len(SAMPLE_FILES),
        "inventoryEquipmentValueRows": inventory_value_rows,
        "inventoryEquipmentDistinctRangeCount": sum(1 for row in inventory_value_rows if not row["allSamplesSame"]),
        "inventoryEquipmentPairDecodeRows": pair_decode_rows,
        "inventoryEquipmentPairDecodeRowCount": len(pair_decode_rows),
        "inventoryEquipmentPairOneBasedHitCount": sum(row["oneBasedEquipmentHitCount"] for row in pair_decode_rows),
        "inventoryEquipmentPairZeroBasedHitCount": sum(row["zeroBasedEquipmentHitCount"] for row in pair_decode_rows),
        "inventoryEquipmentPairOneBasedItemHitCount": sum(row["oneBasedItemHitCount"] for row in pair_decode_rows),
        "inventoryEquipmentPairZeroBasedItemHitCount": sum(row["zeroBasedItemHitCount"] for row in pair_decode_rows),
        "inventoryEquipmentPairCandidateBasis": "one-based equipment table candidate",
        "routeControlValueRows": route_value_rows,
        "routeControlDistinctRangeCount": sum(1 for row in route_value_rows if not row["allSamplesSame"]),
        "nonPromoting": True,
        "equipmentOffsetsMapped": False,
        "statusFlagsMapped": False,
        "storyEventWritersMapped": False,
        "requiresSelector20Save": True,
        "conclusion": conclusion,
    }


def markdown(summary: dict) -> str:
    lines = [
        "# Savedata Offset Triage Clusters",
        "",
        f"- known semantic bytes: {summary['knownSemanticByteCount']}",
        f"- unknown varying ranges: {summary['unknownVaryingRangeCount']}",
        f"- unknown varying bytes: {summary['unknownVaryingByteCount']}",
        f"- route-critical offsets: {summary['routeCriticalOffsetCount']}",
        f"- buckets: {summary['bucketCount']}",
        f"- non-promoting: {summary['nonPromoting']}",
        "",
        summary["conclusion"],
        "",
        "## Buckets",
        "",
        "| bucket | status | ranges | bytes | sample ranges | nearest categories | next local check | boundary |",
        "| --- | --- | ---: | ---: | --- | --- | --- | --- |",
    ]
    for row in summary["bucketRows"]:
        lines.append(
            f"| `{row['bucket']}` | `{row['status']}` | {row['rangeCount']} | {row['byteCount']} | "
            f"{', '.join(f'`{item}`' for item in row['sampleRanges'])} | "
            f"{', '.join(row['nearestCategories']) or '-'} | {row['nextLocalCheck']} | {row['promotionBoundary']} |"
        )
    lines.extend([
        "",
        "## Inventory/Equipment Candidate Values",
        "",
        "| range | size | distinct | values by selector | boundary |",
        "| --- | ---: | ---: | --- | --- |",
    ])
    for row in summary["inventoryEquipmentValueRows"]:
        selector_values = "; ".join(
            f"{selector}: {', '.join(f'`{value}`' for value in values)}"
            for selector, values in row["valuesBySelectorHex"].items()
        )
        lines.append(
            f"| `{row['rangeHex']}` | {row['size']} | {row['distinctValueCount']} | "
            f"{selector_values} | {row['promotionBoundary']} |"
        )
    lines.extend([
        "",
        "## Inventory/Equipment Pair Decode Candidates",
        "",
        "| range | pairs | unique ids | 1-based equipment hits | 0-based equipment hits | 1-based item hits | 0-based item hits | boundary |",
        "| --- | ---: | --- | --- | --- | --- | --- | --- |",
    ])
    for row in summary["inventoryEquipmentPairDecodeRows"]:
        lines.append(
            f"| `{row['rangeHex']}` | {row['pairCount']} | {', '.join(f'`{item}`' for item in row['uniqueIdsHex']) or '-'} | "
            f"{', '.join(f'`{item}`' for item in row['oneBasedEquipmentHitIdsHex']) or '-'} | "
            f"{', '.join(f'`{item}`' for item in row['zeroBasedEquipmentHitIdsHex']) or '-'} | "
            f"{', '.join(f'`{item}`' for item in row['oneBasedItemHitIdsHex']) or '-'} | "
            f"{', '.join(f'`{item}`' for item in row['zeroBasedItemHitIdsHex']) or '-'} | {row['promotionBoundary']} |"
        )
    lines.extend([
        "",
        "## Largest Ranges",
        "",
        "| range | bucket | size | before | after | distance |",
        "| --- | --- | ---: | --- | --- | ---: |",
    ])
    for row in summary["topRangeRows"]:
        before = row.get("nearestKnownBefore") or {}
        after = row.get("nearestKnownAfter") or {}
        before_text = f"{before.get('category', '-')}/{before.get('label', '-')}"
        after_text = f"{after.get('category', '-')}/{after.get('label', '-')}"
        lines.append(
            f"| `{row['rangeHex']}` | `{row['bucket']}` | {row['size']} | "
            f"{before_text} | {after_text} | {row.get('distanceToKnown') if row.get('distanceToKnown') is not None else '-'} |"
        )
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    bucket_rows = []
    for row in summary["bucketRows"]:
        bucket_rows.append(
            "<tr>"
            f"<td><code>{html.escape(row['bucket'])}</code></td>"
            f"<td>{html.escape(row['status'])}</td>"
            f"<td>{row['rangeCount']}</td>"
            f"<td>{row['byteCount']}</td>"
            f"<td><code>{html.escape(', '.join(row['sampleRanges']))}</code></td>"
            f"<td>{html.escape(', '.join(row['nearestCategories']) or '-')}</td>"
            f"<td>{html.escape(row['nextLocalCheck'])}</td>"
            f"<td>{html.escape(row['promotionBoundary'])}</td>"
            "</tr>"
        )
    top_rows = []
    for row in summary["topRangeRows"]:
        before = row.get("nearestKnownBefore") or {}
        after = row.get("nearestKnownAfter") or {}
        top_rows.append(
            "<tr>"
            f"<td><code>{html.escape(row['rangeHex'])}</code></td>"
            f"<td><code>{html.escape(row['bucket'])}</code></td>"
            f"<td>{row['size']}</td>"
            f"<td>{html.escape(before.get('category', '-') + '/' + before.get('label', '-'))}</td>"
            f"<td>{html.escape(after.get('category', '-') + '/' + after.get('label', '-'))}</td>"
            f"<td>{html.escape(str(row.get('distanceToKnown') if row.get('distanceToKnown') is not None else '-'))}</td>"
            "</tr>"
        )
    value_rows = []
    for row in summary["inventoryEquipmentValueRows"]:
        selector_values = "; ".join(
            f"{selector}: {', '.join(values)}"
            for selector, values in row["valuesBySelectorHex"].items()
        )
        value_rows.append(
            "<tr>"
            f"<td><code>{html.escape(row['rangeHex'])}</code></td>"
            f"<td>{row['size']}</td>"
            f"<td>{row['distinctValueCount']}</td>"
            f"<td><code>{html.escape(selector_values)}</code></td>"
            f"<td>{html.escape(row['promotionBoundary'])}</td>"
            "</tr>"
        )
    pair_rows = []
    for row in summary["inventoryEquipmentPairDecodeRows"]:
        pair_rows.append(
            "<tr>"
            f"<td><code>{html.escape(row['rangeHex'])}</code></td>"
            f"<td>{row['pairCount']}</td>"
            f"<td><code>{html.escape(', '.join(row['uniqueIdsHex']) or '-')}</code></td>"
            f"<td><code>{html.escape(', '.join(row['oneBasedEquipmentHitIdsHex']) or '-')}</code></td>"
            f"<td><code>{html.escape(', '.join(row['zeroBasedEquipmentHitIdsHex']) or '-')}</code></td>"
            f"<td><code>{html.escape(', '.join(row['oneBasedItemHitIdsHex']) or '-')}</code></td>"
            f"<td><code>{html.escape(', '.join(row['zeroBasedItemHitIdsHex']) or '-')}</code></td>"
            f"<td>{html.escape(row['promotionBoundary'])}</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>Savedata Offset Triage Clusters</title>",
        "  <style>",
        "    body { margin: 24px; background: #111; color: #eee; font: 14px system-ui, sans-serif; }",
        "    table { border-collapse: collapse; width: 100%; margin: 18px 0 28px; }",
        "    th, td { border: 1px solid #3a3a3a; padding: 6px 8px; vertical-align: top; }",
        "    th { background: #202020; position: sticky; top: 0; }",
        "    code { color: #9bd4ff; }",
        "  </style>",
        "</head>",
        "<body>",
        "  <h1>Savedata Offset Triage Clusters</h1>",
        (
            "  <p>"
            f"known semantic bytes {summary['knownSemanticByteCount']}; "
            f"unknown varying ranges {summary['unknownVaryingRangeCount']}; "
            f"unknown varying bytes {summary['unknownVaryingByteCount']}; "
            f"route-critical offsets {summary['routeCriticalOffsetCount']}; "
            f"buckets {summary['bucketCount']}; non-promoting {summary['nonPromoting']}."
            "</p>"
        ),
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        "  <h2>Buckets</h2>",
        "  <table><thead><tr><th>bucket</th><th>status</th><th>ranges</th><th>bytes</th><th>sample ranges</th><th>nearest categories</th><th>next local check</th><th>boundary</th></tr></thead>",
        f"  <tbody>{''.join(bucket_rows)}</tbody></table>",
        "  <h2>Inventory/Equipment Candidate Values</h2>",
        "  <table><thead><tr><th>range</th><th>size</th><th>distinct</th><th>values by selector</th><th>boundary</th></tr></thead>",
        f"  <tbody>{''.join(value_rows)}</tbody></table>",
        "  <h2>Inventory/Equipment Pair Decode Candidates</h2>",
        "  <table><thead><tr><th>range</th><th>pairs</th><th>unique ids</th><th>1-based equipment hits</th><th>0-based equipment hits</th><th>1-based item hits</th><th>0-based item hits</th><th>boundary</th></tr></thead>",
        f"  <tbody>{''.join(pair_rows)}</tbody></table>",
        "  <h2>Largest Ranges</h2>",
        "  <table><thead><tr><th>range</th><th>bucket</th><th>size</th><th>before</th><th>after</th><th>distance</th></tr></thead>",
        f"  <tbody>{''.join(top_rows)}</tbody></table>",
        "</body>",
        "</html>",
        "",
    ])


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


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(
        load_json(args.out_dir / "savedata_sample_coverage.json", {}),
        load_json(args.out_dir / "savedata_sample_deltas.json", {}),
        load_json(args.out_dir / "text_tables.json", {}),
    )
    write_outputs(summary, args.out_dir)
    print(f"wrote savedata offset triage clusters -> {args.out_dir / 'savedata_offset_triage_clusters.md'}")


if __name__ == "__main__":
    main()
