#!/usr/bin/env python3
"""Summarize the remaining static boundary for the normal-field HUD menu opener.

The status/menu HUD now has several grounded layers: cancel input bits, window
regions, the shared descriptor stack, top/right menu payloads, and status/menu
0x40 VM tables.  The missing proof is narrower: the exact normal-field ESC/X
script that materializes the top-menu object sequence is still not isolated.
"""
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
from summarize_object_payload_442c75_callers import decode_stream


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


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


def summary(data: dict[str, Any]) -> dict[str, Any]:
    value = data.get("summary")
    return value if isinstance(value, dict) else {}


def write_json(path: Path, payload: Any) -> None:
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


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


def scan_cancel_mask_descriptor_add_streams() -> dict[str, Any]:
    """Scan for a simple command stream carrying the ESC/X action bit and add op.

    The 0x0200 value is grounded as the packed input action bit.  This probe
    only tests whether that same value is embedded in linear decoded command
    streams as an opcode 0x1d mask next to descriptor-add 0x62.
    """
    if not EXE.exists():
        return {
            "status": "missing-exe",
            "maskHex": f"0x{CANCEL_INPUT_MASK:08x}",
            "candidateCount": 0,
            "candidates": [],
        }

    exe = EXE.read_bytes()
    sections = read_sections(exe)
    rows: list[dict[str, Any]] = []
    seen: set[int] = set()
    mask_bytes = struct.pack("<I", CANCEL_INPUT_MASK)

    for section in sections:
        if section["name"] not in {".data", ".rdata"}:
            continue
        raw_start = int(section["raw"])
        raw_end = raw_start + int(section["raw_size"])
        data = exe[raw_start:raw_end]
        index = 0
        while True:
            hit = data.find(b"\x1d", index)
            if hit < 0:
                break
            file_offset = raw_start + hit
            # opcode 0x1d has the input mask at +4 in the decoded stream shape.
            if file_offset + 12 <= len(exe) and exe[file_offset + 4:file_offset + 8] == mask_bytes:
                hit_va = offset_to_va(sections, file_offset)
                if hit_va is not None:
                    for start_va in range(max(int(section["va"]), hit_va - 0x40), hit_va + 1, 4):
                        if start_va in seen:
                            continue
                        seen.add(start_va)
                        try:
                            decoded = decode_stream(exe, sections, start_va, max_commands=40, max_bytes=0x200)
                        except Exception:
                            continue
                        commands = decoded.get("commands")
                        if not isinstance(commands, list):
                            continue
                        has_cancel = any(
                            isinstance(cmd, dict)
                            and cmd.get("opcode") == 0x1D
                            and cmd.get("maskHex") == f"0x{CANCEL_INPUT_MASK:08x}"
                            for cmd in commands
                        )
                        descriptor_adds = [
                            cmd
                            for cmd in commands
                            if isinstance(cmd, dict) and cmd.get("opcode") == 0x62
                        ]
                        if has_cancel and descriptor_adds:
                            rows.append(
                                {
                                    "startVaHex": decoded.get("startVaHex"),
                                    "decodedCommandCount": decoded.get("decodedCommandCount"),
                                    "descriptorAddCount": len(descriptor_adds),
                                    "descriptorOperands": [
                                        cmd.get("operandIndex")
                                        for cmd in descriptor_adds
                                    ],
                                    "commands": [
                                        {
                                            "vaHex": cmd.get("vaHex"),
                                            "opcodeHex": cmd.get("opcodeHex"),
                                            "opcodeName": cmd.get("opcodeName"),
                                            "summary": cmd.get("summary"),
                                            "maskHex": cmd.get("maskHex"),
                                            "operandIndex": cmd.get("operandIndex"),
                                        }
                                        for cmd in commands
                                        if isinstance(cmd, dict)
                                    ],
                                }
                            )
            index = hit + 1

    if rows:
        interpretation = "Review candidates manually; a same-stream opener shape may exist."
    elif seen:
        interpretation = (
            "Decoded command streams with the ESC/X action bit were found, but none also carry "
            "descriptor-add opcode 0x62.  A simple same-stream opener shape is excluded for this scope."
        )
    else:
        interpretation = (
            "No .data/.rdata linear command stream was found with opcode 0x1d using the confirmed ESC/X "
            "action bit directly.  The opener likely consumes the packed input bit through x86/control "
            "state first, then reaches descriptor materialization indirectly."
        )

    return {
        "status": "scanned",
        "maskHex": f"0x{CANCEL_INPUT_MASK:08x}",
        "candidateCount": len(rows),
        "scannedStartCount": len(seen),
        "scanScope": ".data/.rdata stream starts within 0x40 bytes before opcode 0x1d cancel-mask branches",
        "candidates": rows[:50],
        "interpretation": interpretation,
    }


def build_report() -> dict[str, Any]:
    cancel = summary(read_json("menu_cancel_window_consumer_review.json"))
    descriptor_full = read_json("menu_descriptor_stack_review.json")
    descriptor = summary(descriptor_full)
    strict_inventory = descriptor_full.get("strictInventoryOpenerProbe")
    strict_inventory = strict_inventory if isinstance(strict_inventory, dict) else {}
    right_menu = read_json("menu_right_panel_ui_review.json")
    status_ui = summary(read_json("status_menu_ui_expression_review.json"))
    status_vm = summary(read_json("status_menu_vm_table_review.json"))
    comment = summary(read_json("status_comment_selector_binding_review.json"))
    cancel_add_stream_scan = scan_cancel_mask_descriptor_add_streams()

    right_regions = right_menu.get("regions") if isinstance(right_menu.get("regions"), list) else []
    evidence = right_menu.get("evidence") if isinstance(right_menu.get("evidence"), list) else []

    surfaces = [
        {
            "surface": "keyboard cancel/back action",
            "promotion": "confirmed-input-consumer",
            "artifact": "menu_cancel_window_consumer_review.json",
            "finding": (
                f"input packer {cancel.get('inputPackerVaHex')}, edge mask {cancel.get('edgeActionMaskVaHex')}; "
                f"cancel action {((cancel.get('cancelAction') or {}).get('index'))} "
                f"bit {((cancel.get('cancelAction') or {}).get('bitHex'))}; "
                f"consumer opcode {cancel.get('cancelConsumerOpcode')} handler {cancel.get('cancelConsumerHandlerVaHex')}."
            ),
            "gap": "취소/뒤로가기 consumer는 확정이지만, 필드에서 최초 상태창을 여는 opener는 아니다.",
        },
        {
            "surface": "window region draw / template binding",
            "promotion": "confirmed-regions",
            "artifact": "menu_right_panel_ui_review.json + menu_cancel_window_consumer_review.json",
            "finding": (
                f"normal HUD regions {cancel.get('normalHudRegionIndexes')}; "
                f"top menu candidates {cancel.get('topMenuRegionCandidateIndexes')}; "
                f"right menu region count {len(right_regions)}."
            ),
            "gap": "region #1/#2/#3/#4/#6 위치와 window template은 확정됐지만, opener root와는 별도다.",
        },
        {
            "surface": "status left region #6 payload",
            "promotion": "confirmed-payload",
            "artifact": "status_menu_ui_expression_review.json",
            "finding": (
                f"payload {status_ui.get('statusPayloadVaHex')}..{status_ui.get('statusPayloadEndVaHex')}; "
                f"region {((status_ui.get('windowRegion') or {}).get('index'))}; "
                f"actor row {status_ui.get('actorRowBaseVaHex')} stride {status_ui.get('actorRowStrideHex')}; "
                f"stat coverage {status_ui.get('statOffsetCoverage')}."
            ),
            "gap": "상태창 그리기 core는 확정됐지만, ESC/X opener 선택 시점은 별도 producer다.",
        },
        {
            "surface": "right menu #2/#3/#4 payload",
            "promotion": "confirmed-payload / opener-pending",
            "artifact": "menu_right_panel_ui_review.json",
            "finding": (
                f"status={right_menu.get('status')}; "
                f"evidence rows {len(evidence)}; "
                "top menu icons #0,#1,#2,#3,#8,#4 and sub-list/page models are grounded."
            ),
            "gap": "payload 자체는 top-menu object construction에 attach되지만 normal-field opener는 아직 없다.",
        },
        {
            "surface": "shared active descriptor stack",
            "promotion": "confirmed-materializer / opener-blocked",
            "artifact": "menu_descriptor_stack_review.json",
            "finding": (
                f"add {descriptor.get('addRoutineVaHex')}; remove {descriptor.get('removeRoutineVaHex')}; "
                f"active count {descriptor.get('activeCountVaHex')}; slot table {descriptor.get('slotTableVaHex')}; "
                f"topMenuObjectSequence {descriptor.get('topMenuObjectSequenceVaHex')}; "
                f"add callers opcode/non-opcode "
                f"{descriptor.get('addRoutineOpcodeHandlerCallCount')}/{descriptor.get('addRoutineNonOpcodeCallCount')}; "
                f"remove callers opcode/non-opcode "
                f"{descriptor.get('removeRoutineOpcodeHandlerCallCount')}/{descriptor.get('removeRoutineNonOpcodeCallCount')}."
            ),
            "gap": (
                f"field ESC opener refs {descriptor.get('topMenuObjectSequenceFieldEscOpenerRefs')}; "
                f"direct refs {descriptor.get('topMenuObjectSequenceDirectRefs')} "
                f"({descriptor.get('topMenuObjectSequenceDirectRefInterpretation')}). "
                "The add/remove materializers are reached only through opcode handlers, so the opener must be an upstream command stream/root rather than a direct x86 caller."
            ),
        },
        {
            "surface": "strict active-object inventory opener exclusion",
            "promotion": "negative-static-proof / opener-outside-inventory",
            "artifact": "menu_descriptor_stack_review.json:strictInventoryOpenerProbe",
            "finding": (
                f"script count {strict_inventory.get('scriptCount')}; "
                f"stack-op scripts {strict_inventory.get('stackOpScriptCount')}; "
                f"input-like scripts {strict_inventory.get('inputLikeScriptCount')}; "
                f"descriptor-add scripts {strict_inventory.get('descriptorAddScriptCount')}; "
                f"cancel-op scripts {strict_inventory.get('cancelOpScriptCount')}."
            ),
            "gap": (
                "strict active-object +0xec inventory 안에는 0x62 descriptor-add와 0x9f cancel/back이 없다. "
                "정상 필드 opener는 higher-level field controller 또는 mode/state dispatcher 쪽으로 밀린다."
            ),
        },
        {
            "surface": "same-stream ESC/X cancel + descriptor-add probe",
            "promotion": "negative-static-proof / simple-stream-opener-excluded",
            "artifact": "hud_menu_opener_boundary_review.json:cancelMaskDescriptorAddStreamScan",
            "finding": (
                f"mask {cancel_add_stream_scan.get('maskHex')}; "
                f"scope {cancel_add_stream_scan.get('scanScope')}; "
                f"scanned starts {cancel_add_stream_scan.get('scannedStartCount')}; "
                f"candidate streams {cancel_add_stream_scan.get('candidateCount')}."
            ),
            "gap": cancel_add_stream_scan.get("interpretation", ""),
        },
        {
            "surface": "status/menu 0x40 VM tables",
            "promotion": "confirmed-table-shapes / producers-pending",
            "artifact": "status_menu_vm_table_review.json",
            "finding": (
                f"opcode14 tables {status_vm.get('opcode14TableCount')}; "
                f"opcode13 tables {status_vm.get('opcode13TableCount')}; "
                f"branch lists {status_vm.get('opcode1fBranchListCount')}; "
                f"promotion {status_vm.get('promotionStatus')}."
            ),
            "gap": "각 table shape은 확정됐지만, 현재 선택 actor/menu/page를 계산하는 root producer는 별도다.",
        },
        {
            "surface": "status short-comment selector",
            "promotion": "confirmed-selector-array / producer-pending",
            "artifact": "status_comment_selector_binding_review.json",
            "finding": (
                f"comment groups {comment.get('commentGroupCount')}; actor tables {comment.get('actorTableCount')}; "
                f"selector array count {comment.get('selectorArrayCount')}; "
                f"all blocks matched {comment.get('selectorArrayAllBlocksMatched')}."
            ),
            "gap": f"producer status {comment.get('selectorProducerProbeStatus')}.",
        },
    ]

    return {
        "kind": "hwanse-hud-menu-opener-boundary-review",
        "status": "payloads-grounded-normal-field-opener-unproven",
        "source": [
            "out/menu_cancel_window_consumer_review.json",
            "out/menu_descriptor_stack_review.json",
            "out/menu_right_panel_ui_review.json",
            "out/status_menu_ui_expression_review.json",
            "out/status_menu_vm_table_review.json",
            "out/status_comment_selector_binding_review.json",
            "out/menu_descriptor_stack_review.json:strictInventoryOpenerProbe",
            "tools/build_hud_menu_opener_boundary_review.py",
        ],
        "summary": {
            "surfaceCount": len(surfaces),
            "confirmedSurfaceCount": sum(
                1
                for row in surfaces
                if row["promotion"].startswith("confirmed")
            ),
            "normalFieldEscOpenerProven": False,
            "topMenuObjectSequenceVaHex": descriptor.get("topMenuObjectSequenceVaHex"),
            "topMenuObjectSequenceFieldEscOpenerRefs": descriptor.get("topMenuObjectSequenceFieldEscOpenerRefs"),
            "topRegionScriptDataHits": descriptor.get("topRegionScriptDataHits", 0),
            "exactTopContextInitializerRows": descriptor.get("exactTopContextInitializerRows", 0),
            "strictInventoryDescriptorAddScripts": strict_inventory.get("descriptorAddScriptCount", 0),
            "strictInventoryCancelOpScripts": strict_inventory.get("cancelOpScriptCount", 0),
            "cancelMaskDescriptorAddStreamCandidates": cancel_add_stream_scan.get("candidateCount", 0),
            "cancelMaskDescriptorAddStreamScannedStarts": cancel_add_stream_scan.get("scannedStartCount", 0),
            "addRoutineNonOpcodeCallCount": descriptor.get("addRoutineNonOpcodeCallCount", 0),
            "removeRoutineNonOpcodeCallCount": descriptor.get("removeRoutineNonOpcodeCallCount", 0),
            "cancelConsumerGrounded": bool(cancel.get("cancelConsumerHandlerVaHex")),
            "rightMenuPayloadGrounded": right_menu.get("status") == "top-menu-object-and-right-content-payload-grounded-opener-pending",
            "decision": (
                "ESC/X 입력 bit, cancel consumer, region/template, descriptor stack, status/right-menu payload는 확정됐다. "
                "strict active-object inventory에는 opener가 없다는 음성 증거도 확보했다. "
                "남은 것은 정상 필드 상태에서 ESC/X가 top-menu object sequence를 materialize하는 exact opener root다."
            ),
        },
        "surfaces": surfaces,
        "cancelMaskDescriptorAddStreamScan": cancel_add_stream_scan,
        "nextFrontier": [
            "field controller/input edge mask consumer 주변에서 action 9 bit가 cancel이 아니라 menu-open으로 분기되는 script/root를 찾는다.",
            "topMenuObjectSequence 0x004ddc6c의 direct ref가 intro/title context뿐인 이유를 분리하고 indirect descriptor-add path를 추적한다.",
            "confirmed cancel mask 0x00000200과 descriptor-add 0x62가 같은 linear stream에 없으므로, opener는 같은 command-list 안의 단순 패턴이 아니라 state/mode dispatcher 또는 selected-root 경유일 가능성이 높다.",
            "strict active-object inventory는 제외됐으므로 higher-level field controller 또는 mode/state dispatcher 쪽 descriptor-add materializer를 찾는다.",
            "정적 field controller의 input-action dispatch 표면에서 active descriptor count/order와 object sequence pointer write producer를 찾는다.",
        ],
    }


def render_html(report: dict[str, Any]) -> str:
    s = report["summary"]
    cards = [
        ("status", report["status"]),
        ("surfaces", s["surfaceCount"]),
        ("confirmed", s["confirmedSurfaceCount"]),
        ("ESC opener", "proven" if s["normalFieldEscOpenerProven"] else "unproven"),
        ("top sequence", s["topMenuObjectSequenceVaHex"]),
    ]
    card_html = "".join(f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>" for k, v in cards)
    rows = []
    for row in report["surfaces"]:
        rows.append(
            "<tr>"
            f"<td><b>{h(row['surface'])}</b><br><code>{h(row['artifact'])}</code></td>"
            f"<td>{h(row['promotion'])}</td>"
            f"<td>{h(row['finding'])}</td>"
            f"<td>{h(row['gap'])}</td>"
            "</tr>"
        )
    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 Boundary Review</title>
  <style>
    body {{ margin:0; background:#101318; color:#edf1f7; font-family:system-ui,sans-serif; }}
    main {{ max-width:1240px; 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(170px,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; }}
    pre {{ white-space:pre-wrap; background:#0b0f14; border:1px solid #253044; border-radius:8px; padding:12px; max-height:360px; overflow:auto; }}
  </style>
</head>
<body>
<main>
  <div class="nav">
    <a class="chip" href="index.html">index</a>
    <a class="chip" href="menu_cancel_window_consumer_review.html">cancel consumer</a>
    <a class="chip" href="menu_descriptor_stack_review.html">descriptor stack</a>
    <a class="chip" href="hud_menu_preview.html">HUD menu preview</a>
    <a class="chip" href="../out/menu_right_panel_ui_review.json">right menu JSON</a>
    <a class="chip" href="status_menu_ui_expression_review.html">status UI</a>
    <a class="chip" href="status_menu_vm_table_review.html">0x40 VM tables</a>
  </div>
  <h1>HUD Menu Opener Boundary Review</h1>
  <p>{h(s["decision"])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Opener Boundary</h2>
    <table>
      <thead><tr><th>surface</th><th>promotion</th><th>finding</th><th>gap</th></tr></thead>
      <tbody>{''.join(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_MENU_OPENER_BOUNDARY_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_HUD_MENU_OPENER_BOUNDARY_REVIEW, null, 2);
</script>
</body>
</html>
"""


def main() -> int:
    report = build_report()
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    write_json(OUT / "hud_menu_opener_boundary_review.json", report)
    html_text = render_html(report)
    print("hud_menu_opener_boundary_review ok")
    return 0


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