#!/usr/bin/env python3
"""Summarize sources for the opcode 0x12 active-selection flag."""
from __future__ import annotations

import argparse
import html
import json
import struct
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"

ROUTE_SOURCE = "map1_01a"
ROUTE_TARGET = "map2_02d"
ACTIVE_FLAG_VA = 0x00457744
NEIGHBOR_START = 0x00457744
NEIGHBOR_END = 0x00457749
STARTUP_INIT_VA = 0x00411300
OPCODE12_HANDLER_START = 0x0040B55F
OPCODE13_HANDLER_START = 0x0040B696
OPCODE16_HANDLER_START = 0x0040BA3F
FIRST_SAVE_BLOCK_BASE = 0x004576D8


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


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


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


def section_for_offset(sections: list[dict], offset: int) -> dict | None:
    for section in sections:
        start = section["raw"]
        end = start + section["raw_size"]
        if start <= offset < end:
            return section
    return None


def byte_at(exe: bytes, sections: list[dict], va: int) -> int | None:
    offset = va_to_offset(sections, va)
    if offset is None or offset >= len(exe):
        return None
    return exe[offset]


def classify_direct_ref(data: bytes, pos: int) -> dict:
    candidates = []
    if pos >= 1 and data[pos - 1] in {0xA0, 0xA1, 0xA2, 0xA3}:
        opcode = data[pos - 1]
        if opcode == 0xA0:
            candidates.append(("read", 1, "mov al, ds:[addr]", 1))
        elif opcode == 0xA1:
            candidates.append(("read", 4, "mov eax, ds:[addr]", 1))
        elif opcode == 0xA2:
            candidates.append(("write", 1, "mov ds:[addr], al", 1))
        elif opcode == 0xA3:
            candidates.append(("write", 4, "mov ds:[addr], eax", 1))
    for prefix, access, width, instruction in [
        (b"\x8a\x0d", "read", 1, "mov cl, byte ptr ds:[addr]"),
        (b"\x8b\x0d", "read", 4, "mov ecx, dword ptr ds:[addr]"),
        (b"\x88\x0d", "write", 1, "mov byte ptr ds:[addr], cl"),
        (b"\x89\x0d", "write", 4, "mov dword ptr ds:[addr], ecx"),
        (b"\x8a\x15", "read", 1, "mov dl, byte ptr ds:[addr]"),
        (b"\x8b\x15", "read", 4, "mov edx, dword ptr ds:[addr]"),
        (b"\x88\x15", "write", 1, "mov byte ptr ds:[addr], dl"),
        (b"\x89\x15", "write", 4, "mov dword ptr ds:[addr], edx"),
        (b"\xc6\x05", "write", 1, "mov byte ptr ds:[addr], imm8"),
        (b"\xc7\x05", "write", 4, "mov dword ptr ds:[addr], imm32"),
    ]:
        if pos >= len(prefix) and data[pos - len(prefix):pos] == prefix:
            candidates.append((access, width, instruction, len(prefix)))
    for prefix, access, width, instruction in [
        (b"\x66\x8b\x0d", "read", 2, "mov cx, word ptr ds:[addr]"),
        (b"\x66\x89\x0d", "write", 2, "mov word ptr ds:[addr], cx"),
    ]:
        if pos >= len(prefix) and data[pos - len(prefix):pos] == prefix:
            candidates.append((access, width, instruction, len(prefix)))
    if not candidates:
        return {
            "accessKind": "unknown",
            "width": None,
            "instruction": "unclassified direct address reference",
            "prefixLength": 0,
        }
    access, width, instruction, prefix_length = candidates[0]
    return {
        "accessKind": access,
        "width": width,
        "instruction": instruction,
        "prefixLength": prefix_length,
    }


def classify_context(instruction_va: int) -> str:
    if OPCODE12_HANDLER_START <= instruction_va < OPCODE13_HANDLER_START:
        return "opcode 0x12 active-state slot selector"
    if OPCODE13_HANDLER_START <= instruction_va < OPCODE16_HANDLER_START:
        return "opcode 0x13/runtime-slot related handler"
    if STARTUP_INIT_VA <= instruction_va < STARTUP_INIT_VA + 0x180:
        return "startup/runtime init routine"
    return "other text routine"


def scan_exact_refs(exe: bytes, sections: list[dict], address: int) -> list[dict]:
    rows = []
    needle = struct.pack("<I", address)
    search = 0
    while True:
        hit = exe.find(needle, search)
        if hit < 0:
            break
        search = hit + 1
        section = section_for_offset(sections, hit)
        if section is None or section["name"] != ".text":
            continue
        ref_va = offset_to_va(sections, hit)
        if ref_va is None:
            continue
        ref = classify_direct_ref(exe, hit)
        instruction_va = ref_va - (ref.get("prefixLength") or 0)
        immediate = None
        if ref["instruction"] == "mov byte ptr ds:[addr], imm8" and hit + 4 < len(exe):
            immediate = exe[hit + 4]
        rows.append({
            "addressHex": hex32(address),
            "refVaHex": hex32(ref_va),
            "instructionVaHex": hex32(instruction_va),
            "accessKind": ref["accessKind"],
            "width": ref["width"],
            "instruction": ref["instruction"],
            "immediate": immediate,
            "immediateHex": f"0x{immediate:02x}" if immediate is not None else None,
            "context": classify_context(instruction_va),
            "coversActiveFlag": address <= ACTIVE_FLAG_VA < address + (ref["width"] or 0),
        })
    rows.sort(key=lambda row: row["instructionVaHex"])
    return rows


def scan_call_refs(exe: bytes, sections: list[dict], target_va: int) -> list[dict]:
    text = next(section for section in sections if section["name"] == ".text")
    raw = exe[text["raw"]: text["raw"] + text["raw_size"]]
    rows = []
    for offset in range(0, max(0, len(raw) - 4)):
        if raw[offset] != 0xE8:
            continue
        call_va = text["va"] + offset
        displacement = struct.unpack_from("<i", raw, offset + 1)[0]
        actual_target = call_va + 5 + displacement
        if actual_target != target_va:
            continue
        rows.append({
            "callVaHex": hex32(call_va),
            "targetVaHex": hex32(actual_target),
            "instruction": "call rel32",
        })
    return rows


def save_block_for_runtime_va(save_loader_trace: dict, runtime_va: int) -> dict | None:
    for row in save_loader_trace.get("saveReadBlocks") or []:
        target = int(row["targetVaHex"], 16)
        size = int(row["sizeHex"], 16)
        if target <= runtime_va < target + size:
            save_offset = int(row["saveOffsetHex"], 16) + runtime_va - target
            return {
                "callVaHex": row["callVaHex"],
                "targetVaHex": row["targetVaHex"],
                "sizeHex": row["sizeHex"],
                "description": row["description"],
                "saveOffsetHex": hex16(save_offset),
                "offsetWithinBlockHex": hex16(runtime_va - target),
            }
    return None


def build_summary(exe: bytes, save_loader_trace: dict | None = None) -> dict:
    sections = read_sections(exe)
    save_loader_trace = (
        save_loader_trace
        if save_loader_trace is not None
        else load_json(OUT / "save_loader_trace.json", {})
    )
    active_refs = scan_exact_refs(exe, sections, ACTIVE_FLAG_VA)
    neighbor_rows = []
    for address in range(NEIGHBOR_START, NEIGHBOR_END + 1):
        refs = scan_exact_refs(exe, sections, address)
        neighbor_rows.append({
            "addressHex": hex32(address),
            "saveOffsetHex": hex16(address - FIRST_SAVE_BLOCK_BASE),
            "staticInitialByte": byte_at(exe, sections, address),
            "staticInitialByteHex": f"0x{byte_at(exe, sections, address):02x}",
            "refCount": len(refs),
            "readCount": sum(1 for row in refs if row["accessKind"] == "read"),
            "writeCount": sum(1 for row in refs if row["accessKind"] == "write"),
            "refs": refs,
        })

    direct_writes = [row for row in active_refs if row["accessKind"] == "write"]
    direct_reads = [row for row in active_refs if row["accessKind"] == "read"]
    init_writes = [
        row for row in direct_writes
        if row["instructionVaHex"] == "0x00411306" and row.get("immediate") == 1
    ]
    save_source = save_block_for_runtime_va(save_loader_trace, ACTIVE_FLAG_VA)
    startup_calls = scan_call_refs(exe, sections, STARTUP_INIT_VA)
    conclusion = (
        "The opcode 0x12 active-selection flag is not an unknown static producer: the executable image "
        "initializes 0x00457744 to 1, and startup routine 0x00411300 writes it to 1 via a direct byte store "
        "called from 0x00401928. The same byte is also inside the first save-read block at save offset 0x006c, "
        "so a loaded save can override the startup/default value. For the current blocker this removes the "
        "static-writer mystery around byte(0x00457744), but route promotion is still blocked because the actual "
        "runtime/save value, prior selectionBuffer[0x20], secondaryBranchState contents, control path, and strict "
        "map1_01a hotspot remain unproven."
    )
    return {
        "source": ROUTE_SOURCE,
        "target": ROUTE_TARGET,
        "activeFlagVaHex": hex32(ACTIVE_FLAG_VA),
        "activeFlagStaticInitialByte": byte_at(exe, sections, ACTIVE_FLAG_VA),
        "activeFlagStaticInitialByteHex": f"0x{byte_at(exe, sections, ACTIVE_FLAG_VA):02x}",
        "activeFlagSaveSource": save_source,
        "activeFlagInsideSaveReadBlock": save_source is not None,
        "startupInitVaHex": hex32(STARTUP_INIT_VA),
        "startupInitCallCount": len(startup_calls),
        "startupInitCalls": startup_calls,
        "activeFlagRefCount": len(active_refs),
        "activeFlagReadCount": len(direct_reads),
        "activeFlagWriteCount": len(direct_writes),
        "activeFlagInitWriteCount": len(init_writes),
        "activeFlagRefs": active_refs,
        "neighborFlagRows": neighbor_rows,
        "promotionStatus": "blocked",
        "resolvedStaticDefault": bool(init_writes) and byte_at(exe, sections, ACTIVE_FLAG_VA) == 1,
        "remainingUnknowns": [
            "actual loaded-save byte at save offset 0x006c, if a save-load path overwrites startup defaults",
            "prior selectionBuffer[0x20] when byte(0x00457744) is nonzero",
            "runtime contents of secondaryBranchState[0..11]",
            "control-flow proof that 0x005428bc reaches the 0x00542b0c frontier reader",
            "strict source tile coordinate or hotspot for map1_01a",
        ],
        "conclusion": conclusion,
    }


def markdown(summary: dict) -> str:
    save_source = summary.get("activeFlagSaveSource") or {}
    lines = [
        "# Save Selector Active Flag Sources",
        "",
        f"- route: {summary['source']} -> {summary['target']}",
        f"- active flag: `{summary['activeFlagVaHex']}`",
        f"- static initial byte: `{summary['activeFlagStaticInitialByteHex']}`",
        f"- save source: `{save_source.get('saveOffsetHex', '-')}` via `{save_source.get('callVaHex', '-')}` ({save_source.get('description', '-')})",
        f"- startup init routine: `{summary['startupInitVaHex']}` calls: {summary['startupInitCallCount']}",
        f"- exact refs: {summary['activeFlagRefCount']} (reads {summary['activeFlagReadCount']}, writes {summary['activeFlagWriteCount']})",
        f"- init writes to 1: {summary['activeFlagInitWriteCount']}",
        f"- resolved static default: {summary['resolvedStaticDefault']}",
        f"- promotion status: `{summary['promotionStatus']}`",
        "",
        summary["conclusion"],
        "",
        "## Active Flag References",
        "",
        "| address | instruction | access | width | immediate | context | instruction |",
        "| --- | --- | --- | ---: | --- | --- | --- |",
    ]
    for row in summary["activeFlagRefs"]:
        lines.append(
            f"| `{row['addressHex']}` | `{row['instructionVaHex']}` | {row['accessKind']} | "
            f"{row['width'] if row['width'] is not None else '-'} | `{row.get('immediateHex') or '-'}` | "
            f"{row['context']} | {row['instruction']} |"
        )
    lines.extend([
        "",
        "## Neighbor Flag Bytes",
        "",
        "| address | save offset | static byte | refs | reads | writes |",
        "| --- | --- | --- | ---: | ---: | ---: |",
    ])
    for row in summary["neighborFlagRows"]:
        lines.append(
            f"| `{row['addressHex']}` | `{row['saveOffsetHex']}` | `{row['staticInitialByteHex']}` | "
            f"{row['refCount']} | {row['readCount']} | {row['writeCount']} |"
        )
    lines.extend(["", "## Startup Init Calls", "", "| call | target | instruction |", "| --- | --- | --- |"])
    for row in summary["startupInitCalls"]:
        lines.append(f"| `{row['callVaHex']}` | `{row['targetVaHex']}` | {row['instruction']} |")
    if not summary["startupInitCalls"]:
        lines.append("| - | - | - |")
    lines.extend(["", "## Remaining Unknowns", ""])
    lines.extend(f"- {item}" for item in summary["remainingUnknowns"])
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    save_source = summary.get("activeFlagSaveSource") or {}
    ref_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['addressHex'])}</code></td>"
        f"<td><code>{html.escape(row['instructionVaHex'])}</code></td>"
        f"<td>{html.escape(row['accessKind'])}</td>"
        f"<td>{row['width'] if row['width'] is not None else '-'}</td>"
        f"<td><code>{html.escape(row.get('immediateHex') or '-')}</code></td>"
        f"<td>{html.escape(row['context'])}</td>"
        f"<td>{html.escape(row['instruction'])}</td>"
        "</tr>"
        for row in summary["activeFlagRefs"]
    )
    neighbor_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['addressHex'])}</code></td>"
        f"<td><code>{html.escape(row['saveOffsetHex'])}</code></td>"
        f"<td><code>{html.escape(row['staticInitialByteHex'])}</code></td>"
        f"<td>{row['refCount']}</td>"
        f"<td>{row['readCount']}</td>"
        f"<td>{row['writeCount']}</td>"
        "</tr>"
        for row in summary["neighborFlagRows"]
    )
    call_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['callVaHex'])}</code></td>"
        f"<td><code>{html.escape(row['targetVaHex'])}</code></td>"
        f"<td>{html.escape(row['instruction'])}</td>"
        "</tr>"
        for row in summary["startupInitCalls"]
    ) or '<tr><td colspan="3">No startup init calls found.</td></tr>'
    unknowns = "".join(f"<li>{html.escape(item)}</li>" for item in summary["remainingUnknowns"])
    return "\n".join([
        "<!doctype html><meta charset=\"utf-8\"><title>Save Selector Active Flag Sources</title>",
        "<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;max-width:1120px;margin:24px auto;line-height:1.45}table{border-collapse:collapse;width:100%;margin:16px 0 28px}td,th{border:1px solid #444;padding:6px 8px;vertical-align:top}th{background:#202020}code{color:#9bd4ff}</style>",
        "<h1>Save Selector Active Flag Sources</h1>",
        f"<p>route: {html.escape(summary['source'])} -&gt; {html.escape(summary['target'])}; active flag <code>{summary['activeFlagVaHex']}</code>; static byte <code>{summary['activeFlagStaticInitialByteHex']}</code>; save offset <code>{save_source.get('saveOffsetHex', '-')}</code>.</p>",
        f"<p>startup init <code>{summary['startupInitVaHex']}</code>; calls {summary['startupInitCallCount']}; exact refs {summary['activeFlagRefCount']} (reads {summary['activeFlagReadCount']}, writes {summary['activeFlagWriteCount']}); resolved static default: {summary['resolvedStaticDefault']}; promotion status <code>{summary['promotionStatus']}</code>.</p>",
        f"<p>{html.escape(summary['conclusion'])}</p>",
        "<h2>Active Flag References</h2>",
        "<table><thead><tr><th>address</th><th>instruction</th><th>access</th><th>width</th><th>immediate</th><th>context</th><th>instruction</th></tr></thead><tbody>",
        ref_rows,
        "</tbody></table>",
        "<h2>Neighbor Flag Bytes</h2>",
        "<table><thead><tr><th>address</th><th>save offset</th><th>static byte</th><th>refs</th><th>reads</th><th>writes</th></tr></thead><tbody>",
        neighbor_rows,
        "</tbody></table>",
        "<h2>Startup Init Calls</h2>",
        "<table><thead><tr><th>call</th><th>target</th><th>instruction</th></tr></thead><tbody>",
        call_rows,
        "</tbody></table>",
        "<h2>Remaining Unknowns</h2>",
        f"<ul>{unknowns}</ul>",
    ])


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


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)
    args = parser.parse_args()
    summary = build_summary(args.exe.read_bytes())
    write_outputs(summary, args.out_dir)
    print(f"wrote save selector active flag sources -> {args.out_dir / 'save_selector_active_flag_sources.html'}")


if __name__ == "__main__":
    main()
