#!/usr/bin/env python3
"""Explain opcode 0x2c stops on the current route-pair descriptors."""
from __future__ import annotations

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

from probe_exe_scene_tables import find_cns_strings, read_sections, va_to_offset
from summarize_script_handler_table import DEFAULT_HANDLER_VA, HANDLER_TABLE_VA
from summarize_script_handler_table import analyze_stream_effect, dword_at, section_name_for_va
from summarize_save_selector_stream_traces import byte_at, handler_entry, u32_at


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
OPCODE = 0x2C
FRONTIER_READER_HEX = "0x00542b0c"
FAILED_OPCODE2C_ROUTE_PAIR_GATE_IDS = [
    "normal-route-descriptor-selection",
    "frontier-reader-target-linkage",
    "strict-source-hotspot",
]
OPCODE2C_ROUTE_PAIR_MISSING_EVIDENCE = [
    "normal runtime selector/root execution selecting corrected route-pair descriptors",
    "frontier reader branch outcome and field-map target linkage on a real route path",
    "strict map1_01a source coordinate or hotspot",
]
OPCODE2C_ROUTE_PAIR_EVIDENCE_REFS = [
    {
        "path": "Hwanse2.exe",
        "fields": [".text", "handler table", "opcode 0x2c"],
    },
    {
        "path": "out/save_selector_route_pair_descriptor_context.json",
        "fields": ["rows", "frontierReaderHex", "descriptorTrace", "geometryExitWordHitCount"],
    },
]


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


def table_handler_vas(exe: bytes, sections: list[dict]) -> list[int]:
    handlers = []
    for opcode in range(256):
        handler = dword_at(exe, sections, HANDLER_TABLE_VA + opcode * 4)
        if handler is not None and section_name_for_va(sections, handler) == ".text":
            handlers.append(handler)
    return sorted(set(handlers))


def read_u32(exe: bytes, sections: list[dict], va: int) -> int | None:
    offset = va_to_offset(sections, va)
    if offset is None or offset + 4 > len(exe):
        return None
    return struct.unpack_from("<I", exe, offset)[0]


def scan_full_handler(exe: bytes, sections: list[dict]) -> dict:
    entry_va = HANDLER_TABLE_VA + OPCODE * 4
    handler_va = dword_at(exe, sections, entry_va)
    handler_section = section_name_for_va(sections, handler_va) if handler_va is not None else None
    if handler_va is None or handler_section != ".text":
        return {
            "opcodeHex": f"0x{OPCODE:02x}",
            "entryVaHex": f"0x{entry_va:08x}",
            "handlerVaHex": f"0x{handler_va:08x}" if handler_va is not None else None,
            "handlerSection": handler_section,
            "promotionStatus": "blocked",
        }
    offset = va_to_offset(sections, handler_va)
    all_handlers = table_handler_vas(exe, sections)
    next_handler = min((va for va in all_handlers if va > handler_va), default=handler_va + 0x300)
    code = exe[offset: offset + max(0, next_handler - handler_va)] if offset is not None else b""
    old_effect = analyze_stream_effect(exe, sections, handler_va)
    fixed_advances = []
    ret_sites = []
    false_ret_sites = []
    for index, byte in enumerate(code):
        va = handler_va + index
        if byte == 0xC3:
            ret_sites.append({"vaHex": f"0x{va:08x}", "offsetHex": f"0x{index:02x}"})
            if index > 0 and code[index - 1] == 0xE9:
                false_ret_sites.append({
                    "vaHex": f"0x{va:08x}",
                    "offsetHex": f"0x{index:02x}",
                    "reason": "byte is the first displacement byte of a near jmp, not a ret opcode",
                    "instructionVaHex": f"0x{handler_va + index - 1:08x}",
                    "instructionBytesHex": code[index - 1:index + 4].hex(" "),
                })
    for index in range(max(0, len(code) - 3)):
        va = handler_va + index
        if code[index:index + 3] == b"\x83\x40\x40":
            fixed_advances.append({
                "vaHex": f"0x{va:08x}",
                "offsetHex": f"0x{index:02x}",
                "bytes": code[index + 3],
            })
    corrected_fixed_advances = sorted({
        item["bytes"]
        for item in fixed_advances
        if item.get("bytes")
    })
    old_scan_bytes = old_effect.get("scanBytes")
    old_fixed_advances = sorted({
        item.get("bytes")
        for item in old_effect.get("fixedAdvances", [])
        if item.get("bytes")
    })
    true_ret = ret_sites[-1] if ret_sites else None
    return {
        "opcode": OPCODE,
        "opcodeHex": f"0x{OPCODE:02x}",
        "entryVaHex": f"0x{entry_va:08x}",
        "handlerVa": handler_va,
        "handlerVaHex": f"0x{handler_va:08x}",
        "handlerSection": handler_section,
        "nextHandlerVaHex": f"0x{next_handler:08x}",
        "oldAnalyzerScanBytes": old_scan_bytes,
        "fullHandlerSpanBytes": len(code),
        "oldAnalyzerFixedAdvances": old_fixed_advances,
        "fullHandlerFixedAdvances": fixed_advances,
        "correctedFixedAdvances": corrected_fixed_advances,
        "correctedFixedAdvanceBytes": corrected_fixed_advances[0] if corrected_fixed_advances == [4] else None,
        "retSites": ret_sites,
        "trueRetVaHex": true_ret.get("vaHex") if true_ret else None,
        "falseRetSites": false_ret_sites,
        "oldNoFixedAdvanceIsScannerArtifact": old_fixed_advances == [] and corrected_fixed_advances == [4],
    }


def corrected_trace(
    exe: bytes,
    sections: list[dict],
    strings: dict[int, str],
    stream_va: int,
    stop_reader_hex: str,
    max_steps: int = 48,
) -> list[dict]:
    rows = []
    seen = set()
    va = stream_va
    stop_reader = int(stop_reader_hex, 16)
    for step in range(max_steps):
        if va in seen:
            rows.append({"step": step, "vaHex": f"0x{va:08x}", "stopReason": "loop"})
            break
        seen.add(va)
        opcode = byte_at(exe, sections, va)
        value = u32_at(exe, sections, va)
        if opcode is None or value is None:
            rows.append({"step": step, "vaHex": f"0x{va:08x}", "stopReason": "unreadable"})
            break
        handler = handler_entry(exe, sections, opcode)
        advances = handler.get("fixedAdvances") or []
        override_reason = None
        if opcode == OPCODE:
            advances = [4]
            override_reason = "full handler scan finds add [context+0x40], +4 after the old analyzer clipped on a jmp displacement byte"
        row = {
            "step": step,
            "va": va,
            "vaHex": f"0x{va:08x}",
            "value": value,
            "valueHex": f"0x{value:08x}",
            **handler,
            "fixedAdvances": advances,
            "overrideReason": override_reason,
        }
        if value in strings:
            row["cns"] = strings[value]
        elif va_to_offset(sections, value) is not None:
            row["pointer"] = True
            row["pointerHex"] = f"0x{value:08x}"
        if handler.get("canJumpToDwordAtPlus4"):
            target = read_u32(exe, sections, va + 4)
            row["branchTargetHex"] = f"0x{target:08x}" if target is not None else None
            row["fallthroughVaHex"] = f"0x{va + 8:08x}"
        rows.append(row)
        if va == stop_reader:
            row["stopReason"] = "frontier-reader"
            break
        if len(advances) == 1 and not handler.get("canJumpToDwordAtPlus4"):
            va += advances[0]
            continue
        if len(advances) == 1 and handler.get("canJumpToDwordAtPlus4"):
            row["stopReason"] = "branch-or-fallthrough"
            break
        if not advances:
            row["stopReason"] = "no-fixed-advance"
            break
        row["stopReason"] = "multiple-advances"
        break
    return rows


def trace_summary(trace: list[dict], reader_hex: str) -> dict:
    reader_steps = [
        row for row in trace
        if row.get("vaHex") == reader_hex
    ]
    stop = trace[-1] if trace else {}
    return {
        "stepCount": len(trace),
        "opcodes": [row.get("opcodeHex") for row in trace if row.get("opcodeHex")],
        "reachesFrontierReader": bool(reader_steps),
        "frontierReaderStep": reader_steps[0].get("step") if reader_steps else None,
        "stopVaHex": stop.get("vaHex"),
        "stopOpcodeHex": stop.get("opcodeHex"),
        "stopReason": stop.get("stopReason"),
    }


def build_summary(exe: bytes, route_pair_descriptor_context: dict) -> dict:
    sections = read_sections(exe)
    strings = find_cns_strings(exe, sections)
    handler = scan_full_handler(exe, sections)
    rows = []
    for row in route_pair_descriptor_context.get("rows") or []:
        if not row.get("insideCurrentRootEntryRun") or not row.get("descriptorHasRoutePair"):
            continue
        descriptor_hex = row.get("descriptorHex")
        descriptor_va = int(descriptor_hex, 16)
        trace = corrected_trace(
            exe,
            sections,
            strings,
            descriptor_va,
            route_pair_descriptor_context.get("frontierReaderHex") or FRONTIER_READER_HEX,
        )
        old_trace = row.get("descriptorTrace") or {}
        rows.append({
            "index": row.get("index"),
            "entryVaHex": row.get("entryVaHex"),
            "descriptorHex": descriptor_hex,
            "childPointerHex": row.get("childPointerHex"),
            "oldDescriptorTrace": old_trace,
            "correctedTrace": trace_summary(trace, route_pair_descriptor_context.get("frontierReaderHex") or FRONTIER_READER_HEX),
            "correctedTraceRows": trace,
            "geometryExitWordHitCount": row.get("geometryExitWordHitCount"),
            "sourceToTargetAdjacent": row.get("sourceToTargetAdjacent"),
        })
    corrected_reader_hits = sum(1 for row in rows if (row.get("correctedTrace") or {}).get("reachesFrontierReader"))
    old_no_fixed_count = sum(
        1
        for row in rows
        if (row.get("oldDescriptorTrace") or {}).get("stopReason") == "no-fixed-advance"
    )
    conclusion = (
        "The previous route-pair descriptor traces stopped at opcode 0x2c because the generic handler "
        "scanner clipped the handler at a 0xc3 byte inside a near-jump displacement. A full handler-span "
        "scan shows opcode 0x2c ends by advancing the stream pointer by four bytes. With that local "
        "correction, both non-negative current route-pair descriptors can be linearly traced to the "
        "frontier reader 0x00542b0c. This removes opcode 0x2c as the immediate trace blocker, but it "
        "does not promote map1_01a -> map2_02d because a strict source coordinate/hotspot and normal "
        "runtime selector execution proof are still missing."
    )
    return {
        "source": route_pair_descriptor_context.get("source"),
        "target": route_pair_descriptor_context.get("target"),
        "selector": route_pair_descriptor_context.get("selector"),
        "rootHex": route_pair_descriptor_context.get("rootHex"),
        "frontierReaderHex": route_pair_descriptor_context.get("frontierReaderHex") or FRONTIER_READER_HEX,
        "opcode2cHandler": handler,
        "routePairDescriptorCount": len(rows),
        "oldNoFixedAdvanceStopCount": old_no_fixed_count,
        "correctedTraceReachesReaderCount": corrected_reader_hits,
        "correctedTraceAllRoutePairDescriptorsReachReader": bool(rows) and corrected_reader_hits == len(rows),
        "strictHotspotFound": False,
        "runtimeSelectionProven": False,
        "proofFound": False,
        "opcode2cRoutePairProofFound": False,
        "failedOpcode2cRoutePairGateIds": FAILED_OPCODE2C_ROUTE_PAIR_GATE_IDS,
        "missingEvidence": OPCODE2C_ROUTE_PAIR_MISSING_EVIDENCE,
        "evidenceRefs": OPCODE2C_ROUTE_PAIR_EVIDENCE_REFS,
        "evidenceRefCount": len(OPCODE2C_ROUTE_PAIR_EVIDENCE_REFS),
        "promotionStatus": "blocked",
        "rows": rows,
        "conclusion": conclusion,
        "remainingProofs": OPCODE2C_ROUTE_PAIR_MISSING_EVIDENCE,
    }


def markdown(summary: dict) -> str:
    handler = summary.get("opcode2cHandler") or {}
    lines = [
        "# Save Selector Opcode 0x2c Route-Pair Context",
        "",
        f"- route: `{summary.get('source')} -> {summary.get('target')}`",
        f"- selector: `{summary.get('selector')}` root `{summary.get('rootHex')}`",
        f"- frontier reader: `{summary.get('frontierReaderHex')}`",
        f"- opcode 0x2c handler: `{handler.get('handlerVaHex')}`",
        f"- old analyzer scan bytes: {handler.get('oldAnalyzerScanBytes')}",
        f"- full handler span bytes: {handler.get('fullHandlerSpanBytes')}",
        f"- old fixed advances: {handler.get('oldAnalyzerFixedAdvances')}",
        f"- corrected fixed advances: {handler.get('correctedFixedAdvances')}",
        f"- old no-fixed-advance stops: {summary.get('oldNoFixedAdvanceStopCount')}",
        f"- corrected route-pair traces reaching reader: {summary.get('correctedTraceReachesReaderCount')}/{summary.get('routePairDescriptorCount')}",
        f"- strict hotspot found: {summary.get('strictHotspotFound')}",
        f"- runtime selection proven: {summary.get('runtimeSelectionProven')}",
        f"- proof found: {summary.get('proofFound')}",
        f"- failed opcode 0x2c route-pair gates: {', '.join(summary.get('failedOpcode2cRoutePairGateIds') or [])}",
        f"- missing evidence count: {len(summary.get('missingEvidence') or [])}",
        f"- evidence refs: {summary.get('evidenceRefCount')}",
        f"- promotion status: `{summary.get('promotionStatus')}`",
        "",
        summary.get("conclusion") or "",
        "",
        "## Handler Span",
        "",
        "| kind | va | detail |",
        "| --- | --- | --- |",
    ]
    for site in handler.get("falseRetSites") or []:
        lines.append(
            f"| false ret byte | `{site.get('vaHex')}` | {site.get('reason')} (`{site.get('instructionBytesHex')}` at `{site.get('instructionVaHex')}`) |"
        )
    for site in handler.get("fullHandlerFixedAdvances") or []:
        lines.append(f"| fixed advance | `{site.get('vaHex')}` | `+{site.get('bytes')}` bytes |")
    if handler.get("trueRetVaHex"):
        lines.append(f"| true ret | `{handler.get('trueRetVaHex')}` | final handler return before `{handler.get('nextHandlerVaHex')}` |")
    lines.extend([
        "",
        "## Corrected Route-Pair Traces",
        "",
        "| index | descriptor | old stop | corrected steps | reaches reader | reader step | corrected stop |",
        "| ---: | --- | --- | ---: | --- | ---: | --- |",
    ])
    for row in summary.get("rows") or []:
        old_trace = row.get("oldDescriptorTrace") or {}
        corrected = row.get("correctedTrace") or {}
        lines.append(
            f"| {row.get('index')} | `{row.get('descriptorHex')}` | "
            f"`{old_trace.get('stopReason')}` at `{old_trace.get('stopVaHex')}` | "
            f"{corrected.get('stepCount')} | {corrected.get('reachesFrontierReader')} | "
            f"{corrected.get('frontierReaderStep')} | `{corrected.get('stopReason')}` at `{corrected.get('stopVaHex')}` |"
        )
    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:
    handler = summary.get("opcode2cHandler") or {}
    span_rows = []
    for site in handler.get("falseRetSites") or []:
        span_rows.append(
            "<tr>"
            "<td>false ret byte</td>"
            f"<td><code>{html.escape(str(site.get('vaHex')))}</code></td>"
            f"<td>{html.escape(str(site.get('reason')))} (<code>{html.escape(str(site.get('instructionBytesHex')))}</code> at <code>{html.escape(str(site.get('instructionVaHex')))}</code>)</td>"
            "</tr>"
        )
    for site in handler.get("fullHandlerFixedAdvances") or []:
        span_rows.append(
            "<tr>"
            "<td>fixed advance</td>"
            f"<td><code>{html.escape(str(site.get('vaHex')))}</code></td>"
            f"<td><code>+{html.escape(str(site.get('bytes')))}</code> bytes</td>"
            "</tr>"
        )
    if handler.get("trueRetVaHex"):
        span_rows.append(
            "<tr>"
            "<td>true ret</td>"
            f"<td><code>{html.escape(str(handler.get('trueRetVaHex')))}</code></td>"
            f"<td>final handler return before <code>{html.escape(str(handler.get('nextHandlerVaHex')))}</code></td>"
            "</tr>"
        )
    trace_rows = []
    for row in summary.get("rows") or []:
        old_trace = row.get("oldDescriptorTrace") or {}
        corrected = row.get("correctedTrace") or {}
        trace_rows.append(
            "<tr>"
            f"<td>{row.get('index')}</td>"
            f"<td><code>{html.escape(str(row.get('descriptorHex')))}</code></td>"
            f"<td><code>{html.escape(str(old_trace.get('stopReason')))}</code> at <code>{html.escape(str(old_trace.get('stopVaHex')))}</code></td>"
            f"<td>{corrected.get('stepCount')}</td>"
            f"<td>{corrected.get('reachesFrontierReader')}</td>"
            f"<td>{corrected.get('frontierReaderStep')}</td>"
            f"<td><code>{html.escape(str(corrected.get('stopReason')))}</code> at <code>{html.escape(str(corrected.get('stopVaHex')))}</code></td>"
            "</tr>"
        )
    proofs = "".join(f"<li>{html.escape(item)}</li>" for item in summary.get("remainingProofs") 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>Save Selector Opcode 0x2c Route-Pair Context</title>",
        "  <style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;margin:24px}table{border-collapse:collapse;width:100%;max-width:1280px;margin-bottom:24px}td,th{border:1px solid #333;padding:6px 8px;text-align:left;vertical-align:top}th{background:#1f1f1f}code{color:#9bd4ff}</style>",
        "</head>",
        "<body>",
        "  <h1>Save Selector Opcode 0x2c Route-Pair Context</h1>",
        f"  <p>route <code>{html.escape(str(summary.get('source')))} -&gt; {html.escape(str(summary.get('target')))}</code>; selector <code>{html.escape(str(summary.get('selector')))}</code>; handler <code>{html.escape(str(handler.get('handlerVaHex')))}</code>; corrected route-pair traces reaching reader <code>{summary.get('correctedTraceReachesReaderCount')}/{summary.get('routePairDescriptorCount')}</code>; promotion status <code>{html.escape(str(summary.get('promotionStatus')))}</code>.</p>",
        f"  <p>proof found <code>{summary.get('proofFound')}</code>; failed opcode 0x2c route-pair gates <code>{html.escape(','.join(summary.get('failedOpcode2cRoutePairGateIds') or []))}</code>; missing evidence <code>{len(summary.get('missingEvidence') or [])}</code>; evidence refs <code>{summary.get('evidenceRefCount')}</code>.</p>",
        f"  <p>{html.escape(summary.get('conclusion') or '')}</p>",
        "  <h2>Handler Span</h2>",
        "  <table><thead><tr><th>kind</th><th>va</th><th>detail</th></tr></thead>",
        f"  <tbody>{''.join(span_rows)}</tbody></table>",
        "  <h2>Corrected Route-Pair Traces</h2>",
        "  <table><thead><tr><th>index</th><th>descriptor</th><th>old stop</th><th>corrected steps</th><th>reaches reader</th><th>reader step</th><th>corrected stop</th></tr></thead>",
        f"  <tbody>{''.join(trace_rows)}</tbody></table>",
        "  <h2>Remaining Proofs</h2>",
        f"  <ul>{proofs}</ul>",
        "</body>",
        "</html>",
        "",
    ])


def write_outputs(summary: dict, out_dir: Path = OUT, html_out: Path | None = None) -> Path:
    out_dir.mkdir(parents=True, exist_ok=True)
    json_out = out_dir / "save_selector_opcode2c_route_pair_context.json"
    json_out.write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    if html_out is not None:
        html_out.parent.mkdir(parents=True, exist_ok=True)
        html_out.write_text(html_page(summary), encoding="utf-8")
    return json_out


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--route-pair", type=Path, default=OUT / "save_selector_route_pair_descriptor_context.json")
    parser.add_argument("--html-out", type=Path)
    args = parser.parse_args()
    summary = build_summary(
        args.exe.read_bytes(),
        load_json(args.route_pair, {}),
    )
    json_out = write_outputs(summary, args.out_dir, args.html_out)
    print(f"wrote opcode 0x2c route-pair context -> {json_out}")


if __name__ == "__main__":
    main()
