#!/usr/bin/env python3
"""Map the current gate offsets through possible selection-buffer base candidates."""
from __future__ import annotations

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

sys.path.insert(0, str(Path(__file__).resolve().parent))

from parse_savedata import (
    CHARACTERS,
    ITEM_FIELDS,
    SAVE_BLOCKS,
    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"
SOURCE = "map1_01a"
TARGET = "map2_02d"
GLOBAL_SELECTION_BUFFER = 0x0059E310
SAVE_RUNTIME_BLOCK_BASE = 0x004576D8
PARTY_SLOT_RUNTIME_BASE = 0x00457750
PARTY_SLOT_STRIDE = 0x00D8
EXPECTED_INDEX_MIN = 0
EXPECTED_INDEX_MAX = 11
FAILED_GATE_BASE_CANDIDATE_GATE_IDS = [
    "save-runtime-gate-byte-route-sample",
    "party-slot-branch-state-index",
    "runtime-pointer-mode-proof",
    "control-path-gate-base-proof",
]
GATE_BASE_CANDIDATE_MISSING_EVIDENCE = [
    "current selector 2:0 save/runtime sample proving save-runtime gate bytes",
    "party-slot byte mapping that yields a valid branch-state index instead of stat bytes",
    "runtime pointer-mode proof selecting the correct context+0xa8 base",
    "control-path proof that the resolved gate base enables current frontier fallthrough",
]
GATE_BASE_CANDIDATE_EVIDENCE_REFS = [
    {
        "path": "out/save_selector_gate_offset_sources.json",
        "description": "current gate offsets and inherited/runtime gate-source classification",
    },
    {
        "path": "out/save_selector_selection_buffer_bases.json",
        "description": "candidate context+0xa8 base modes and static address reference checks",
    },
    {
        "path": "tools/parse_savedata.py",
        "description": "save-block and field-offset schema used for loader-aware byte classification",
    },
]


def hex32(value: int) -> str:
    return f"0x{value:08x}"


def hex16(value: int) -> str:
    return f"0x{value:04x}"


def hex8(value: int) -> str:
    return f"0x{value:02x}"


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


def offset_label(offset: int, start: int, size: int, name: str) -> dict | None:
    if not (start <= offset < start + size):
        return None
    byte_role = "whole-byte"
    if size == 2:
        byte_role = "low-byte" if offset == start else "high-byte"
    elif size == 3:
        byte_role = f"byte-{offset - start}"
    return {
        "field": name,
        "fieldOffset": start,
        "fieldOffsetHex": hex16(start),
        "fieldSize": size,
        "byteRole": byte_role,
    }


def known_save_field(offset: int) -> dict | None:
    basics = [
        (SCENE_SELECTOR_GROUP_OFFSET, 1, "scene selector group"),
        (SCENE_SELECTOR_SLOT_OFFSET, 1, "scene selector slot"),
        (SCENE_POSITION_X_OFFSET, 2, "tile/camera X candidate"),
        (SCENE_POSITION_Y_OFFSET, 2, "tile/camera Y candidate"),
        (0x0008, 3, "money"),
    ]
    for start, size, name in basics:
        label = offset_label(offset, start, size, name)
        if label:
            return label
    for key, korean, start in ITEM_FIELDS:
        label = offset_label(offset, start, 1, f"item count: {korean} ({key})")
        if label:
            return label
    for character in CHARACTERS:
        char_name = character["name"]
        scalar_fields = [
            ("level", character["level"]),
            ("experience current", character["experience"]["current"]),
            ("experience maximum", character["experience"]["maximum"]),
        ]
        for group_name, group in [
            ("HP", character["hp"]),
            ("MP", character["mp"]),
            ("base stat", character["baseStats"]),
            ("enhance stat", character["enhanceStats"]),
        ]:
            scalar_fields.extend((f"{group_name} {name}", field_offset) for name, field_offset in group.items())
        for name, start in scalar_fields:
            label = offset_label(offset, start, 2, f"{char_name} {name}")
            if label:
                return label
        for block_name, block in character["skills"].items():
            base = block["base"]
            slots = len(block["valueBases"])
            label = offset_label(offset, base, slots, f"{char_name} {block_name} skill packed bytes")
            if label:
                return label
    for block in SAVE_BLOCKS:
        start = block["saveOffset"]
        if start <= offset < start + block["size"]:
            return {
                "field": f"unknown within {block['name']} save block",
                "fieldOffset": offset,
                "fieldOffsetHex": hex16(offset),
                "fieldSize": 1,
                "byteRole": "unmapped-byte",
            }
    return None


def runtime_to_save_offset(runtime_address: int) -> dict | None:
    for block in SAVE_BLOCKS:
        runtime_start = block["runtimeVa"]
        runtime_end = runtime_start + block["size"]
        if runtime_start <= runtime_address < runtime_end:
            block_offset = runtime_address - runtime_start
            save_offset = block["saveOffset"] + block_offset
            return {
                "saveOffset": save_offset,
                "saveOffsetHex": hex16(save_offset),
                "runtimeSaveBlock": block["name"],
                "runtimeBlockOffsetHex": hex16(block_offset),
            }
    return None


def gate_offsets(gate_sources: dict) -> list[dict]:
    rows = []
    for row in gate_sources.get("gates") or []:
        offset = row.get("selectionBufferOffset")
        if not isinstance(offset, int):
            offset_hex = row.get("selectionBufferOffsetHex")
            if not isinstance(offset_hex, str):
                continue
            offset = int(offset_hex, 16)
        rows.append({
            "gateVaHex": row.get("gateVaHex"),
            "offset": offset,
            "offsetHex": hex8(offset),
        })
    return rows


def address_ref_counts(selection_buffer_bases: dict) -> dict[tuple[int, int], int]:
    result = {}
    for row in selection_buffer_bases.get("gateOffsetAddressChecks") or []:
        address = row.get("address")
        offset = row.get("offset")
        if isinstance(address, int) and isinstance(offset, int):
            result[(address, offset)] = row.get("directDwordRefCount", 0)
            continue
        address_hex = row.get("addressHex")
        offset_hex = row.get("offsetHex")
        if isinstance(address_hex, str) and address_hex != "-" and isinstance(offset_hex, str):
            result[(int(address_hex, 16), int(offset_hex, 16))] = row.get("directDwordRefCount", 0)
    return result


def save_offset_row(kind: str, runtime_base: int, save_backed: bool, gate: dict, slot: int | None = None) -> dict:
    runtime_address = runtime_base + gate["offset"]
    mapped = runtime_to_save_offset(runtime_address) if save_backed else None
    save_offset = (mapped or {}).get("saveOffset")
    field = known_save_field(save_offset) if save_offset is not None else None
    return {
        "baseKind": kind,
        "slot": slot,
        "gateVaHex": gate["gateVaHex"],
        "gateOffset": gate["offset"],
        "gateOffsetHex": gate["offsetHex"],
        "runtimeBaseHex": hex32(runtime_base),
        "runtimeAddressHex": hex32(runtime_address),
        "saveBacked": save_backed,
        "runtimeSaveBlock": (mapped or {}).get("runtimeSaveBlock"),
        "runtimeBlockOffsetHex": (mapped or {}).get("runtimeBlockOffsetHex"),
        "saveOffset": save_offset,
        "saveOffsetHex": (mapped or {}).get("saveOffsetHex"),
        "knownField": field,
        "expectedIndexRange": f"{EXPECTED_INDEX_MIN}..{EXPECTED_INDEX_MAX}",
    }


def classify_base_row(row: dict) -> str:
    if row["baseKind"] == "party slot base":
        field = (row.get("knownField") or {}).get("field") or ""
        if "MP" in field or "stat" in field or "level" in field or "experience" in field:
            return "implausible-stat-byte-index"
        return "party-slot-byte-needs-runtime-proof"
    if row["baseKind"] == "save/runtime block base":
        return "save-backed-unmapped-byte"
    if row["baseKind"] == "global selection buffer":
        return "unresolved-global-buffer"
    return "unresolved-pointer-table-base"


def build_summary(gate_sources: dict, selection_buffer_bases: dict) -> dict:
    gates = gate_offsets(gate_sources)
    refs = address_ref_counts(selection_buffer_bases)
    rows = []
    for gate in gates:
        rows.append(save_offset_row("global selection buffer", GLOBAL_SELECTION_BUFFER, False, gate))
        rows.append(save_offset_row("save/runtime block base", SAVE_RUNTIME_BLOCK_BASE, True, gate))
        for slot in range(3):
            rows.append(
                save_offset_row(
                    "party slot base",
                    PARTY_SLOT_RUNTIME_BASE + slot * PARTY_SLOT_STRIDE,
                    True,
                    gate,
                    slot=slot,
                )
            )
        rows.append({
            "baseKind": "runtime object pointer table",
            "slot": None,
            "gateVaHex": gate["gateVaHex"],
            "gateOffset": gate["offset"],
            "gateOffsetHex": gate["offsetHex"],
            "runtimeBaseHex": "dword[0x0059db30+i*4] or dword[0x0059dd70+i*4]",
            "runtimeAddressHex": "-",
            "saveBacked": False,
            "saveBaseHex": None,
            "saveOffset": None,
            "saveOffsetHex": None,
            "knownField": None,
            "expectedIndexRange": f"{EXPECTED_INDEX_MIN}..{EXPECTED_INDEX_MAX}",
        })
    for row in rows:
        if row.get("runtimeAddressHex", "-") != "-":
            runtime_address = int(row["runtimeAddressHex"], 16)
            row["directDwordRefCount"] = refs.get((runtime_address, row["gateOffset"]), 0)
        else:
            row["directDwordRefCount"] = 0
        row["classification"] = classify_base_row(row)
    party_stat_rows = [
        row for row in rows
        if row["classification"] == "implausible-stat-byte-index"
    ]
    direct_ref_rows = [row for row in rows if row["directDwordRefCount"]]
    conclusion = (
        "Mapping the gate offsets through known context+0xa8 base candidates weakens the party-slot-base path: "
        "for party slots, 0xe8/0xea land on later character MP/stat bytes such as Rinshan and Smashu MP fields, "
        "not on documented selector-index bytes. Since opcode 0x11 expects a branch-state slot index in the "
        "0..11 range, those stat-byte mappings are not promotion evidence. Loader-aware save/runtime mapping also "
        "matters here: 0x004576d8+0xe8/0xea falls into the second loaded save block after the six-byte runtime gap, "
        "so those bytes map to save offsets 0x00e2/0x00e4 rather than linear offsets 0x00e8/0x00ea. The viable "
        "sources remain an unresolved global/save-runtime buffer or object pointer table mode, so control-flow proof "
        "is still blocked."
    )
    return {
        "source": SOURCE,
        "target": TARGET,
        "gateOffsetsHex": [gate["offsetHex"] for gate in gates],
        "expectedIndexRange": [EXPECTED_INDEX_MIN, EXPECTED_INDEX_MAX],
        "partySlotStrideHex": hex16(PARTY_SLOT_STRIDE),
        "candidateCount": len(rows),
        "partySlotStatByteCandidateCount": len(party_stat_rows),
        "directRefCandidateCount": len(direct_ref_rows),
        "runtimePointerModeStillRequired": True,
        "proofFound": False,
        "gateBaseCandidateProofFound": False,
        "failedGateBaseCandidateGateIds": FAILED_GATE_BASE_CANDIDATE_GATE_IDS,
        "missingEvidence": GATE_BASE_CANDIDATE_MISSING_EVIDENCE,
        "evidenceRefs": GATE_BASE_CANDIDATE_EVIDENCE_REFS,
        "evidenceRefCount": len(GATE_BASE_CANDIDATE_EVIDENCE_REFS),
        "promotionStatus": "blocked",
        "baseCandidates": rows,
        "conclusion": conclusion,
    }


def field_text(row: dict) -> str:
    field = row.get("knownField")
    if not field:
        return "-"
    return f"{field['field']} ({field['byteRole']})"


def markdown(summary: dict) -> str:
    lines = [
        "# Save Selector Gate Base Candidates",
        "",
        f"- route: {summary['source']} -> {summary['target']}",
        f"- gate offsets: {', '.join(f'`{item}`' for item in summary['gateOffsetsHex'])}",
        f"- expected selection index range: {summary['expectedIndexRange'][0]}..{summary['expectedIndexRange'][1]}",
        f"- party-slot stat-byte candidates: {summary['partySlotStatByteCandidateCount']}",
        f"- direct ref candidates: {summary['directRefCandidateCount']}",
        f"- runtime pointer mode still required: {summary['runtimePointerModeStillRequired']}",
        f"- proofFound: `{summary['proofFound']}`",
        f"- gateBaseCandidateProofFound: `{summary['gateBaseCandidateProofFound']}`",
        f"- promotion status: `{summary['promotionStatus']}`",
        "",
        summary["conclusion"],
        "",
        "## Failed Gates",
        "",
    ]
    lines.extend(f"- `{item}`" for item in summary["failedGateBaseCandidateGateIds"])
    lines.extend([
        "",
        "## Missing Evidence",
        "",
    ])
    lines.extend(f"- {item}" for item in summary["missingEvidence"])
    lines.extend([
        "",
        "## Evidence Refs",
        "",
    ])
    lines.extend(
        f"- `{row['path']}`: {row['description']}"
        for row in summary["evidenceRefs"]
    )
    lines.extend([
        "",
        "| base | slot | gate | runtime address | save block | save offset | known save field | direct refs | classification |",
        "| --- | ---: | --- | --- | --- | --- | --- | ---: | --- |",
    ])
    for row in summary["baseCandidates"]:
        slot = "-" if row.get("slot") is None else str(row["slot"])
        lines.append(
            f"| {row['baseKind']} | {slot} | `{row['gateOffsetHex']}` | "
            f"`{row['runtimeAddressHex']}` | "
            f"{row.get('runtimeSaveBlock') or '-'} | "
            f"{'`' + row['saveOffsetHex'] + '`' if row.get('saveOffsetHex') else '-'} | "
            f"{field_text(row)} | {row['directDwordRefCount']} | {row['classification']} |"
        )
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    failed_gates = "".join(
        f"<li><code>{html.escape(item)}</code></li>"
        for item in summary["failedGateBaseCandidateGateIds"]
    )
    missing_evidence = "".join(
        f"<li>{html.escape(item)}</li>"
        for item in summary["missingEvidence"]
    )
    evidence_refs = "".join(
        f"<li><code>{html.escape(row['path'])}</code>: {html.escape(row['description'])}</li>"
        for row in summary["evidenceRefs"]
    )
    rows = []
    for row in summary["baseCandidates"]:
        slot = "-" if row.get("slot") is None else str(row["slot"])
        save_offset = f"<code>{html.escape(row['saveOffsetHex'])}</code>" if row.get("saveOffsetHex") else "-"
        rows.append(
            "<tr>"
            f"<td>{html.escape(row['baseKind'])}</td>"
            f"<td>{slot}</td>"
            f"<td><code>{html.escape(row['gateOffsetHex'])}</code></td>"
            f"<td><code>{html.escape(row['runtimeAddressHex'])}</code></td>"
            f"<td>{html.escape(row.get('runtimeSaveBlock') or '-')}</td>"
            f"<td>{save_offset}</td>"
            f"<td>{html.escape(field_text(row))}</td>"
            f"<td>{row['directDwordRefCount']}</td>"
            f"<td>{html.escape(row['classification'])}</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>Save Selector Gate Base Candidates</title>",
        "  <style>",
        "    :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #101010; color: #eee; }",
        "    body { margin: 0; padding: 24px; }",
        "    h1 { margin: 0 0 8px; font-size: 24px; }",
        "    p { max-width: 1120px; color: #bbb; line-height: 1.45; }",
        "    table { width: 100%; border-collapse: collapse; margin: 12px 0 20px; font-size: 13px; }",
        "    th, td { border-bottom: 1px solid #303030; padding: 7px 8px; text-align: left; vertical-align: top; }",
        "    th { position: sticky; top: 0; background: #181818; color: #ddd; }",
        "    code { color: #d7f0ff; }",
        "  </style>",
        "</head>",
        "<body>",
        "  <h1>Save Selector Gate Base Candidates</h1>",
        f"  <p>route <code>{summary['source']} -&gt; {summary['target']}</code>; "
        f"gate offsets {html.escape(', '.join(summary['gateOffsetsHex']))}; "
        f"expected selection index range {summary['expectedIndexRange'][0]}..{summary['expectedIndexRange'][1]}; "
        f"proofFound <code>{summary['proofFound']}</code>; promotion status <code>{summary['promotionStatus']}</code></p>",
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        "  <h2>Failed Gates</h2>",
        f"  <ul>{failed_gates}</ul>",
        "  <h2>Missing Evidence</h2>",
        f"  <ul>{missing_evidence}</ul>",
        "  <h2>Evidence Refs</h2>",
        f"  <ul>{evidence_refs}</ul>",
        "  <table><thead><tr><th>base</th><th>slot</th><th>gate</th><th>runtime address</th><th>save block</th><th>save offset</th><th>known save field</th><th>direct refs</th><th>classification</th></tr></thead><tbody>",
        *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 / "save_selector_gate_base_candidates.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "save_selector_gate_base_candidates.html").write_text(html_page(summary), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--gate-offset-sources", type=Path, default=OUT / "save_selector_gate_offset_sources.json")
    parser.add_argument("--selection-buffer-bases", type=Path, default=OUT / "save_selector_selection_buffer_bases.json")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(
        load_json(args.gate_offset_sources, {}),
        load_json(args.selection_buffer_bases, {}),
    )
    write_outputs(summary, args.out_dir)
    print(f"wrote save selector gate base candidates -> {args.out_dir / 'save_selector_gate_base_candidates.html'}")


if __name__ == "__main__":
    main()
