#!/usr/bin/env python3
"""Classify the HUD/menu input consumer boundary.

The menu payload and top-menu sequence are grounded elsewhere.  This pass does
not try to rediscover those assets.  It asks a narrower question:

* Does the ESC/X edge bit consumer open the normal field menu?
* Or does it only consume cancel/back after an active descriptor stack exists?

The answer matters because the same input bit is used by several already-open
menu/status contexts.  Treating every 0x0200 consumer as an opener kept pulling
the analysis back into confirmed close/back code.
"""
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
ACTIVE_DESCRIPTOR_COUNT_VA = 0x004576E8
ACTIVE_DESCRIPTOR_CURSOR_VA = 0x0059E33E
ACTIVE_DESCRIPTOR_SLOT_POINTERS_VA = 0x0059DB30
ACTIVE_DESCRIPTOR_TEMP_STATE_VA = 0x0059DD6C
ADD_DESCRIPTOR_ROUTINE_VA = 0x00431FE8
REMOVE_DESCRIPTOR_ROUTINE_VA = 0x00432541
REBUILD_DESCRIPTOR_ROUTINE_VA = 0x00432323
TOP_MENU_OBJECT_SEQUENCE_VA = 0x004DDC6C

CANCEL_BACK_HANDLER_VA = 0x0040C6CC
MENU_INTERNAL_DISPATCHER_VA = 0x0041D61B
DIRECTION_SELECTOR_HANDLER_VA = 0x0040B13B
INPUT_POLLER_VA = 0x00422D74
FIELD_CONTROLLER_VA = 0x0043022D

FOCUS_OUT_ROUTINE_VA = 0x00422093
FOCUS_IN_ROUTINE_VA = 0x004226E9
MENU_RESULT_ROUTINE_VA = 0x0042AF73
CURSOR_MOVE_ROUTINE_VA = 0x00410570
MENU_CURSOR_APPLY_ROUTINE_VA = 0x004106BA

CANCEL_BIT_MASK = 0x0200


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 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 function_ranges(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    text = text_section(sections)
    raw_start = int(text["raw"])
    raw_end = raw_start + int(text["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
        if index + 1 < len(starts):
            end_va = starts[index + 1]
            end_off = va_to_offset(sections, end_va)
        else:
            end_va = int(text["va"]) + int(text["raw_size"])
            end_off = raw_end
        if end_off is None:
            end_off = raw_end
        rows.append(
            {
                "startVa": start_va,
                "endVa": end_va,
                "startOff": start_off,
                "endOff": max(start_off, end_off),
                "size": max(0, end_off - start_off),
            }
        )
    return rows


def find_function(functions: list[dict[str, Any]], va: int) -> dict[str, Any]:
    for row in functions:
        if int(row["startVa"]) <= va < int(row["endVa"]):
            return row
    raise ValueError(f"function not found for {hx(va)}")


def read_function_bytes(exe: bytes, row: dict[str, Any]) -> bytes:
    return exe[int(row["startOff"]): int(row["endOff"])]


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 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 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 call_targets_in_function(exe: bytes, sections: list[dict[str, Any]], row: dict[str, Any]) -> list[dict[str, Any]]:
    calls: list[dict[str, Any]] = []
    text = text_section(sections)
    text_start = int(text["va"])
    text_end = text_start + int(text["raw_size"])
    start = int(row["startOff"])
    end = int(row["endOff"])
    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]
        target_va = call_va + 5 + rel
        if text_start <= target_va < text_end:
            calls.append({"callVa": call_va, "targetVa": target_va})
    return calls


def find_pattern_vas(exe: bytes, sections: list[dict[str, Any]], row: dict[str, Any], pattern: bytes) -> list[int]:
    data = read_function_bytes(exe, row)
    out: list[int] = []
    pos = data.find(pattern)
    while pos >= 0:
        va = offset_to_va(sections, int(row["startOff"]) + pos)
        if va is not None:
            out.append(va)
        pos = data.find(pattern, pos + 1)
    return out


def contains_call(row: dict[str, Any], call_refs: list[int]) -> bool:
    return bool(refs_in_function(row, call_refs))


def function_summary(
    exe: bytes,
    sections: list[dict[str, Any]],
    row: dict[str, Any],
    refs_by_name: dict[str, list[int]],
    calls_by_name: dict[str, list[int]],
    label: str,
    classification: str,
    note: str,
) -> dict[str, Any]:
    calls = call_targets_in_function(exe, sections, row)
    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)
    }
    cancel_tests = find_pattern_vas(exe, sections, row, b"\x66\xa1\x12\xe3\x59\x00\xf6\xc4\x02")
    cancel_clears = find_pattern_vas(
        exe,
        sections,
        row,
        b"\x66\xa1\x12\xe3\x59\x00\x25\xff\xfd\xff\xff\x66\xa3\x12\xe3\x59\x00",
    )
    return {
        "label": label,
        "functionVaHex": hx(int(row["startVa"])),
        "endVaHex": hx(int(row["endVa"])),
        "size": row["size"],
        "classification": classification,
        "refHits": ref_hits,
        "callHits": call_hits,
        "callTargets": [
            {
                "callVaHex": hx(call["callVa"]),
                "targetVaHex": hx(call["targetVa"]),
            }
            for call in calls
        ],
        "cancelBitTestVasHex": [hx(va) for va in cancel_tests],
        "cancelBitClearVasHex": [hx(va) for va in cancel_clears],
        "hasCancelBitTest": bool(cancel_tests),
        "hasCancelBitClear": bool(cancel_clears),
        "callsAddDescriptor": contains_call(row, calls_by_name["addDescriptor"]),
        "referencesTopMenuSequence": bool(ref_hits.get("topMenu.objectSequence")),
        "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.activeCursor": text_refs(exe, sections, ACTIVE_DESCRIPTOR_CURSOR_VA),
        "descriptor.slotPointers": text_refs(exe, sections, ACTIVE_DESCRIPTOR_SLOT_POINTERS_VA),
        "descriptor.tempState": text_refs(exe, sections, ACTIVE_DESCRIPTOR_TEMP_STATE_VA),
        "topMenu.objectSequence": text_refs(exe, sections, TOP_MENU_OBJECT_SEQUENCE_VA),
    }
    calls_by_name = {
        "addDescriptor": call_sites(exe, sections, ADD_DESCRIPTOR_ROUTINE_VA),
        "removeDescriptor": call_sites(exe, sections, REMOVE_DESCRIPTOR_ROUTINE_VA),
        "rebuildDescriptor": call_sites(exe, sections, REBUILD_DESCRIPTOR_ROUTINE_VA),
        "focusOut": call_sites(exe, sections, FOCUS_OUT_ROUTINE_VA),
        "focusIn": call_sites(exe, sections, FOCUS_IN_ROUTINE_VA),
        "menuResult": call_sites(exe, sections, MENU_RESULT_ROUTINE_VA),
        "cursorMove": call_sites(exe, sections, CURSOR_MOVE_ROUTINE_VA),
        "menuCursorApply": call_sites(exe, sections, MENU_CURSOR_APPLY_ROUTINE_VA),
    }

    cancel_row = find_function(functions, CANCEL_BACK_HANDLER_VA)
    internal_row = find_function(functions, MENU_INTERNAL_DISPATCHER_VA)
    direction_row = find_function(functions, DIRECTION_SELECTOR_HANDLER_VA)

    key_functions = [
        function_summary(
            exe,
            sections,
            cancel_row,
            refs_by_name,
            calls_by_name,
            "0x0200 cancel/back consumer",
            "cancel/back-active-descriptor-unwind",
            (
                "Consumes and clears edge input bit 0x0200, rewrites object+0x40 to the stream "
                "continuation, decrements/scans active descriptor cursor 0x0059e33e, and calls "
                "focus routines 0x00422093/0x004226e9. It has no call to addDescriptor and no "
                "top-menu sequence reference."
            ),
        ),
        function_summary(
            exe,
            sections,
            internal_row,
            refs_by_name,
            calls_by_name,
            "menu-internal selector dispatcher",
            "already-open-menu-dispatcher",
            (
                "Uses 0x0200 only inside an object payload dispatcher after menu state exists. "
                "It calls 0x0042af73, writes 0x0059dd6c, marks object+0xa8[...] = 0xff, "
                "clears the bit, and advances object+0xb0."
            ),
        ),
        function_summary(
            exe,
            sections,
            direction_row,
            refs_by_name,
            calls_by_name,
            "direction edge selector",
            "direction-navigation-only",
            (
                "Consumes direction edge bits 0x1/0x2/0x4/0x8 and calls 0x00410570 with "
                "direction indexes. It does not test 0x0200 and does not add descriptors."
            ),
        ),
    ]

    input_consumer_starts = sorted(
        {
            int(find_function(functions, ref)["startVa"])
            for ref in refs_by_name["input.currentActionMask"] + refs_by_name["input.edgeActionMask"]
        }
    )
    input_consumer_rows = []
    for start_va in input_consumer_starts:
        row = find_function(functions, start_va)
        input_consumer_rows.append(
            {
                "functionVaHex": hx(start_va),
                "endVaHex": hx(int(row["endVa"])),
                "edgeRefsHex": [hx(ref) for ref in refs_in_function(row, refs_by_name["input.edgeActionMask"])],
                "currentRefsHex": [hx(ref) for ref in refs_in_function(row, refs_by_name["input.currentActionMask"])],
                "callsAddDescriptor": contains_call(row, calls_by_name["addDescriptor"]),
                "referencesTopMenuSequence": bool(refs_in_function(row, refs_by_name["topMenu.objectSequence"])),
                "knownRole": {
                    CANCEL_BACK_HANDLER_VA: "cancel/back consumer",
                    MENU_INTERNAL_DISPATCHER_VA: "menu-internal dispatcher",
                    DIRECTION_SELECTOR_HANDLER_VA: "direction selector",
                    INPUT_POLLER_VA: "input poll driver",
                    FIELD_CONTROLLER_VA: "field movement/collision controller",
                }.get(start_va, "input consumer"),
            }
        )

    cancel_summary = key_functions[0]
    internal_summary = key_functions[1]
    direction_summary = key_functions[2]
    report = {
        "kind": "hwanse-hud-input-consumer-boundary-review",
        "source": "tools/build_hud_input_consumer_boundary_review.py",
        "exe": str(EXE),
        "status": "input-consumers-grounded-opener-still-unproven",
        "summary": {
            "inputConsumerFunctionCount": len(input_consumer_rows),
            "cancelBackHandlerVaHex": hx(CANCEL_BACK_HANDLER_VA),
            "cancelBitHex": hx(CANCEL_BIT_MASK, 4),
            "cancelBackClearsBit": cancel_summary["hasCancelBitClear"],
            "cancelBackUsesActiveDescriptorCursor": bool(cancel_summary["refHits"].get("descriptor.activeCursor")),
            "cancelBackReferencesActiveCount": bool(cancel_summary["refHits"].get("descriptor.activeCount")),
            "cancelBackReferencesSlotPointers": bool(cancel_summary["refHits"].get("descriptor.slotPointers")),
            "cancelBackCallsAddDescriptor": cancel_summary["callsAddDescriptor"],
            "cancelBackReferencesTopMenuSequence": cancel_summary["referencesTopMenuSequence"],
            "menuInternalDispatcherUsesCancelBit": internal_summary["hasCancelBitTest"],
            "menuInternalCallsAddDescriptor": internal_summary["callsAddDescriptor"],
            "directionSelectorUsesCancelBit": direction_summary["hasCancelBitTest"],
            "inputConsumersCallingAddDescriptor": sum(1 for row in input_consumer_rows if row["callsAddDescriptor"]),
            "inputConsumersReferencingTopMenuSequence": sum(1 for row in input_consumer_rows if row["referencesTopMenuSequence"]),
            "normalFieldEscOpenerPromoted": False,
            "classification": "input/cancel consumers grounded; opener producer still unproven",
            "decision": (
                "0x0200 ESC/X edge bit의 주요 소비자는 메뉴 최초 opener가 아니라 active descriptor "
                "stack을 되감는 cancel/back 경계다. 0x0040c6cc는 bit clear, active cursor "
                "scan, focus in/out 호출만 수행하며 addDescriptor/top-menu sequence 근거가 없다. "
                "0x0041d61b도 이미 열린 메뉴 내부 selector dispatcher로 분류된다."
            ),
        },
        "keyFunctions": key_functions,
        "inputConsumerFunctions": input_consumer_rows,
        "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()},
        "nextFrontier": [
            "ESC/X opener는 0x0200 bit consumer 자체가 아니라 field mode/state transition 또는 descriptor attachment producer에서 찾아야 한다.",
            "0x0040c6cc/0x0041d61b는 close/back/internal dispatcher로 고정하고 반복 후보에서 제외한다.",
            "다음 증거는 active descriptor stack에 top-menu wrapper/sequence를 attach하는 producer, 또는 normal-field state에서 top-menu wrapper를 호출하는 상위 dispatcher다.",
        ],
    }
    return report


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    cards = [
        ("status", report["status"]),
        ("input funcs", summary["inputConsumerFunctionCount"]),
        ("cancel handler", summary["cancelBackHandlerVaHex"]),
        ("cancel bit", summary["cancelBitHex"]),
        ("open promoted", summary["normalFieldEscOpenerPromoted"]),
    ]
    card_html = "".join(f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>" for k, v in cards)
    key_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['functionVaHex'])}</code><br>{h(row['label'])}</td>"
        f"<td>{h(row['classification'])}<br><span class='note'>{h(row['note'])}</span></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(', '.join(row['cancelBitTestVasHex']) or '-')}<br>clear: {h(', '.join(row['cancelBitClearVasHex']) or '-')}</td>"
        "</tr>"
        for row in report["keyFunctions"]
    )
    consumer_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['functionVaHex'])}</code><br>{h(row['knownRole'])}</td>"
        f"<td>{h(', '.join(row['edgeRefsHex']) or '-')}</td>"
        f"<td>{h(', '.join(row['currentRefsHex']) or '-')}</td>"
        f"<td>{h(row['callsAddDescriptor'])}</td>"
        f"<td>{h(row['referencesTopMenuSequence'])}</td>"
        "</tr>"
        for row in report["inputConsumerFunctions"]
    )
    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 Input Consumer Boundary Review</title>
  <style>
    body {{ margin:0; background:#101318; color:#edf1f7; font-family:system-ui,sans-serif; }}
    main {{ max-width:1320px; 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:1100px; 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="hud_menu_opener_frontier_review.html">opener frontier</a>
    <a class="chip" href="hud_active_group_gate_frontier_review.html">active group gate</a>
    <a class="chip" href="hud_menu_preview.html">HUD menu preview</a>
    <a class="chip" href="../out/hud_input_consumer_boundary_review.json">JSON</a>
  </div>
  <h1>HUD Input Consumer Boundary Review</h1>
  <p>{h(summary["decision"])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Key Functions</h2>
    <table>
      <thead><tr><th>function</th><th>classification</th><th>refs</th><th>calls</th><th>0x0200 evidence</th></tr></thead>
      <tbody>{key_rows}</tbody>
    </table>
  </section>
  <section>
    <h2>Input Consumer Inventory</h2>
    <table>
      <thead><tr><th>function</th><th>edge refs</th><th>current refs</th><th>calls addDescriptor</th><th>top-menu ref</th></tr></thead>
      <tbody>{consumer_rows}</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_INPUT_CONSUMER_BOUNDARY_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_HUD_INPUT_CONSUMER_BOUNDARY_REVIEW, null, 2);
</script>
</body>
</html>
"""


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


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