#!/usr/bin/env python3
"""Narrow the static frontier for the normal-field ESC/X HUD menu opener.

The region/window payloads and top-menu object sequence are already grounded.
This pass deliberately avoids re-decoding those payloads.  Instead it scans
the EXE at x86-function granularity and asks a narrower question:

* Do input-mask consumers and descriptor/menu materializers meet in the same
  function?
* If they do, is the function the known cancel/back handler, a movement
  controller, or a plausible normal-field open-menu producer?
"""
from __future__ import annotations

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

from probe_exe_scene_tables import offset_to_va, read_sections, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
EXE = ROOT / "Hwanse2.exe"
OUT = ROOT / "out"
WEB = ROOT / "web"

CURRENT_ACTION_MASK_VA = 0x0059E310
EDGE_ACTION_MASK_VA = 0x0059E312
INPUT_PACKER_VA = 0x0042F6D2
INPUT_POLL_DRIVER_VA = 0x00422D74

ACTIVE_DESCRIPTOR_COUNT_VA = 0x004576E8
ACTIVE_DESCRIPTOR_ID_ORDER_VA = 0x004576E9
ACTIVE_DESCRIPTOR_SLOT_TABLE_VA = 0x00457750
ACTIVE_DESCRIPTOR_SLOT_POINTERS_VA = 0x0059DB30
ACTIVE_DESCRIPTOR_OBJECT_POINTERS_VA = 0x0059DD70
DESCRIPTOR_ROOT_TABLE_VA = 0x00442D95

ADD_DESCRIPTOR_ROUTINE_VA = 0x00431FE8
REMOVE_DESCRIPTOR_ROUTINE_VA = 0x00432541
REBUILD_DESCRIPTOR_ROUTINE_VA = 0x00432323

TOP_MENU_OBJECT_SEQUENCE_VA = 0x004DDC6C
TOP_MENU_INTERMEDIATE_RECORD_VA = 0x0047E6A0
TOP_MENU_PARENT_CONTEXT_SCRIPT_VA = 0x0047E624

KNOWN_CANCEL_HANDLER_VA = 0x0040C6CC
DISPLAY_VM_BASE_BINDER_VA = 0x0041D89D
ACTIVE_DESCRIPTOR_FIELD_CONTROLLER_VA = 0x0043022D

MAP_COLLISION_FLAGS_VA = 0x0058D7D0
MAP_WIDTH_VA = 0x00595ADA


def hx(value: int | None, width: int = 8) -> str:
    return "-" if value is None else f"0x{value:0{width}x}"


def h(value: Any) -> str:
    return html.escape("" if value is None else str(value), quote=True)


def read_json(path: Path) -> dict[str, Any]:
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return {}
    return data if isinstance(data, dict) else {}


def text_section(sections: list[dict[str, Any]]) -> dict[str, Any]:
    for section in sections:
        if section.get("name") == ".text":
            return section
    raise ValueError("missing .text section")


def text_refs(exe: bytes, sections: list[dict[str, Any]], value: int) -> list[int]:
    needle = struct.pack("<I", value)
    refs: list[int] = []
    for section in sections:
        if section.get("name") != ".text":
            continue
        start = int(section["raw"])
        end = start + int(section["raw_size"])
        pos = exe.find(needle, start, end)
        while pos != -1:
            va = offset_to_va(sections, pos)
            if va is not None:
                refs.append(va)
            pos = exe.find(needle, pos + 1, end)
    return refs


def all_refs(exe: bytes, sections: list[dict[str, Any]], value: int) -> list[int]:
    needle = struct.pack("<I", value)
    refs: list[int] = []
    pos = exe.find(needle)
    while pos != -1:
        va = offset_to_va(sections, pos)
        if va is not None:
            refs.append(va)
        pos = exe.find(needle, pos + 1)
    return refs


def call_sites(exe: bytes, sections: list[dict[str, Any]], target_va: int) -> list[int]:
    hits: list[int] = []
    for section in sections:
        if section.get("name") != ".text":
            continue
        start = int(section["raw"])
        end = start + int(section["raw_size"])
        for off in range(start, max(start, end - 5)):
            if exe[off] != 0xE8:
                continue
            call_va = offset_to_va(sections, off)
            if call_va is None:
                continue
            rel = struct.unpack_from("<i", exe, off + 1)[0]
            if call_va + 5 + rel == target_va:
                hits.append(call_va)
    return hits


def function_ranges(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    section = text_section(sections)
    raw_start = int(section["raw"])
    raw_end = raw_start + int(section["raw_size"])
    starts: list[int] = []
    pos = raw_start
    while True:
        hit = exe.find(b"\x55\x8b\xec", pos, raw_end)
        if hit < 0:
            break
        va = offset_to_va(sections, hit)
        if va is not None:
            starts.append(va)
        pos = hit + 1
    starts = sorted(set(starts))
    rows: list[dict[str, Any]] = []
    for index, start_va in enumerate(starts):
        start_off = va_to_offset(sections, start_va)
        if start_off is None:
            continue
        next_va = starts[index + 1] if index + 1 < len(starts) else int(section["va"]) + int(section["raw_size"])
        next_off = va_to_offset(sections, next_va) if index + 1 < len(starts) else raw_end
        if next_off is None:
            next_off = raw_end
        # Keep the prologue-to-next-prologue range.  It is conservative for this
        # compiler output and avoids needing a full disassembler.
        rows.append(
            {
                "startVa": start_va,
                "endVa": next_va,
                "startOff": start_off,
                "endOff": max(start_off, next_off),
                "size": max(0, next_off - start_off),
            }
        )
    return rows


def find_function(functions: list[dict[str, Any]], va: int) -> dict[str, Any] | None:
    for row in functions:
        if int(row["startVa"]) <= va < int(row["endVa"]):
            return row
    return None


def contains_bytes(row: dict[str, Any], exe: bytes, pattern: bytes) -> bool:
    return exe[int(row["startOff"]): int(row["endOff"])].find(pattern) >= 0


def refs_in_function(row: dict[str, Any], refs: list[int]) -> list[int]:
    start = int(row["startVa"])
    end = int(row["endVa"])
    return [ref for ref in refs if start <= ref < end]


def classify_function(row: dict[str, Any], refs_by_name: dict[str, list[int]], calls_by_name: dict[str, list[int]], exe: bytes) -> dict[str, Any]:
    ref_hits = {
        name: [hx(ref) for ref in refs_in_function(row, refs)]
        for name, refs in refs_by_name.items()
        if refs_in_function(row, refs)
    }
    call_hits = {
        name: [hx(ref) for ref in refs_in_function(row, refs)]
        for name, refs in calls_by_name.items()
        if refs_in_function(row, refs)
    }
    input_names = {name for name in ref_hits if name.startswith("input.")}
    descriptor_names = {name for name in ref_hits if name.startswith("descriptor.")}
    top_names = {name for name in ref_hits if name.startswith("topMenu.")}
    map_names = {name for name in ref_hits if name.startswith("map.")}
    materializer_calls = set(call_hits)

    start_va = int(row["startVa"])
    if start_va == KNOWN_CANCEL_HANDLER_VA:
        role = "known-cancel/back-handler"
        promotion = "negative-static-proof"
        note = "ESC/X edge bit consumer, but it unwinds active descriptor state; this is cancel/back, not normal open."
    elif start_va == DISPLAY_VM_BASE_BINDER_VA:
        role = "display-vm-base-pointer-binder"
        promotion = "negative-static-proof"
        note = "Command byte selects a base pointer for the display/object VM; it reads input context but does not add the menu descriptor."
    elif start_va == ACTIVE_DESCRIPTOR_FIELD_CONTROLLER_VA:
        role = "active-descriptor-field-movement-controller"
        promotion = "negative-static-proof"
        note = "Consumes current input mask, active descriptors, and map collision data for field movement/collision; not an ESC/X menu opener."
    elif input_names and map_names and not descriptor_names and not top_names:
        role = "field-movement/controller"
        promotion = "context-only"
        note = "Input and map data meet in movement/collision context."
    elif descriptor_names and not input_names and materializer_calls:
        role = "descriptor-materializer-helper"
        promotion = "context-only"
        note = "Descriptor helper/materializer context without direct input evidence."
    elif input_names and descriptor_names:
        role = "input+descriptor-intersection"
        promotion = "candidate-review"
        note = "Input and descriptor data meet in one function; inspect manually before promotion."
    elif input_names and top_names:
        role = "input+top-menu-intersection"
        promotion = "candidate-review"
        note = "Input and top-menu data meet in one function; inspect manually before promotion."
    elif top_names:
        role = "top-menu-data-context"
        promotion = "context-only"
        note = "Top-menu payload/data context."
    elif input_names:
        role = "input-consumer"
        promotion = "context-only"
        note = "Input consumer only."
    elif descriptor_names:
        role = "descriptor-consumer"
        promotion = "context-only"
        note = "Descriptor consumer only."
    else:
        role = "other"
        promotion = "context-only"
        note = "Reference/call context does not match the opener frontier."

    has_cancel_bit_test = contains_bytes(row, exe, b"\x66\xa1\x12\xe3\x59\x00")
    has_current_mask_read = contains_bytes(row, exe, b"\x66\xa1\x10\xe3\x59\x00")
    return {
        "functionVaHex": hx(start_va),
        "endVaHex": hx(int(row["endVa"])),
        "size": row["size"],
        "role": role,
        "refHits": ref_hits,
        "callHits": call_hits,
        "hasEdgeMaskRead": has_cancel_bit_test,
        "hasCurrentMaskRead": has_current_mask_read,
        "promotion": promotion,
        "note": note,
    }


def build_report() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    functions = function_ranges(exe, sections)

    refs_by_name = {
        "input.currentActionMask": text_refs(exe, sections, CURRENT_ACTION_MASK_VA),
        "input.edgeActionMask": text_refs(exe, sections, EDGE_ACTION_MASK_VA),
        "descriptor.activeCount": text_refs(exe, sections, ACTIVE_DESCRIPTOR_COUNT_VA),
        "descriptor.activeIdOrder": text_refs(exe, sections, ACTIVE_DESCRIPTOR_ID_ORDER_VA),
        "descriptor.slotTable": text_refs(exe, sections, ACTIVE_DESCRIPTOR_SLOT_TABLE_VA),
        "descriptor.slotPointers": text_refs(exe, sections, ACTIVE_DESCRIPTOR_SLOT_POINTERS_VA),
        "descriptor.objectPointers": text_refs(exe, sections, ACTIVE_DESCRIPTOR_OBJECT_POINTERS_VA),
        "descriptor.rootTable": text_refs(exe, sections, DESCRIPTOR_ROOT_TABLE_VA),
        "topMenu.objectSequence": text_refs(exe, sections, TOP_MENU_OBJECT_SEQUENCE_VA),
        "topMenu.intermediateRecord": text_refs(exe, sections, TOP_MENU_INTERMEDIATE_RECORD_VA),
        "topMenu.parentContextScript": text_refs(exe, sections, TOP_MENU_PARENT_CONTEXT_SCRIPT_VA),
        "map.collisionFlags": text_refs(exe, sections, MAP_COLLISION_FLAGS_VA),
        "map.width": text_refs(exe, sections, MAP_WIDTH_VA),
    }
    calls_by_name = {
        "call.addDescriptor": call_sites(exe, sections, ADD_DESCRIPTOR_ROUTINE_VA),
        "call.removeDescriptor": call_sites(exe, sections, REMOVE_DESCRIPTOR_ROUTINE_VA),
        "call.rebuildDescriptor": call_sites(exe, sections, REBUILD_DESCRIPTOR_ROUTINE_VA),
        "call.inputPacker": call_sites(exe, sections, INPUT_PACKER_VA),
    }

    touched_vas = sorted({va for refs in refs_by_name.values() for va in refs} | {va for refs in calls_by_name.values() for va in refs})
    candidate_funcs: dict[int, dict[str, Any]] = {}
    for va in touched_vas:
        row = find_function(functions, va)
        if row:
            candidate_funcs[int(row["startVa"])] = row

    classified = [
        classify_function(row, refs_by_name, calls_by_name, exe)
        for row in sorted(candidate_funcs.values(), key=lambda item: int(item["startVa"]))
    ]

    intersections = [
        row
        for row in classified
        if row["role"] in {
            "input+descriptor-intersection",
            "input+top-menu-intersection",
            "known-cancel/back-handler",
            "display-vm-base-pointer-binder",
            "active-descriptor-field-movement-controller",
        }
    ]
    normal_open_candidates = [
        row
        for row in intersections
        if row["role"] not in {"known-cancel/back-handler"}
        and row["callHits"].get("call.addDescriptor")
    ]

    top_sequence_refs_all = all_refs(exe, sections, TOP_MENU_OBJECT_SEQUENCE_VA)
    top_sequence_text_refs = refs_by_name["topMenu.objectSequence"]

    return {
        "kind": "hwanse-hud-menu-opener-frontier-review",
        "source": "tools/build_hud_menu_opener_frontier_review.py",
        "exe": str(EXE),
        "status": "normal-field-opener-still-unproven",
        "summary": {
            "functionCount": len(functions),
            "touchedFunctionCount": len(classified),
            "inputFunctionCount": sum(1 for row in classified if row["refHits"].keys() & {"input.currentActionMask", "input.edgeActionMask"}),
            "descriptorFunctionCount": sum(1 for row in classified if any(key.startswith("descriptor.") for key in row["refHits"])),
            "intersectionFunctionCount": len(intersections),
            "normalOpenCandidateCount": len(normal_open_candidates),
            "topMenuSequenceTextRefCount": len(top_sequence_text_refs),
            "topMenuSequenceAllRefCount": len(top_sequence_refs_all),
            "topMenuSequenceAllRefsHex": [hx(ref) for ref in top_sequence_refs_all],
            "decision": (
                "x86 함수 단위 교집합에서도 normal-field ESC/X opener는 아직 증명되지 않았다. "
                "입력 mask와 descriptor stack이 만나는 확정 함수는 cancel/back, display VM base binder, "
                "field movement/collision 계층으로 배제됐으며, "
                "top-menu object sequence 0x004ddc6c의 직접 ref는 여전히 data-context 1건뿐이다."
            ),
        },
        "refsByName": {name: [hx(ref) for ref in refs] for name, refs in refs_by_name.items()},
        "callsByName": {name: [hx(ref) for ref in refs] for name, refs in calls_by_name.items()},
        "intersectionFunctions": intersections,
        "normalOpenCandidates": normal_open_candidates,
        "roleCounts": {
            role: sum(1 for row in classified if row["role"] == role)
            for role in sorted({row["role"] for row in classified})
        },
        "classifiedFunctions": classified,
        "nextFrontier": [
            "입력 bit 자체가 아니라 mode/state byte가 바뀐 뒤 0x62 add-descriptor가 실행되는 selected/root command stream을 찾아야 한다.",
            "topMenu sequence direct pointer는 data-only라서, parent/intermediate record를 고르는 selector table이나 descriptor id producer를 추적해야 한다.",
            "0x40/0x8a/0x87 계열 object VM selector producer가 +0x2f/+0x3d를 쓰는 위치는 이미 일부 확인됐으므로, 다음은 이 object가 어떤 descriptor id로 stack에 올라오는지 역추적한다.",
        ],
    }


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    cards = [
        ("status", report["status"]),
        ("touched funcs", summary["touchedFunctionCount"]),
        ("intersections", summary["intersectionFunctionCount"]),
        ("open candidates", summary["normalOpenCandidateCount"]),
        ("top seq refs", f"text {summary['topMenuSequenceTextRefCount']} / all {summary['topMenuSequenceAllRefCount']}"),
    ]
    card_html = "".join(f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>" for k, v in cards)
    inter_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['functionVaHex'])}</code><br>{h(row['role'])}</td>"
        f"<td>{h(json.dumps(row['refHits'], ensure_ascii=False))}</td>"
        f"<td>{h(json.dumps(row['callHits'], ensure_ascii=False))}</td>"
        f"<td>{h(row['promotion'])}<br><span class='note'>{h(row.get('note', ''))}</span></td>"
        "</tr>"
        for row in report["intersectionFunctions"]
    )
    frontier = "".join(f"<li>{h(item)}</li>" for item in report["nextFrontier"])
    payload = json.dumps(report, ensure_ascii=False)
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>HUD Menu Opener Frontier Review</title>
  <style>
    body {{ margin:0; background:#101318; color:#edf1f7; font-family:system-ui,sans-serif; }}
    main {{ max-width:1280px; margin:0 auto; padding:24px; }}
    a {{ color:#8ecbff; }}
    .nav {{ display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px; }}
    .chip {{ border:1px solid #334155; border-radius:999px; padding:6px 10px; text-decoration:none; background:#161b22; }}
    .summary {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:12px; margin:16px 0; }}
    .card {{ border:1px solid #2b3544; border-radius:8px; padding:12px; background:#161b22; }}
    .card b {{ display:block; color:#9fb1c9; font-size:12px; text-transform:uppercase; }}
    .card span {{ display:block; margin-top:8px; font-size:18px; overflow-wrap:anywhere; }}
    section {{ border:1px solid #273244; border-radius:10px; padding:16px; margin:16px 0; background:#141922; overflow:auto; }}
    table {{ border-collapse:collapse; width:100%; min-width:1080px; font-size:13px; }}
    th,td {{ border-bottom:1px solid #283342; padding:8px; text-align:left; vertical-align:top; }}
    th {{ color:#b9c7dc; background:#111722; }}
    code {{ color:#dbeafe; overflow-wrap:anywhere; }}
    .note {{ display:block; margin-top:6px; color:#9fb1c9; line-height:1.35; }}
    pre {{ white-space:pre-wrap; background:#0b0f14; border:1px solid #253044; border-radius:8px; padding:12px; max-height:420px; overflow:auto; }}
  </style>
</head>
<body>
<main>
  <div class="nav">
    <a class="chip" href="index.html">index</a>
    <a class="chip" href="../out/hud_menu_opener_boundary_review.json">opener boundary JSON</a>
    <a class="chip" href="hud_menu_preview.html">HUD menu preview</a>
    <a class="chip" href="menu_descriptor_stack_review.html">descriptor stack</a>
    <a class="chip" href="menu_cancel_window_consumer_review.html">cancel consumer</a>
  </div>
  <h1>HUD Menu Opener Frontier Review</h1>
  <p>{h(summary["decision"])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Input / Descriptor Intersections</h2>
    <table>
      <thead><tr><th>function</th><th>refs</th><th>calls</th><th>promotion</th></tr></thead>
      <tbody>{inter_rows or "<tr><td colspan='4'>No intersections</td></tr>"}</tbody>
    </table>
  </section>
  <section>
    <h2>Next Frontier</h2>
    <ul>{frontier}</ul>
  </section>
  <section>
    <h2>Raw JSON</h2>
    <pre id="json"></pre>
  </section>
</main>
<script>
window.HWANSE_HUD_MENU_OPENER_FRONTIER_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_HUD_MENU_OPENER_FRONTIER_REVIEW, null, 2);
</script>
</body>
</html>
"""


def main() -> int:
    report = build_report()
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    (OUT / "hud_menu_opener_frontier_review.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (WEB / "hud_menu_opener_frontier_review.html").write_text(render_html(report), encoding="utf-8")
    print("hud_menu_opener_frontier_review ok")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
