#!/usr/bin/env python3
"""Summarize targeted savedata delta capture readiness."""
from __future__ import annotations

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

from summarize_savedata_sample_deltas import EXPECTED_SAVE_SIZE, SAMPLE_FILES


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
MANIFEST = ROOT / "data" / "targeted_savedata" / "manifest.json"


TARGETED_DELTA_PLAN = [
    {
        "id": "inventory-item-count-delta",
        "capture": "same save before/after one item count changes by exactly 1",
        "ranges": [(0x006D, 0x006F), (0x0020, 0x0021)],
        "expected": "positive",
        "links": ["system_review.html#systemOffsetPairDecodePanel", "game.html?map=map4_08n&startTile=18%2C42&menu=shop"],
        "boundary": "count changes do not prove original item formula or writer semantics",
    },
    {
        "id": "equipment-ownership-delta",
        "capture": "same save before/after equipment is gained or removed",
        "ranges": [(0x0022, 0x0038)],
        "expected": "positive",
        "links": ["system_review.html#systemOffsetEquipmentNameCoveragePanel", "menu_review.html?panel=equipment"],
        "boundary": "id/name matches remain candidates until ownership writer proof is found",
    },
    {
        "id": "equip-unequip-slot-delta",
        "capture": "same save before/after equip or unequip while inventory ownership is unchanged",
        "ranges": [(0x005C, 0x0067), (0x0022, 0x0038)],
        "expected": "positive",
        "links": ["game.html?map=map2_02d&startTile=11%2C12&publicSave=flack3r-savedat2&menu=equipment", "system_review.html#systemOffsetValueMatrixPanel"],
        "boundary": "browser equipment smoke is not original equipped-slot offset proof",
    },
    {
        "id": "status-story-negative-control",
        "capture": "item/equipment-only save pair where status/story candidate ranges should stay unchanged",
        "ranges": [(0x00B2, 0x00B4), (0x0313, 0x032E)],
        "expected": "negative",
        "links": ["system_review.html#systemOffsetClusterPanel", "menu_review.html#menuRuntimeBoundaryPanel"],
        "boundary": "a stable negative control does not identify original status/story writers",
    },
    {
        "id": "route-selector-negative-control",
        "capture": "item/equipment-only save pair where route selector/control candidates should stay unchanged",
        "ranges": [(0x0000, 0x0002), (0x000C, 0x000D), (0x000E, 0x000F)],
        "expected": "negative",
        "links": ["system_review.html#systemOffsetRouteControlMatrixPanel", "completion_review.html"],
        "boundary": "selector 2:0 captured savedata and selected-root runtime proof are still required",
    },
]


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 bytes_hex(data: bytes) -> str:
    return " ".join(f"{value:02x}" for value in data)


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


def rel_path(path: Path) -> str:
    try:
        return str(path.relative_to(ROOT))
    except ValueError:
        return str(path)


def resolve_manifest_path(value: str) -> Path:
    path = Path(value)
    return path if path.is_absolute() else ROOT / path


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


def target_offsets(ranges: list[tuple[int, int]]) -> set[int]:
    return {offset for start, end in ranges for offset in range(start, end)}


def offsets_to_ranges(offsets: list[int]) -> list[dict]:
    if not offsets:
        return []
    ranges: list[tuple[int, int]] = []
    start = 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 public_samples() -> list[dict]:
    rows = []
    for sample_id, path in SAMPLE_FILES:
        data = path.read_bytes()
        rows.append({
            "id": sample_id,
            "path": rel_path(path),
            "selector": selector_from_data(data),
            "data": data,
        })
    return rows


def range_values_by_sample(samples: list[dict], ranges: list[tuple[int, int]]) -> list[dict]:
    rows = []
    for start, end in ranges:
        values_by_selector: dict[str, set[str]] = {}
        values_by_sample = []
        for sample in samples:
            data = sample["data"]
            value = bytes(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"],
                "selector": sample["selector"],
                "path": sample["path"],
                "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({
            **range_record(start, end),
            "valuesBySelectorHex": selector_values,
            "valuesBySample": values_by_sample,
            "distinctValueCount": len(distinct_values),
            "allPublicSamplesSame": len(distinct_values) == 1,
        })
    return rows


def compare_pair(before_path: Path, after_path: Path, ranges: list[tuple[int, int]]) -> dict:
    before = before_path.read_bytes()
    after = after_path.read_bytes()
    byte_count = min(len(before), len(after))
    changed = [offset for offset in range(byte_count) if before[offset] != after[offset]]
    target = target_offsets(ranges)
    target_changed = [offset for offset in changed if offset in target]
    outside_changed = [offset for offset in changed if offset not in target]
    return {
        "beforePath": rel_path(before_path),
        "afterPath": rel_path(after_path),
        "beforeSize": len(before),
        "afterSize": len(after),
        "validSize": len(before) == EXPECTED_SAVE_SIZE and len(after) == EXPECTED_SAVE_SIZE,
        "beforeSelector": selector_from_data(before),
        "afterSelector": selector_from_data(after),
        "changedByteCount": len(changed),
        "targetChangedByteCount": len(target_changed),
        "outsideTargetChangedByteCount": len(outside_changed),
        "targetChangedRanges": offsets_to_ranges(target_changed),
        "firstChangedOffsetsHex": [f"0x{offset:04x}" for offset in changed[:32]],
        "firstTargetChangedOffsetsHex": [f"0x{offset:04x}" for offset in target_changed[:32]],
        "firstOutsideTargetChangedOffsetsHex": [f"0x{offset:04x}" for offset in outside_changed[:32]],
    }


def capture_pairs_for_row(manifest: dict, row_id: str, ranges: list[tuple[int, int]]) -> list[dict]:
    captures = []
    for raw in manifest.get("captures") or []:
        if raw.get("targetId") != row_id:
            continue
        before_value = raw.get("before")
        after_value = raw.get("after")
        record = {
            "id": raw.get("id") or row_id,
            "targetId": row_id,
            "note": raw.get("note") or "",
            "before": before_value or "",
            "after": after_value or "",
            "status": "invalid",
        }
        if not before_value or not after_value:
            record["reason"] = "before/after missing"
            captures.append(record)
            continue
        before_path = resolve_manifest_path(before_value)
        after_path = resolve_manifest_path(after_value)
        if not before_path.exists() or not after_path.exists():
            record["reason"] = "before/after file missing"
            captures.append(record)
            continue
        diff = compare_pair(before_path, after_path, ranges)
        record.update(diff)
        record["status"] = "captured" if diff["validSize"] else "invalid-size"
        captures.append(record)
    return captures


def candidate_context(plan_id: str, triage_clusters: dict) -> dict:
    if plan_id in {"equipment-ownership-delta", "equip-unequip-slot-delta", "inventory-item-count-delta"}:
        rows = triage_clusters.get("inventoryEquipmentPairDecodeRows") or []
        return {
            "pairDecodeRowCount": len(rows),
            "oneBasedEquipmentHitCount": triage_clusters.get("inventoryEquipmentPairOneBasedHitCount", 0),
            "oneBasedItemHitCount": triage_clusters.get("inventoryEquipmentPairOneBasedItemHitCount", 0),
            "candidateRanges": [row.get("rangeHex") for row in rows],
        }
    if plan_id == "route-selector-negative-control":
        rows = triage_clusters.get("routeControlValueRows") or []
        return {
            "routeControlRowCount": len(rows),
            "candidateRanges": [row.get("rangeHex") for row in rows],
            "requiresSelector20Save": triage_clusters.get("requiresSelector20Save") is True,
        }
    return {
        "bucketRows": [
            row for row in triage_clusters.get("bucketRows") or []
            if row.get("bucket") in {"skill-status-candidate", "character-status-candidate", "story-event-tail-candidate"}
        ],
    }


def status_for_row(expected: str, captures: list[dict]) -> tuple[str, str]:
    valid = [row for row in captures if row.get("status") == "captured"]
    if not captures:
        return "missing-capture", "no before/after save pair supplied"
    if not valid:
        return "invalid-capture", "capture entries exist but no valid 1274-byte before/after pair is available"
    target_changed = sum(int(row.get("targetChangedByteCount", 0)) for row in valid)
    if expected == "negative":
        if target_changed == 0:
            return "captured-control", "valid negative-control pair has no changes in target ranges"
        return "changed-control", "negative-control target ranges changed and need manual review"
    if target_changed > 0:
        return "captured-candidate", "valid pair changed candidate ranges; writer semantics still need proof"
    return "weak-capture", "valid pair exists but target ranges did not change"


def manifest_example() -> dict:
    return {
        "captures": [
            {
                "id": "herb-plus-one-001",
                "targetId": "inventory-item-count-delta",
                "before": "data/targeted_savedata/herb_before.dat",
                "after": "data/targeted_savedata/herb_after.dat",
                "note": "same location, only herb count changed by +1",
            }
        ]
    }


def build_summary(
    triage_clusters: dict | None = None,
    manifest_path: Path = MANIFEST,
) -> dict:
    triage_clusters = triage_clusters if triage_clusters is not None else load_json(OUT / "savedata_offset_triage_clusters.json", {})
    manifest = load_json(manifest_path, {})
    samples = public_samples()
    rows = []
    for plan in TARGETED_DELTA_PLAN:
        ranges = plan["ranges"]
        captures = capture_pairs_for_row(manifest, plan["id"], ranges) if manifest else []
        status, reason = status_for_row(plan["expected"], captures)
        range_values = range_values_by_sample(samples, ranges)
        rows.append({
            "id": plan["id"],
            "status": status,
            "statusReason": reason,
            "expected": plan["expected"],
            "requiredCapture": plan["capture"],
            "targetRanges": [range_record(start, end) for start, end in ranges],
            "publicValueRows": range_values,
            "publicDistinctRangeCount": sum(1 for row in range_values if not row["allPublicSamplesSame"]),
            "capturePairs": captures,
            "validCapturePairCount": sum(1 for row in captures if row.get("status") == "captured"),
            "candidateContext": candidate_context(plan["id"], triage_clusters),
            "links": plan["links"],
            "promotionBoundary": plan["boundary"],
        })
    status_counts: dict[str, int] = {}
    for row in rows:
        status_counts[row["status"]] = status_counts.get(row["status"], 0) + 1
    provided = sum(len(row["capturePairs"]) for row in rows)
    valid = sum(row["validCapturePairCount"] for row in rows)
    missing = sum(1 for row in rows if row["status"] == "missing-capture")
    return {
        "source": "targeted savedata delta intake",
        "triageClusterSource": "out/savedata_offset_triage_clusters.json",
        "manifestPath": rel_path(manifest_path),
        "manifestFound": manifest_path.exists(),
        "manifestExample": manifest_example(),
        "publicSampleCount": len(samples),
        "planRowCount": len(rows),
        "requiredCapturePairCount": len(rows),
        "providedCapturePairCount": provided,
        "validCapturePairCount": valid,
        "missingCapturePairCount": missing,
        "statusCounts": status_counts,
        "rows": rows,
        "nonPromoting": True,
        "completionStatus": "missing-targeted-saves" if missing else "captured-needs-review",
        "conclusion": (
            "The targeted savedata delta workflow is ready, but no changed save pairs are currently supplied. "
            "Public sample values are comparison context only; they do not prove equipment ownership, equipped-slot, "
            "status/story, route selector, or original writer semantics."
        ),
    }


def markdown(summary: dict) -> str:
    lines = [
        "# Savedata Targeted Delta Intake",
        "",
        f"- plan rows: {summary['planRowCount']}",
        f"- public samples: {summary['publicSampleCount']}",
        f"- manifest: `{summary['manifestPath']}` found `{summary['manifestFound']}`",
        f"- provided capture pairs: {summary['providedCapturePairCount']}",
        f"- valid capture pairs: {summary['validCapturePairCount']}",
        f"- missing capture rows: {summary['missingCapturePairCount']}",
        f"- completion status: `{summary['completionStatus']}`",
        f"- non-promoting: {summary['nonPromoting']}",
        "",
        summary["conclusion"],
        "",
        "## Required Captures",
        "",
        "| id | status | target ranges | public distinct ranges | required capture | boundary |",
        "| --- | --- | --- | ---: | --- | --- |",
    ]
    for row in summary["rows"]:
        ranges = ", ".join(f"`{item['rangeHex']}`" for item in row["targetRanges"])
        lines.append(
            f"| `{row['id']}` | `{row['status']}` | {ranges} | {row['publicDistinctRangeCount']} | "
            f"{row['requiredCapture']} | {row['promotionBoundary']} |"
        )
    lines.extend([
        "",
        "## Manifest Example",
        "",
        "```json",
        json.dumps(summary["manifestExample"], ensure_ascii=False, indent=2),
        "```",
        "",
        "## Public Sample Context",
        "",
        "| id | range | distinct values | values by selector |",
        "| --- | --- | ---: | --- |",
    ])
    for row in summary["rows"]:
        for value_row in row["publicValueRows"]:
            selector_values = "; ".join(
                f"{selector}: {', '.join(f'`{value}`' for value in values)}"
                for selector, values in value_row["valuesBySelectorHex"].items()
            )
            lines.append(
                f"| `{row['id']}` | `{value_row['rangeHex']}` | {value_row['distinctValueCount']} | {selector_values} |"
            )
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    rows_html = []
    for row in summary["rows"]:
        ranges = ", ".join(item["rangeHex"] for item in row["targetRanges"])
        links = " ".join(f'<a href="{html.escape(link)}">{html.escape(link)}</a>' for link in row["links"])
        rows_html.append(
            "<tr>"
            f"<td><code>{html.escape(row['id'])}</code></td>"
            f"<td><code>{html.escape(row['status'])}</code><br><small>{html.escape(row['statusReason'])}</small></td>"
            f"<td><code>{html.escape(ranges)}</code></td>"
            f"<td>{row['publicDistinctRangeCount']}</td>"
            f"<td>{html.escape(row['requiredCapture'])}</td>"
            f"<td>{links}</td>"
            f"<td>{html.escape(row['promotionBoundary'])}</td>"
            "</tr>"
        )
    public_rows = []
    for row in summary["rows"]:
        for value_row in row["publicValueRows"]:
            selector_values = "; ".join(
                f"{selector}: {', '.join(values)}"
                for selector, values in value_row["valuesBySelectorHex"].items()
            )
            public_rows.append(
                "<tr>"
                f"<td><code>{html.escape(row['id'])}</code></td>"
                f"<td><code>{html.escape(value_row['rangeHex'])}</code></td>"
                f"<td>{value_row['distinctValueCount']}</td>"
                f"<td><code>{html.escape(selector_values)}</code></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 Targeted Delta Intake</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; }",
        "    small { color: #aaa; }",
        "  </style>",
        "</head>",
        "<body>",
        "  <h1>Savedata Targeted Delta Intake</h1>",
        (
            "  <p>"
            f"plan rows {summary['planRowCount']}; public samples {summary['publicSampleCount']}; "
            f"manifest <code>{html.escape(summary['manifestPath'])}</code> found <code>{summary['manifestFound']}</code>; "
            f"provided captures {summary['providedCapturePairCount']}; valid captures {summary['validCapturePairCount']}; "
            f"missing rows {summary['missingCapturePairCount']}; completion <code>{html.escape(summary['completionStatus'])}</code>; "
            f"non-promoting <code>{summary['nonPromoting']}</code>."
            "</p>"
        ),
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        "  <h2>Required Captures</h2>",
        "  <table><thead><tr><th>id</th><th>status</th><th>target ranges</th><th>public distinct ranges</th><th>required capture</th><th>links</th><th>boundary</th></tr></thead>",
        f"  <tbody>{''.join(rows_html)}</tbody></table>",
        "  <h2>Public Sample Context</h2>",
        "  <table><thead><tr><th>id</th><th>range</th><th>distinct values</th><th>values by selector</th></tr></thead>",
        f"  <tbody>{''.join(public_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_targeted_delta_intake.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "savedata_targeted_delta_intake.md").write_text(markdown(summary), encoding="utf-8")
    (out_dir / "savedata_targeted_delta_intake.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)
    parser.add_argument("--manifest", type=Path, default=MANIFEST)
    args = parser.parse_args()
    summary = build_summary(
        load_json(args.out_dir / "savedata_offset_triage_clusters.json", {}),
        args.manifest,
    )
    write_outputs(summary, args.out_dir)
    print(f"wrote savedata targeted delta intake -> {args.out_dir / 'savedata_targeted_delta_intake.md'}")


if __name__ == "__main__":
    main()
