#!/usr/bin/env python3
"""Compare public savedat samples around route-critical offsets."""
from __future__ import annotations

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

from parse_savedata import (
    ACTIVE_DESCRIPTOR_COUNT_OFFSET,
    ACTIVE_DESCRIPTOR_ORDER_OFFSET,
    ACTIVE_DESCRIPTOR_ORDER_SCAN_BYTES,
    CHARACTERS,
    EQUIPMENT_EVIDENCE,
    ITEM_FIELDS,
    SCENE_POSITION_X_OFFSET,
    SCENE_POSITION_Y_OFFSET,
    SCENE_SELECTOR_GROUP_OFFSET,
    SCENE_SELECTOR_SLOT_OFFSET,
)


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
PUBLIC_SAVEDATA = ROOT / "data" / "public_savedata"
SOURCE = "map1_01a"
TARGET = "map2_02d"
CURRENT_SELECTOR = "2:0"
EXPECTED_SAVE_SIZE = 1274

SAMPLE_FILES = [
    ("debug_savedat1", PUBLIC_SAVEDATA / "HandyHwanseEditor" / "bin" / "Debug" / "savedat1.dat"),
    ("debug_savedat1_copy", PUBLIC_SAVEDATA / "HandyHwanseEditor" / "bin" / "Debug" / "savedat1-copy.dat"),
    ("debug_savedat2", PUBLIC_SAVEDATA / "HandyHwanseEditor" / "bin" / "Debug" / "savedat2.dat"),
    ("release_savedat2", PUBLIC_SAVEDATA / "HandyHwanseEditor" / "bin" / "Release" / "savedat2.dat"),
    ("flack3r_savedat2", PUBLIC_SAVEDATA / "flack3r" / "savedat2.dat"),
    ("flack3r_zip_savedat4", PUBLIC_SAVEDATA / "flack3r_zip" / "savedat4.dat"),
]

ROUTE_CRITICAL_OFFSETS = [
    (0x0002, 1, "scene selector group", 2),
    (0x0003, 1, "scene selector slot", 0),
    (0x0004, 2, "scene tile X", None),
    (0x0006, 2, "scene tile Y", None),
    (0x0010, 1, "active descriptor count", None),
    (0x0011, 8, "active descriptor order bytes", None),
    (0x006C, 1, "active flag", 1),
    (0x00E2, 1, "save-runtime gate e2", None),
    (0x00E4, 1, "save-runtime gate e4", None),
    (0x00E8, 1, "linear trap gate e8", None),
    (0x00EA, 1, "linear trap gate ea", None),
]


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


def selector_key(group: int | None, slot: int | None) -> str | None:
    if group is None or slot is None:
        return None
    return f"{group}:{slot}"


def read_value(data: bytes, offset: int, size: int) -> int | list[int]:
    if offset + size > len(data):
        raise ValueError(f"savedata too short for offset 0x{offset:04x}+{size}")
    if size == 1:
        return data[offset]
    if size == 2:
        return data[offset] | (data[offset + 1] << 8)
    return list(data[offset:offset + size])


def value_hex(value: int | list[int], size: int) -> str:
    if isinstance(value, list):
        return "[" + ", ".join(f"0x{item:02x}" for item in value) + "]"
    width = size * 2
    return f"0x{value:0{width}x}"


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


def range_record(start: int, end: int) -> dict:
    return {
        "start": start,
        "endExclusive": end,
        "startHex": f"0x{start:04x}",
        "endExclusiveHex": f"0x{end:04x}",
        "endInclusiveHex": f"0x{end - 1:04x}",
        "size": end - start,
        "rangeHex": hex_range(start, end),
    }


def merge_ranges(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
    merged: list[tuple[int, int]] = []
    for start, end in sorted(ranges):
        start = max(0, start)
        end = min(EXPECTED_SAVE_SIZE, end)
        if start >= end:
            continue
        if not merged or start > merged[-1][1]:
            merged.append((start, end))
        else:
            previous_start, previous_end = merged[-1]
            merged[-1] = (previous_start, max(previous_end, end))
    return merged


def complement_ranges(ranges: list[tuple[int, int]], size: int) -> list[tuple[int, int]]:
    unknown = []
    cursor = 0
    for start, end in ranges:
        if cursor < start:
            unknown.append((cursor, start))
        cursor = max(cursor, end)
    if cursor < size:
        unknown.append((cursor, size))
    return unknown


def offsets_to_ranges(offsets: list[int]) -> list[dict]:
    if not offsets:
        return []
    ranges: list[tuple[int, int]] = []
    start = offsets[0]
    previous = offsets[0]
    for offset in offsets[1:]:
        if offset == previous + 1:
            previous = offset
            continue
        ranges.append((start, previous + 1))
        start = previous = offset
    ranges.append((start, previous + 1))
    return [range_record(start, end) for start, end in ranges]


def known_semantic_fields() -> list[dict]:
    fields = [
        {
            "category": "scene",
            "label": "scene selector group",
            "offset": SCENE_SELECTOR_GROUP_OFFSET,
            "size": 1,
            "source": "parse_savedata.sceneSelector.group",
        },
        {
            "category": "scene",
            "label": "scene selector slot",
            "offset": SCENE_SELECTOR_SLOT_OFFSET,
            "size": 1,
            "source": "parse_savedata.sceneSelector.slot",
        },
        {
            "category": "scene",
            "label": "scene tile X",
            "offset": SCENE_POSITION_X_OFFSET,
            "size": 2,
            "source": "parse_savedata.scenePositionCandidate.x",
        },
        {
            "category": "scene",
            "label": "scene tile Y",
            "offset": SCENE_POSITION_Y_OFFSET,
            "size": 2,
            "source": "parse_savedata.scenePositionCandidate.y",
        },
        {
            "category": "money",
            "label": "money",
            "offset": 0x0008,
            "size": 3,
            "source": "HDNua/HandyHwanseEditor money",
        },
        {
            "category": "activeDescriptor",
            "label": "active descriptor count",
            "offset": ACTIVE_DESCRIPTOR_COUNT_OFFSET,
            "size": 1,
            "source": "parse_savedata.activeDescriptorOrder.count",
        },
        {
            "category": "activeDescriptor",
            "label": "active descriptor order scan",
            "offset": ACTIVE_DESCRIPTOR_ORDER_OFFSET,
            "size": ACTIVE_DESCRIPTOR_ORDER_SCAN_BYTES,
            "source": "parse_savedata.activeDescriptorOrder.orderBytes",
        },
    ]
    for key, name, offset in ITEM_FIELDS:
        fields.append({
            "category": "items",
            "label": name or key,
            "offset": offset,
            "size": 1,
            "source": f"parse_savedata.items.{key}",
        })
    for character in CHARACTERS:
        character_key = character["key"]
        character_name = character["name"]
        fields.append({
            "category": "character",
            "label": f"{character_name} level",
            "offset": character["level"],
            "size": 2,
            "source": f"parse_savedata.characters.{character_key}.level",
        })
        for group_name in ["hp", "mp", "experience", "baseStats", "enhanceStats"]:
            for field_name, offset in character[group_name].items():
                fields.append({
                    "category": "character",
                    "label": f"{character_name} {group_name}.{field_name}",
                    "offset": offset,
                    "size": 2,
                    "source": f"parse_savedata.characters.{character_key}.{group_name}.{field_name}",
                })
        for skill_group, block in character["skills"].items():
            for index, _base_value in enumerate(block["valueBases"]):
                fields.append({
                    "category": "skills",
                    "label": f"{character_name} {skill_group} skill slot {index}",
                    "offset": block["base"] + index,
                    "size": 1,
                    "source": f"parse_savedata.characters.{character_key}.skills.{skill_group}.{index}",
                })
    return [
        {
            **field,
            "offsetHex": f"0x{field['offset']:04x}",
            "endExclusive": field["offset"] + field["size"],
            "endExclusiveHex": f"0x{field['offset'] + field['size']:04x}",
            "rangeHex": hex_range(field["offset"], field["offset"] + field["size"]),
        }
        for field in fields
    ]


def semantic_coverage(samples: list[dict]) -> dict:
    fields = known_semantic_fields()
    raw_ranges = [(field["offset"], field["offset"] + field["size"]) for field in fields]
    merged = merge_ranges(raw_ranges)
    known_offsets = {offset for start, end in merged for offset in range(start, end)}
    unknown = complement_ranges(merged, EXPECTED_SAVE_SIZE)
    category_counts: dict[str, set[int]] = defaultdict(set)
    field_hits_by_offset: dict[int, list[str]] = defaultdict(list)
    for field in fields:
        category = field["category"]
        label = field["label"]
        for offset in range(field["offset"], min(EXPECTED_SAVE_SIZE, field["offset"] + field["size"])):
            category_counts[category].add(offset)
            field_hits_by_offset[offset].append(label)
    overlapping = [
        {
            "offset": offset,
            "offsetHex": f"0x{offset:04x}",
            "fields": labels,
        }
        for offset, labels in sorted(field_hits_by_offset.items())
        if len(labels) > 1
    ]
    sample_data = [(ROOT / sample["path"]).read_bytes() for sample in samples]
    varying_offsets = [
        offset
        for offset in range(EXPECTED_SAVE_SIZE)
        if len({data[offset] for data in sample_data if offset < len(data)}) > 1
    ]
    unknown_varying_offsets = [offset for offset in varying_offsets if offset not in known_offsets]
    known_varying_offsets = [offset for offset in varying_offsets if offset in known_offsets]
    unknown_varying_set = set(unknown_varying_offsets)
    unknown_ranges = [range_record(start, end) for start, end in unknown]
    unknown_ranges_with_variation = []
    for start, end in unknown:
        varying_count = sum(1 for offset in range(start, end) if offset in unknown_varying_set)
        row = range_record(start, end)
        row["varyingByteCount"] = varying_count
        if varying_count:
            row["firstVaryingOffsetsHex"] = [
                f"0x{offset:04x}"
                for offset in range(start, end)
                if offset in unknown_varying_set
            ][:16]
        unknown_ranges_with_variation.append(row)
    return {
        "expectedSaveSize": EXPECTED_SAVE_SIZE,
        "knownFieldCount": len(fields),
        "knownSemanticByteCount": len(known_offsets),
        "knownSemanticBytePercent": round(len(known_offsets) * 100 / EXPECTED_SAVE_SIZE, 2),
        "unknownByteCount": EXPECTED_SAVE_SIZE - len(known_offsets),
        "unknownRangeCount": len(unknown_ranges),
        "knownMergedRanges": [range_record(start, end) for start, end in merged],
        "unknownRanges": unknown_ranges,
        "unknownRangesWithVariation": unknown_ranges_with_variation,
        "fieldCategoryByteCounts": {
            category: len(offsets)
            for category, offsets in sorted(category_counts.items())
        },
        "overlappingKnownByteCount": len(overlapping),
        "overlappingKnownFields": overlapping[:32],
        "varyingByteCount": len(varying_offsets),
        "knownVaryingByteCount": len(known_varying_offsets),
        "unknownVaryingByteCount": len(unknown_varying_offsets),
        "unknownVaryingRanges": offsets_to_ranges(unknown_varying_offsets),
        "knownSemanticFields": fields,
        "equipmentStatusStoryOffsetStatus": "unmapped-known-editor-coverage-only",
        "equipmentEvidence": EQUIPMENT_EVIDENCE,
        "unsupportedOriginalSystems": [
            "equipment ownership/equipped offsets",
            "original poison/paralysis/fallen status flag offsets",
            "story/event flag mutation offsets",
        ],
        "conclusion": (
            "Only the editor-backed scene, money, item, character stat, and skill bytes are semantically mapped. "
            "The remaining bytes still need controlled saves or runtime writes before equipment ownership, equipped "
            "slots, original status flags, or story/event flags can be claimed."
        ),
    }


def selector_rows_by_key(selectors: list[dict]) -> dict[str, dict]:
    return {
        selector_key(row.get("group"), row.get("slot")): row
        for row in selectors
        if selector_key(row.get("group"), row.get("slot"))
    }


def sample_rows(selectors: list[dict]) -> list[dict]:
    selector_rows = selector_rows_by_key(selectors)
    rows = []
    for sample_id, path in SAMPLE_FILES:
        data = path.read_bytes()
        group = read_value(data, 0x0002, 1)
        slot = read_value(data, 0x0003, 1)
        selector = selector_key(group if isinstance(group, int) else None, slot if isinstance(slot, int) else None)
        selector_row = selector_rows.get(selector or "") or {}
        field_maps = selector_row.get("fieldMaps") or []
        rows.append({
            "id": sample_id,
            "path": str(path.relative_to(ROOT)),
            "size": len(data),
            "selector": selector,
            "selectorGroup": group,
            "selectorSlot": slot,
            "selectedPointerHex": selector_row.get("selectedPointerHex"),
            "fieldMaps": field_maps,
            "coversSource": SOURCE in field_maps,
            "coversTarget": TARGET in field_maps,
            "coversRoutePair": SOURCE in field_maps and TARGET in field_maps,
            "routeCriticalValues": {
                f"0x{offset:04x}": {
                    "size": size,
                    "label": label,
                    "value": read_value(data, offset, size),
                    "valueHex": value_hex(read_value(data, offset, size), size),
                }
                for offset, size, label, _expected in ROUTE_CRITICAL_OFFSETS
            },
        })
    return rows


def unique_values(values: list[int | list[int]]) -> list[int | list[int]]:
    seen = set()
    rows: list[int | list[int]] = []
    for value in values:
        key = tuple(value) if isinstance(value, list) else value
        if key in seen:
            continue
        seen.add(key)
        rows.append(value)
    return rows


def offset_rows(samples: list[dict]) -> list[dict]:
    rows = []
    for offset, size, label, expected in ROUTE_CRITICAL_OFFSETS:
        key = f"0x{offset:04x}"
        values = [sample["routeCriticalValues"][key]["value"] for sample in samples]
        distinct = unique_values(values)
        values_by_selector: dict[str, list[str]] = defaultdict(list)
        for sample in samples:
            value = sample["routeCriticalValues"][key]["value"]
            text = value_hex(value, size)
            selector = sample.get("selector") or "unknown"
            if text not in values_by_selector[selector]:
                values_by_selector[selector].append(text)
        expected_present = None
        if expected is not None:
            expected_present = any(value == expected for value in values)
        rows.append({
            "offset": offset,
            "offsetHex": key,
            "size": size,
            "label": label,
            "expectedForCurrent": expected,
            "expectedForCurrentHex": value_hex(expected, size) if expected is not None else None,
            "expectedPresentInPublicSamples": expected_present,
            "distinctValueCount": len(distinct),
            "distinctValuesHex": [value_hex(value, size) for value in distinct],
            "valuesBySelector": dict(sorted(values_by_selector.items())),
            "allSamplesSame": len(distinct) == 1,
        })
    return rows


def selector_distinguishing_offsets(samples: list[dict]) -> list[dict]:
    data_by_id = {sample["id"]: (ROOT / sample["path"]).read_bytes() for sample in samples}
    rows = []
    for offset in range(EXPECTED_SAVE_SIZE):
        values_by_selector: dict[str, set[int]] = defaultdict(set)
        for sample in samples:
            values_by_selector[sample.get("selector") or "unknown"].add(data_by_id[sample["id"]][offset])
        if len(values_by_selector) < 2:
            continue
        selector_values = {selector: sorted(values) for selector, values in values_by_selector.items()}
        if all(len(values) == 1 for values in selector_values.values()) and len({values[0] for values in selector_values.values()}) > 1:
            rows.append({
                "offset": offset,
                "offsetHex": f"0x{offset:04x}",
                "valuesBySelectorHex": {
                    selector: f"0x{values[0]:02x}"
                    for selector, values in sorted(selector_values.items())
                },
            })
    return rows


def closest_public_route_pair(samples: list[dict]) -> dict:
    source_samples = [sample for sample in samples if sample.get("coversSource")]
    target_samples = [sample for sample in samples if sample.get("coversTarget")]
    pairs = []
    for source_sample in source_samples:
        source_data = (ROOT / source_sample["path"]).read_bytes()
        for target_sample in target_samples:
            target_data = (ROOT / target_sample["path"]).read_bytes()
            differing = [
                index for index, (left, right) in enumerate(zip(source_data, target_data))
                if left != right
            ]
            pairs.append({
                "sourceSample": source_sample["id"],
                "sourceSelector": source_sample.get("selector"),
                "targetSample": target_sample["id"],
                "targetSelector": target_sample.get("selector"),
                "differingByteCount": len(differing),
                "firstDifferingOffsetsHex": [f"0x{offset:04x}" for offset in differing[:24]],
            })
    pairs.sort(key=lambda row: (row["differingByteCount"], row["sourceSample"], row["targetSample"]))
    return pairs[0] if pairs else {}


def build_summary(selectors: list[dict] | None = None) -> dict:
    selectors = selectors if selectors is not None else load_json(OUT / "save_scene_selectors.json", [])
    samples = sample_rows(selectors)
    selectors_seen = sorted({sample.get("selector") for sample in samples if sample.get("selector")})
    selector_2_0_samples = [sample for sample in samples if sample.get("selector") == CURRENT_SELECTOR]
    route_pair_samples = [sample for sample in samples if sample.get("coversRoutePair")]
    distinguishing = selector_distinguishing_offsets(samples)
    coverage = semantic_coverage(samples)
    summary = {
        "source": SOURCE,
        "target": TARGET,
        "sampleCount": len(samples),
        "sampleSelectors": selectors_seen,
        "currentSelector": CURRENT_SELECTOR,
        "currentSelectorPublicSampleCount": len(selector_2_0_samples),
        "routePairPublicSampleCount": len(route_pair_samples),
        "expectedCurrentSelectorBytes": {
            "0x0002": "0x02",
            "0x0003": "0x00",
        },
        "routeCriticalOffsets": offset_rows(samples),
        "selectorDistinguishingOffsetCount": len(distinguishing),
        "selectorDistinguishingOffsets": distinguishing[:80],
        "closestPublicSourceTargetPair": closest_public_route_pair(samples),
        "semanticCoverage": coverage,
        "samples": samples,
        "promotionStatus": "blocked",
        "conclusion": (
            "Public savedata samples distinguish the source-side selector 0:0 and target-side selector 1:0, "
            "but no public sample has save bytes 0x0002=0x02 and 0x0003=0x00. The selector 2:0 route pair still "
            "requires a real captured save, a runtime selected-pointer trace, or a strict map1_01a hotspot."
        ),
    }
    return summary


def markdown(summary: dict) -> str:
    coverage = summary.get("semanticCoverage") or {}
    equipment = coverage.get("equipmentEvidence") or {}
    lines = [
        "# Savedata Sample Deltas",
        "",
        f"- route: {summary['source']} -> {summary['target']}",
        f"- samples: {summary['sampleCount']}",
        f"- sample selectors: {', '.join(summary['sampleSelectors'])}",
        f"- current selector: `{summary['currentSelector']}`",
        f"- current selector public samples: {summary['currentSelectorPublicSampleCount']}",
        f"- route-pair public samples: {summary['routePairPublicSampleCount']}",
        f"- selector-distinguishing byte offsets: {summary['selectorDistinguishingOffsetCount']}",
        f"- known semantic bytes: {coverage.get('knownSemanticByteCount', 0)}/{coverage.get('expectedSaveSize', EXPECTED_SAVE_SIZE)}",
        f"- unknown bytes: {coverage.get('unknownByteCount', 0)}",
        f"- unknown varying bytes in public samples: {coverage.get('unknownVaryingByteCount', 0)}",
        f"- promotion status: `{summary['promotionStatus']}`",
        "",
        summary["conclusion"],
        "",
        "## Route-Critical Offsets",
        "",
        "| offset | label | expected for 2:0 | public distinct values | values by selector |",
        "| --- | --- | --- | --- | --- |",
    ]
    for row in summary["routeCriticalOffsets"]:
        by_selector = "; ".join(
            f"{selector}={','.join(values)}"
            for selector, values in row["valuesBySelector"].items()
        )
        lines.append(
            f"| `{row['offsetHex']}` | {row['label']} | `{row.get('expectedForCurrentHex') or '-'}` | "
            f"`{', '.join(row['distinctValuesHex'])}` | `{by_selector}` |"
        )
    lines.extend([
        "",
        "## Semantic Coverage Gap",
        "",
        coverage.get("conclusion", ""),
        "",
        "| category | known bytes |",
        "| --- | ---: |",
    ])
    for category, count in (coverage.get("fieldCategoryByteCounts") or {}).items():
        lines.append(f"| {category} | {count} |")
    lines.extend([
        "",
        f"- known semantic fields: {coverage.get('knownFieldCount', 0)}",
        f"- known semantic ranges: {len(coverage.get('knownMergedRanges') or [])}",
        f"- unknown ranges: {coverage.get('unknownRangeCount', 0)}",
        f"- varying bytes: {coverage.get('varyingByteCount', 0)} "
        f"(known {coverage.get('knownVaryingByteCount', 0)}, unknown {coverage.get('unknownVaryingByteCount', 0)})",
        f"- equipment/status/story offset status: `{coverage.get('equipmentStatusStoryOffsetStatus', '-')}`",
        "",
        "### Unknown Ranges",
        "",
        "| range | size | varying bytes | first varying offsets |",
        "| --- | ---: | ---: | --- |",
    ])
    for row in (coverage.get("unknownRangesWithVariation") or [])[:40]:
        first_offsets = ", ".join(row.get("firstVaryingOffsetsHex") or [])
        lines.append(
            f"| `{row['rangeHex']}` | {row['size']} | {row.get('varyingByteCount', 0)} | "
            f"{first_offsets or '-'} |"
        )
    lines.extend([
        "",
        "### Equipment Evidence",
        "",
        f"- status: `{equipment.get('status', '-')}`",
        f"- source: {equipment.get('source', '-')}",
    ])
    for note in equipment.get("notes") or []:
        lines.append(f"- {note}")
    pair = summary.get("closestPublicSourceTargetPair") or {}
    lines.extend([
        "",
        "## Closest Public Source/Target Pair",
        "",
        f"- source sample: `{pair.get('sourceSample')}` selector `{pair.get('sourceSelector')}`",
        f"- target sample: `{pair.get('targetSample')}` selector `{pair.get('targetSelector')}`",
        f"- differing bytes: {pair.get('differingByteCount')}",
        f"- first differing offsets: {', '.join(pair.get('firstDifferingOffsetsHex') or []) or '-'}",
        "",
        "## Selector-Distinguishing Offsets",
        "",
        "| offset | values by selector |",
        "| --- | --- |",
    ])
    for row in summary["selectorDistinguishingOffsets"]:
        values = "; ".join(f"{selector}={value}" for selector, value in row["valuesBySelectorHex"].items())
        lines.append(f"| `{row['offsetHex']}` | `{values}` |")
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    coverage = summary.get("semanticCoverage") or {}
    equipment = coverage.get("equipmentEvidence") or {}
    critical_rows = []
    for row in summary["routeCriticalOffsets"]:
        by_selector = "; ".join(
            f"{selector}={','.join(values)}"
            for selector, values in row["valuesBySelector"].items()
        )
        critical_rows.append(
            "<tr>"
            f"<td><code>{html.escape(row['offsetHex'])}</code></td>"
            f"<td>{html.escape(row['label'])}</td>"
            f"<td><code>{html.escape(row.get('expectedForCurrentHex') or '-')}</code></td>"
            f"<td><code>{html.escape(', '.join(row['distinctValuesHex']))}</code></td>"
            f"<td><code>{html.escape(by_selector)}</code></td>"
            "</tr>"
        )
    category_rows = []
    for category, count in (coverage.get("fieldCategoryByteCounts") or {}).items():
        category_rows.append(
            "<tr>"
            f"<td>{html.escape(category)}</td>"
            f"<td>{count}</td>"
            "</tr>"
        )
    unknown_rows = []
    for row in (coverage.get("unknownRangesWithVariation") or [])[:40]:
        first_offsets = ", ".join(row.get("firstVaryingOffsetsHex") or [])
        unknown_rows.append(
            "<tr>"
            f"<td><code>{html.escape(row['rangeHex'])}</code></td>"
            f"<td>{row['size']}</td>"
            f"<td>{row.get('varyingByteCount', 0)}</td>"
            f"<td><code>{html.escape(first_offsets or '-')}</code></td>"
            "</tr>"
        )
    equipment_notes = "".join(
        f"<li>{html.escape(note)}</li>"
        for note in equipment.get("notes") or []
    )
    diff_rows = []
    for row in summary["selectorDistinguishingOffsets"]:
        values = "; ".join(f"{selector}={value}" for selector, value in row["valuesBySelectorHex"].items())
        diff_rows.append(
            "<tr>"
            f"<td><code>{html.escape(row['offsetHex'])}</code></td>"
            f"<td><code>{html.escape(values)}</code></td>"
            "</tr>"
        )
    pair = summary.get("closestPublicSourceTargetPair") 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>Savedata Sample Deltas</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 Sample Deltas</h1>",
        f"  <p>route: {html.escape(summary['source'])} -&gt; {html.escape(summary['target'])}; samples {summary['sampleCount']}; current selector <code>{summary['currentSelector']}</code>; current selector public samples {summary['currentSelectorPublicSampleCount']}; route-pair public samples {summary['routePairPublicSampleCount']}; promotion status <code>{summary['promotionStatus']}</code>.</p>",
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        "  <h2>Route-Critical Offsets</h2>",
        "  <table><thead><tr><th>offset</th><th>label</th><th>expected for 2:0</th><th>public distinct values</th><th>values by selector</th></tr></thead>",
        f"  <tbody>{''.join(critical_rows)}</tbody></table>",
        "  <h2>Semantic Coverage Gap</h2>",
        f"  <p>{html.escape(coverage.get('conclusion', ''))}</p>",
        (
            "  <p>"
            f"known semantic bytes {coverage.get('knownSemanticByteCount', 0)}/"
            f"{coverage.get('expectedSaveSize', EXPECTED_SAVE_SIZE)}; "
            f"unknown bytes {coverage.get('unknownByteCount', 0)}; "
            f"unknown varying bytes {coverage.get('unknownVaryingByteCount', 0)}; "
            f"equipment/status/story offset status <code>{html.escape(coverage.get('equipmentStatusStoryOffsetStatus', '-'))}</code>."
            "</p>"
        ),
        "  <table><thead><tr><th>category</th><th>known bytes</th></tr></thead>",
        f"  <tbody>{''.join(category_rows)}</tbody></table>",
        "  <h3>Unknown Ranges</h3>",
        "  <table><thead><tr><th>range</th><th>size</th><th>varying bytes</th><th>first varying offsets</th></tr></thead>",
        f"  <tbody>{''.join(unknown_rows)}</tbody></table>",
        "  <h3>Equipment Evidence</h3>",
        f"  <p>status <code>{html.escape(equipment.get('status', '-'))}</code>; source {html.escape(equipment.get('source', '-'))}</p>",
        f"  <ul>{equipment_notes}</ul>",
        "  <h2>Closest Public Source/Target Pair</h2>",
        f"  <p>source <code>{html.escape(str(pair.get('sourceSample')))}</code> selector <code>{html.escape(str(pair.get('sourceSelector')))}</code>; target <code>{html.escape(str(pair.get('targetSample')))}</code> selector <code>{html.escape(str(pair.get('targetSelector')))}</code>; differing bytes {html.escape(str(pair.get('differingByteCount')))}; first offsets <code>{html.escape(', '.join(pair.get('firstDifferingOffsetsHex') or []))}</code>.</p>",
        "  <h2>Selector-Distinguishing Offsets</h2>",
        "  <table><thead><tr><th>offset</th><th>values by selector</th></tr></thead>",
        f"  <tbody>{''.join(diff_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_sample_deltas.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "savedata_sample_deltas.md").write_text(markdown(summary), encoding="utf-8")
    (out_dir / "savedata_sample_deltas.html").write_text(html_page(summary), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(load_json(args.out_dir / "save_scene_selectors.json", []))
    write_outputs(summary, args.out_dir)
    print(f"wrote savedata sample deltas -> {args.out_dir / 'savedata_sample_deltas.md'}")


if __name__ == "__main__":
    main()
