#!/usr/bin/env python3
"""Summarize the context+0xf2 sources behind opcode 0x20 object-base rows."""
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 probe_exe_scene_tables import offset_to_va, read_sections, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
SOURCE = "map1_01a"
TARGET = "map2_02d"
CONTEXT_F2_OFFSET = 0xF2
RUNTIME_OBJECT_TABLE = 0x0059DB30
DIAGNOSTIC_ACTIVE_ORDER_POLL = "runtime_selected_pointer_patched_public_selector_2_0_active_order_poll.json"
IMAGE_BASE = 0x00400000
IMAGE_SIZE = 0x001BE000
ACTIVE_ORDER_WATCH_NAMES = [
    "activeOrderCount",
    "activeOrder0",
    "activeOrder1",
    "activeOrder2",
    "activeSlot0Descriptor",
    "activeSlot1Descriptor",
    "activeSlot2Descriptor",
    "runtimeSlotBaseTable0",
    "runtimeSlotBaseTable1",
    "runtimeSlotBaseTable2",
    "runtimeObjectTable0",
    "runtimeObjectTable1",
    "runtimeObjectTable2",
]

FAILED_OPCODE20_CONTEXT_F2_GATE_IDS = [
    "normal-route-runtime-object-table-state",
    "context-f2-current-frontier-value",
    "opcode42-object-pointer-trace",
    "strict-source-hotspot",
]
OPCODE20_CONTEXT_F2_MISSING_EVIDENCE = [
    "non-diagnostic/normal-route selector 2:0 active order/count with live 0x0059db30 object-table contents",
    "current frontier context+0xf2 byte or equivalent runtime object selector",
    "runtime trace of opcode 0x42 reader 0x00406470 resolving the current route object pointer",
    "strict map1_01a source hotspot or equivalent route trigger",
]
OPCODE20_CONTEXT_F2_EVIDENCE_REFS = [
    {"path": "Hwanse2.exe", "description": "static context+0xf2 read/write and object-table reader scan"},
    {"path": "out/save_selector_opcode20_object_base_candidates.json", "description": "opcode 0x20 object-base candidates that depend on context+0xf2"},
    {"path": "out/save_selector_opcode20_order_space.json", "description": "current selector sample coverage and active-order insufficiency"},
    {"path": "out/runtime_selected_pointer_patched_public_selector_2_0_active_order_poll.json", "description": "diagnostic-only active order and object-table snapshot"},
]


PATTERNS = [
    {
        "name": "read_al_from_eax_f2",
        "bytes": bytes.fromhex("8a80f2000000"),
        "access": "read",
        "instruction": "mov al, byte [eax+0xf2]",
    },
    {
        "name": "read_cl_from_eax_f2",
        "bytes": bytes.fromhex("8a88f2000000"),
        "access": "read",
        "instruction": "mov cl, byte [eax+0xf2]",
    },
    {
        "name": "read_dl_from_ecx_f2",
        "bytes": bytes.fromhex("8a91f2000000"),
        "access": "read",
        "instruction": "mov dl, byte [ecx+0xf2]",
    },
    {
        "name": "write_al_to_ecx_f2",
        "bytes": bytes.fromhex("8881f2000000"),
        "access": "write",
        "instruction": "mov byte [ecx+0xf2], al",
    },
    {
        "name": "write_imm_to_eax_f2",
        "bytes": bytes.fromhex("c680f2000000"),
        "access": "write",
        "instruction": "mov byte [eax+0xf2], imm8",
    },
]

OBJECT_TABLE_BYTES = RUNTIME_OBJECT_TABLE.to_bytes(4, "little")
COPY_WRITE_BYTES = bytes.fromhex("8881f2000000")


KNOWN_EVIDENCE = {
    0x0040C00E: {
        "role": "active object initializer",
        "sourceExpression": "loop/local object index",
        "effect": "sets context+0xf2 to the table slot and stores the context in 0x0059db30[index]",
        "nearbyEvidenceVaHex": "0x0040c048",
    },
    0x0040C24F: {
        "role": "secondary object initializer",
        "sourceExpression": "loop/local object index + 3",
        "effect": "sets context+0xf2 to an offset slot and stores the context in 0x0059db3c[index]",
        "nearbyEvidenceVaHex": "0x0040c28b",
    },
    0x00406470: {
        "role": "opcode 0x42 context+0xf2 object-base reader",
        "sourceExpression": "context+0xf2",
        "effect": "loads dword[0x0059db30 + context[0xf2]*4] for context+0xa8",
        "nearbyEvidenceVaHex": "0x00406480",
    },
    0x0041D90F: {
        "role": "alternate context+0xf2 object-base reader",
        "sourceExpression": "context+0xf2",
        "effect": "loads dword[0x0059db30 + context[0xf2]*4] for context+0xa8",
        "nearbyEvidenceVaHex": "0x0041d91f",
    },
    0x00433430: {
        "role": "object field materializer",
        "sourceExpression": "linked context+0xf2",
        "effect": "copies linked context+0xf2 into object+0x61",
        "nearbyEvidenceVaHex": "0x00433439",
    },
}


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


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


def text_sections(sections: list[dict]) -> list[dict]:
    return [section for section in sections if section.get("name") == ".text"]


def section_bytes(exe: bytes, section: dict) -> bytes:
    raw = int(section["raw"])
    raw_size = int(section["raw_size"])
    return exe[raw : raw + raw_size]


def nearby_bytes(exe: bytes, sections: list[dict], va: int, before: int = 24, after: int = 48) -> bytes:
    offset = va_to_offset(sections, va)
    if offset is None:
        return b""
    start = max(0, offset - before)
    end = min(len(exe), offset + after)
    return exe[start:end]


def classify_ref(exe: bytes, sections: list[dict], row: dict) -> dict:
    va = int(row["va"])
    access = row["access"]
    window_after = nearby_bytes(exe, sections, va, before=0, after=48)
    window_before = nearby_bytes(exe, sections, va, before=24, after=0)
    known = KNOWN_EVIDENCE.get(va, {})
    uses_object_table = access == "read" and OBJECT_TABLE_BYTES in window_after
    copies_f2_to_child = access == "read" and COPY_WRITE_BYTES in window_after[:28]
    copied_from_parent = access == "write" and any(
        pattern["access"] == "read" and pattern["bytes"] in window_before
        for pattern in PATTERNS
    )
    direct_initializer = va in {0x0040C00E, 0x0040C24F}
    constant_write = row["pattern"] == "write_imm_to_eax_f2"
    return {
        **row,
        "role": known.get("role"),
        "sourceExpression": known.get("sourceExpression"),
        "effect": known.get("effect"),
        "nearbyEvidenceVaHex": known.get("nearbyEvidenceVaHex"),
        "usesRuntimeObjectTable": uses_object_table,
        "copiesF2ToChildContext": copies_f2_to_child,
        "copiedFromParentContext": copied_from_parent,
        "directInitializer": direct_initializer,
        "constantWrite": constant_write,
    }


def scan_context_f2_refs(exe: bytes) -> list[dict]:
    sections = read_sections(exe)
    rows = []
    for section in text_sections(sections):
        data = section_bytes(exe, section)
        base_va = int(section["va"])
        for pattern in PATTERNS:
            start = 0
            while True:
                index = data.find(pattern["bytes"], start)
                if index < 0:
                    break
                va = base_va + index
                file_offset = va_to_offset(sections, va)
                if file_offset is not None and offset_to_va(sections, file_offset) == va:
                    rows.append({
                        "va": va,
                        "vaHex": hex32(va),
                        "fileOffset": file_offset,
                        "pattern": pattern["name"],
                        "access": pattern["access"],
                        "instruction": pattern["instruction"],
                    })
                start = index + 1
    return [
        classify_ref(exe, sections, row)
        for row in sorted(rows, key=lambda item: int(item["va"]))
    ]


def count_where(rows: list[dict], key: str) -> int:
    return sum(1 for row in rows if row.get(key) is True)


def histogram(rows: list[dict], key: str) -> list[dict]:
    counts: dict[str, int] = {}
    for row in rows:
        value = str(row.get(key) or "none")
        counts[value] = counts.get(value, 0) + 1
    return [
        {"value": value, "count": count}
        for value, count in sorted(counts.items())
    ]


def access_histogram(rows: list[dict]) -> dict[str, int]:
    counts: dict[str, int] = {}
    for row in rows:
        access = str(row.get("access") or "unknown")
        counts[access] = counts.get(access, 0) + 1
    return counts


def aggregate_watch_values_from_rows(rows: list[dict]) -> dict[str, list[dict[str, Any]]]:
    aggregate: dict[str, dict[str, int]] = {}
    for row in rows:
        for name, values in (row.get("uniqueWatchValues") or {}).items():
            target = aggregate.setdefault(name, {})
            for value_row in values or []:
                value_hex = value_row.get("valueHex")
                if value_hex is None:
                    continue
                target[value_hex] = target.get(value_hex, 0) + int(value_row.get("count") or 0)
    return {
        name: [
            {"valueHex": value_hex, "count": count}
            for value_hex, count in sorted(value_rows.items(), key=lambda item: (-item[1], item[0]))
        ]
        for name, value_rows in sorted(aggregate.items())
    }


def single_watch_value(watch_values: dict, sample_count: int, name: str) -> dict | None:
    rows = watch_values.get(name) or []
    if len(rows) != 1:
        return None
    if rows[0].get("count") != sample_count:
        return None
    return rows[0]


def watch_value_hex(watch_values: dict, sample_count: int, name: str) -> str | None:
    row = single_watch_value(watch_values, sample_count, name)
    return row.get("valueHex") if row else None


def watch_value_stable(watch_values: dict, sample_count: int, name: str) -> bool:
    return single_watch_value(watch_values, sample_count, name) is not None


def stable_watch_hexes(watch_values: dict, sample_count: int, names: list[str]) -> list[str | None]:
    return [watch_value_hex(watch_values, sample_count, name) for name in names]


def runtime_to_static_hex(value_hex: str | None, loaded_base_hex: str | None) -> str | None:
    if not value_hex or not loaded_base_hex:
        return None
    value = int(value_hex, 16)
    loaded_base = int(loaded_base_hex, 16)
    if loaded_base <= value < loaded_base + IMAGE_SIZE:
        return f"0x{IMAGE_BASE + value - loaded_base:08x}"
    return None


def build_diagnostic_runtime_object_table_evidence(active_order_poll: dict | None) -> dict:
    active_order_poll = active_order_poll or {}
    if not active_order_poll:
        return {
            "available": False,
            "sourcePoll": DIAGNOSTIC_ACTIVE_ORDER_POLL,
            "diagnosticOnly": True,
        }
    all_rows = active_order_poll.get("rows") or []
    route_rows = [row for row in all_rows if row.get("reachedRouteSelectorContext")]
    if not route_rows:
        return {
            "available": False,
            "sourcePoll": DIAGNOSTIC_ACTIVE_ORDER_POLL,
            "diagnosticOnly": True,
            "totalSequenceCount": active_order_poll.get("sequenceCount"),
            "totalSampleCount": active_order_poll.get("sampleCount"),
            "routeSequenceCount": 0,
            "routeSampleCount": 0,
            "observedSelectors": active_order_poll.get("observedSelectors") or [],
            "promotionStatus": "missing-route-row",
        }
    route_sample_count = sum(int(row.get("sampleCount") or 0) for row in route_rows)
    watch_values = aggregate_watch_values_from_rows(route_rows)
    loaded_bases = sorted({row.get("loadedBaseHex") for row in route_rows if row.get("loadedBaseHex")})
    loaded_base_hex = loaded_bases[0] if len(loaded_bases) == 1 else None
    order_byte_names = ["activeOrder0", "activeOrder1", "activeOrder2"]
    active_slot_names = ["activeSlot0Descriptor", "activeSlot1Descriptor", "activeSlot2Descriptor"]
    slot_base_table_names = ["runtimeSlotBaseTable0", "runtimeSlotBaseTable1", "runtimeSlotBaseTable2"]
    object_table_names = ["runtimeObjectTable0", "runtimeObjectTable1", "runtimeObjectTable2"]
    order_byte_hexes = stable_watch_hexes(watch_values, route_sample_count, order_byte_names)
    active_order_count_hex = watch_value_hex(watch_values, route_sample_count, "activeOrderCount")
    active_order_count = int(active_order_count_hex, 16) if active_order_count_hex else None
    active_order_hexes = order_byte_hexes[:active_order_count] if active_order_count is not None else []
    slot_first_dwords = stable_watch_hexes(watch_values, route_sample_count, active_slot_names)
    slot_base_table = stable_watch_hexes(watch_values, route_sample_count, slot_base_table_names)
    object_table = stable_watch_hexes(watch_values, route_sample_count, object_table_names)
    return {
        "available": True,
        "sourcePoll": DIAGNOSTIC_ACTIVE_ORDER_POLL,
        "diagnosticOnly": True,
        "totalSequenceCount": active_order_poll.get("sequenceCount"),
        "totalSampleCount": active_order_poll.get("sampleCount"),
        "routeSequenceCount": len(route_rows),
        "routeSampleCount": route_sample_count,
        "routeSequenceNames": [row.get("name") for row in route_rows if row.get("name")],
        "nonRouteSequenceCount": max(0, len(all_rows) - len(route_rows)),
        "observedSelectors": active_order_poll.get("observedSelectors") or [],
        "observedStagedSelectors": active_order_poll.get("observedPublicSaveSelectors") or [],
        "reachedCurrentRoot": active_order_poll.get("anyReachedCurrentRoot"),
        "reachedRouteSelector": active_order_poll.get("anyReachedRouteSelectorContext"),
        "loadedBaseHex": loaded_base_hex,
        "activeOrderCountHex": active_order_count_hex,
        "activeOrderCount": active_order_count,
        "orderByteHexes": order_byte_hexes,
        "activeOrderHexes": active_order_hexes,
        "activeSlotFirstDwordsHex": slot_first_dwords,
        "activeSlotFirstDwordsStaticHex": [
            runtime_to_static_hex(value, loaded_base_hex) for value in slot_first_dwords
        ],
        "runtimeSlotBaseTableHexes": slot_base_table,
        "runtimeSlotBaseTableStaticHexes": [
            runtime_to_static_hex(value, loaded_base_hex) for value in slot_base_table
        ],
        "runtimeObjectTableHexes": object_table,
        "runtimeObjectTableStaticHexes": [
            runtime_to_static_hex(value, loaded_base_hex) for value in object_table
        ],
        "allWatchedValuesStable": all(
            watch_value_stable(watch_values, route_sample_count, name)
            for name in ACTIVE_ORDER_WATCH_NAMES
        ),
        "specificRuntimeObjectPointerProven": False,
        "normalRouteProof": False,
        "notRoutePromotionProof": True,
        "promotionStatus": "diagnostic-only",
        "nonPromotingBecause": [
            "the object-table values were captured only while loading the patched public-base diagnostic save",
            "the poll reaches selector 2:0 through constructed save staging, not a captured normal gameplay route save",
            "the stable runtime object table explains the diagnostic run but does not prove a normal-route context+0xf2 object pointer",
        ],
    }


def compact_row(row: dict) -> dict:
    return {
        key: row.get(key)
        for key in (
            "vaHex",
            "access",
            "instruction",
            "role",
            "sourceExpression",
            "effect",
            "nearbyEvidenceVaHex",
            "usesRuntimeObjectTable",
            "copiesF2ToChildContext",
            "copiedFromParentContext",
            "directInitializer",
        )
        if row.get(key) not in (None, False)
    }


def build_summary(
    exe: bytes,
    object_base_candidates: dict | None = None,
    order_space: dict | None = None,
    active_order_poll: dict | None = None,
) -> dict:
    object_base_candidates = object_base_candidates if object_base_candidates is not None else load_json(
        OUT / "save_selector_opcode20_object_base_candidates.json",
        {},
    )
    order_space = order_space if order_space is not None else load_json(
        OUT / "save_selector_opcode20_order_space.json",
        {},
    )
    rows = scan_context_f2_refs(exe)
    reads = [row for row in rows if row.get("access") == "read"]
    writes = [row for row in rows if row.get("access") == "write"]
    known_rows = [row for row in rows if row.get("role")]
    object_base_reader_rows = [
        row for row in rows
        if row.get("usesRuntimeObjectTable")
    ]
    direct_initializer_rows = [
        row for row in rows
        if row.get("directInitializer")
    ]
    copy_writer_rows = [
        row for row in writes
        if row.get("copiedFromParentContext")
    ]
    object_candidate_count = int(object_base_candidates.get("candidateCount") or 0)
    context_f2_candidate_count = int(object_base_candidates.get("contextF2ObjectSelectorCount") or 0)
    fixed_stream2_count = int(object_base_candidates.get("immediateObjectIndexCandidateCount") or 0)
    current_sample_covered = order_space.get("currentFrontierSampleCovered")
    active_order_promotes = order_space.get("activeOrderAlonePromotesRoute")
    diagnostic_runtime_object_table = build_diagnostic_runtime_object_table_evidence(active_order_poll)
    conclusion = (
        "The opcode 0x20 object-base candidates all rely on context+0xf2, but the f2 byte is a runtime object-slot "
        "selector. Static code evidence finds only runtime initializers/copies and object-table readers, with no "
        "constant f2 assignment or fixed stream+2 object index for the current route. The patched public-base "
        "diagnostic poll now captures a stable active order and 0x0059db30 object-table snapshot, but that capture "
        "is constructed diagnostic evidence rather than a normal-route proof. Because the current selector sample "
        "is still uncovered and active order alone does not promote the route, context+0xf2 cannot identify a "
        "specific map1_01a->map2_02d gate object without non-diagnostic runtime object-table state or an equivalent trace."
    )
    return {
        "source": SOURCE,
        "target": TARGET,
        "contextFieldOffsetHex": "0x000000f2",
        "runtimeObjectTableHex": hex32(RUNTIME_OBJECT_TABLE),
        "referenceCount": len(rows),
        "readReferenceCount": len(reads),
        "writeReferenceCount": len(writes),
        "accessHistogram": access_histogram(rows),
        "patternHistogram": histogram(rows, "pattern"),
        "runtimeObjectTableReaderCount": len(object_base_reader_rows),
        "directInitializerCount": len(direct_initializer_rows),
        "copyWriterCount": len(copy_writer_rows),
        "constantWriteCount": count_where(rows, "constantWrite"),
        "knownEvidenceRows": [compact_row(row) for row in known_rows],
        "directInitializerRows": [compact_row(row) for row in direct_initializer_rows],
        "sampleRuntimeObjectTableReaders": [compact_row(row) for row in object_base_reader_rows[:16]],
        "objectBaseCandidateCount": object_candidate_count,
        "contextF2ObjectSelectorCount": context_f2_candidate_count,
        "fixedStream2ObjectSelectorCount": fixed_stream2_count,
        "currentFrontierSampleCovered": current_sample_covered,
        "activeOrderAlonePromotesRoute": active_order_promotes,
        "diagnosticRuntimeObjectTableEvidence": diagnostic_runtime_object_table,
        "fixedContextF2ValueProvenForCurrentFrontier": False,
        "specificRuntimeObjectPointerProven": False,
        "runtimeObjectTableStateRequired": True,
        "proofFound": False,
        "opcode20ContextF2ProofFound": False,
        "failedOpcode20ContextF2GateIds": FAILED_OPCODE20_CONTEXT_F2_GATE_IDS,
        "missingEvidence": OPCODE20_CONTEXT_F2_MISSING_EVIDENCE,
        "evidenceRefs": OPCODE20_CONTEXT_F2_EVIDENCE_REFS,
        "evidenceRefCount": len(OPCODE20_CONTEXT_F2_EVIDENCE_REFS),
        "promotionStatus": "blocked",
        "remainingProofs": [
            "capture non-diagnostic/normal-route selector 2:0 active order/count with live 0x0059db30 object-table contents",
            "trace the opcode 0x42 read at 0x00406470 to a concrete context+0xf2 slot and object pointer",
            "find a strict map1_01a source hotspot if object-table state cannot be proven statically",
        ],
        "conclusion": conclusion,
    }


def markdown(summary: dict) -> str:
    diagnostic = summary.get("diagnosticRuntimeObjectTableEvidence") or {}

    def join_values(values: list | None) -> str:
        return ", ".join(str(value) for value in values or [] if value is not None) or "-"

    lines = [
        "# Save Selector Opcode 0x20 Context+0xf2 Sources",
        "",
        f"- route: `{summary['source']} -> {summary['target']}`",
        f"- context field: `{summary['contextFieldOffsetHex']}`",
        f"- runtime object table: `{summary['runtimeObjectTableHex']}`",
        f"- context+0xf2 refs in .text: {summary['referenceCount']}",
        f"- reads/writes: {summary['readReferenceCount']}/{summary['writeReferenceCount']}",
        f"- runtime object-table readers: {summary['runtimeObjectTableReaderCount']}",
        f"- direct f2 initializers: {summary['directInitializerCount']}",
        f"- f2 copy writers: {summary['copyWriterCount']}",
        f"- constant f2 writes: {summary['constantWriteCount']}",
        f"- opcode20 object-base candidates: {summary['objectBaseCandidateCount']}",
        f"- context+0xf2 object selectors: {summary['contextF2ObjectSelectorCount']}",
        f"- fixed stream+2 object selectors: {summary['fixedStream2ObjectSelectorCount']}",
        f"- current selector sample covered: {summary['currentFrontierSampleCovered']}",
        f"- active order alone promotes route: {summary['activeOrderAlonePromotesRoute']}",
        f"- specific runtime object pointer proven: {summary['specificRuntimeObjectPointerProven']}",
        f"- proofFound: `{summary['proofFound']}`",
        f"- opcode20ContextF2ProofFound: `{summary['opcode20ContextF2ProofFound']}`",
        f"- promotion status: `{summary['promotionStatus']}`",
        "",
        summary["conclusion"],
        "",
        "## Failed Gates",
        "",
    ]
    lines.extend(f"- `{item}`" for item in summary["failedOpcode20ContextF2GateIds"])
    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([
        "",
        "## Diagnostic Runtime Object Table",
        "",
        "| field | value |",
        "| --- | --- |",
        f"| source poll | `{diagnostic.get('sourcePoll')}` |",
        f"| available | `{diagnostic.get('available')}` |",
        f"| diagnostic only | `{diagnostic.get('diagnosticOnly')}` |",
        f"| route samples | `{diagnostic.get('routeSampleCount')}` across `{diagnostic.get('routeSequenceCount')}` route-reaching sequence(s) |",
        f"| total samples | `{diagnostic.get('totalSampleCount')}` across `{diagnostic.get('totalSequenceCount')}` sequence(s) |",
        f"| route sequence names | `{join_values(diagnostic.get('routeSequenceNames'))}` |",
        f"| observed selectors | `{join_values(diagnostic.get('observedSelectors'))}` |",
        f"| loaded base | `{diagnostic.get('loadedBaseHex')}` |",
        f"| active order count | `{diagnostic.get('activeOrderCountHex')}` |",
        f"| active order bytes | `{join_values(diagnostic.get('orderByteHexes'))}` |",
        f"| active order used bytes | `{join_values(diagnostic.get('activeOrderHexes'))}` |",
        f"| active slot first dwords static | `{join_values(diagnostic.get('activeSlotFirstDwordsStaticHex'))}` |",
        f"| runtime slot-base table static | `{join_values(diagnostic.get('runtimeSlotBaseTableStaticHexes'))}` |",
        f"| runtime object table static | `{join_values(diagnostic.get('runtimeObjectTableStaticHexes'))}` |",
        f"| all watched values stable | `{diagnostic.get('allWatchedValuesStable')}` |",
        f"| normal route proof | `{diagnostic.get('normalRouteProof')}` |",
        f"| promotion status | `{diagnostic.get('promotionStatus')}` |",
        "",
        "## Known Evidence",
        "",
        "| va | access | instruction | role | source | effect | nearby evidence |",
        "| --- | --- | --- | --- | --- | --- | --- |",
    ])
    for row in summary["knownEvidenceRows"]:
        lines.append(
            f"| `{row.get('vaHex')}` | {row.get('access')} | `{row.get('instruction')}` | "
            f"{row.get('role') or '-'} | {row.get('sourceExpression') or '-'} | "
            f"{row.get('effect') or '-'} | `{row.get('nearbyEvidenceVaHex') or '-'}` |"
        )
    lines.extend([
        "",
        "## Direct Initializers",
        "",
        "| va | source | effect |",
        "| --- | --- | --- |",
    ])
    for row in summary["directInitializerRows"]:
        lines.append(
            f"| `{row.get('vaHex')}` | {row.get('sourceExpression') or '-'} | {row.get('effect') or '-'} |"
        )
    lines.extend([
        "",
        "## Remaining Proofs",
        "",
    ])
    lines.extend(f"- {item}" for item in summary.get("remainingProofs") or [])
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    diagnostic = summary.get("diagnosticRuntimeObjectTableEvidence") or {}

    def join_values(values: list | None) -> str:
        return ", ".join(str(value) for value in values or [] if value is not None) or "-"

    known_rows = []
    for row in summary["knownEvidenceRows"]:
        known_rows.append(
            "<tr>"
            f"<td><code>{html.escape(str(row.get('vaHex')))}</code></td>"
            f"<td>{html.escape(str(row.get('access')))}</td>"
            f"<td><code>{html.escape(str(row.get('instruction')))}</code></td>"
            f"<td>{html.escape(str(row.get('role') or '-'))}</td>"
            f"<td>{html.escape(str(row.get('sourceExpression') or '-'))}</td>"
            f"<td>{html.escape(str(row.get('effect') or '-'))}</td>"
            f"<td><code>{html.escape(str(row.get('nearbyEvidenceVaHex') or '-'))}</code></td>"
            "</tr>"
        )
    initializer_rows = []
    for row in summary["directInitializerRows"]:
        initializer_rows.append(
            "<tr>"
            f"<td><code>{html.escape(str(row.get('vaHex')))}</code></td>"
            f"<td>{html.escape(str(row.get('sourceExpression') or '-'))}</td>"
            f"<td>{html.escape(str(row.get('effect') or '-'))}</td>"
            "</tr>"
        )
    proofs = "\n".join(f"<li>{html.escape(item)}</li>" for item in summary.get("remainingProofs") or [])
    failed_gates = "".join(
        f"<li><code>{html.escape(item)}</code></li>"
        for item in summary["failedOpcode20ContextF2GateIds"]
    )
    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"]
    )
    diagnostic_rows = "\n".join([
        f"<tr><th>source poll</th><td><code>{html.escape(str(diagnostic.get('sourcePoll')))}</code></td></tr>",
        f"<tr><th>available</th><td><code>{html.escape(str(diagnostic.get('available')))}</code></td></tr>",
        f"<tr><th>diagnostic only</th><td><code>{html.escape(str(diagnostic.get('diagnosticOnly')))}</code></td></tr>",
        f"<tr><th>route samples</th><td><code>{html.escape(str(diagnostic.get('routeSampleCount')))}</code> across <code>{html.escape(str(diagnostic.get('routeSequenceCount')))}</code> route-reaching sequence(s)</td></tr>",
        f"<tr><th>total samples</th><td><code>{html.escape(str(diagnostic.get('totalSampleCount')))}</code> across <code>{html.escape(str(diagnostic.get('totalSequenceCount')))}</code> sequence(s)</td></tr>",
        f"<tr><th>route sequence names</th><td><code>{html.escape(join_values(diagnostic.get('routeSequenceNames')))}</code></td></tr>",
        f"<tr><th>observed selectors</th><td><code>{html.escape(join_values(diagnostic.get('observedSelectors')))}</code></td></tr>",
        f"<tr><th>loaded base</th><td><code>{html.escape(str(diagnostic.get('loadedBaseHex')))}</code></td></tr>",
        f"<tr><th>active order count</th><td><code>{html.escape(str(diagnostic.get('activeOrderCountHex')))}</code></td></tr>",
        f"<tr><th>active order bytes</th><td><code>{html.escape(join_values(diagnostic.get('orderByteHexes')))}</code></td></tr>",
        f"<tr><th>active order used bytes</th><td><code>{html.escape(join_values(diagnostic.get('activeOrderHexes')))}</code></td></tr>",
        f"<tr><th>active slot first dwords static</th><td><code>{html.escape(join_values(diagnostic.get('activeSlotFirstDwordsStaticHex')))}</code></td></tr>",
        f"<tr><th>runtime slot-base table static</th><td><code>{html.escape(join_values(diagnostic.get('runtimeSlotBaseTableStaticHexes')))}</code></td></tr>",
        f"<tr><th>runtime object table static</th><td><code>{html.escape(join_values(diagnostic.get('runtimeObjectTableStaticHexes')))}</code></td></tr>",
        f"<tr><th>all watched values stable</th><td><code>{html.escape(str(diagnostic.get('allWatchedValuesStable')))}</code></td></tr>",
        f"<tr><th>normal route proof</th><td><code>{html.escape(str(diagnostic.get('normalRouteProof')))}</code></td></tr>",
        f"<tr><th>promotion status</th><td><code>{html.escape(str(diagnostic.get('promotionStatus')))}</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>Save Selector Opcode 0x20 Context+0xf2 Sources</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; }",
        "    h2 { margin: 24px 0 8px; font-size: 18px; }",
        "    p { max-width: 1180px; 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 Opcode 0x20 Context+0xf2 Sources</h1>",
        f"  <p>route <code>{html.escape(summary['source'])} -&gt; {html.escape(summary['target'])}</code>; "
        f"context+0xf2 refs {summary['referenceCount']}; reads/writes {summary['readReferenceCount']}/{summary['writeReferenceCount']}; "
        f"runtime object-table readers {summary['runtimeObjectTableReaderCount']}; direct f2 initializers {summary['directInitializerCount']}; "
        f"constant f2 writes: {summary['constantWriteCount']}; "
        f"context+0xf2 object selectors: {summary['contextF2ObjectSelectorCount']}; "
        f"fixed stream+2 object selectors: {summary['fixedStream2ObjectSelectorCount']}; "
        f"proofFound <code>{html.escape(str(summary['proofFound']))}</code>; "
        f"promotion status <code>{html.escape(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>",
        "  <h2>Diagnostic Runtime Object Table</h2>",
        "  <table><tbody>",
        diagnostic_rows,
        "  </tbody></table>",
        "  <h2>Known Evidence</h2>",
        "  <table><thead><tr><th>va</th><th>access</th><th>instruction</th><th>role</th><th>source</th><th>effect</th><th>nearby evidence</th></tr></thead><tbody>",
        *known_rows,
        "  </tbody></table>",
        "  <h2>Direct Initializers</h2>",
        "  <table><thead><tr><th>va</th><th>source</th><th>effect</th></tr></thead><tbody>",
        *initializer_rows,
        "  </tbody></table>",
        "  <h2>Remaining Proofs</h2>",
        f"  <ul>{proofs}</ul>",
        "</body>",
        "</html>",
        "",
    ])


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


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--object-base-candidates", type=Path, default=OUT / "save_selector_opcode20_object_base_candidates.json")
    parser.add_argument("--order-space", type=Path, default=OUT / "save_selector_opcode20_order_space.json")
    parser.add_argument("--active-order-poll", type=Path, default=OUT / DIAGNOSTIC_ACTIVE_ORDER_POLL)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(
        args.exe.read_bytes(),
        load_json(args.object_base_candidates, {}),
        load_json(args.order_space, {}),
        load_json(args.active_order_poll, {}),
    )
    write_outputs(summary, args.out_dir)
    print(
        "wrote save selector opcode 0x20 context+0xf2 sources -> "
        f"{args.out_dir / 'save_selector_opcode20_context_f2_sources.html'}"
    )


if __name__ == "__main__":
    main()
