#!/usr/bin/env python3
"""Trace the HUD top-menu VM graph around the remaining opener boundary.

The x86 and descriptor-stack reviews already proved the #6/#3/#4 HUD menu
payload and ruled out a number of direct opener shapes.  This pass focuses on
the nearby VM command/data graph so later analysis does not accidentally promote
table bytes as command streams.
"""
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
from summarize_object_payload_442c75_callers import decode_stream


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

JSON_OUT = OUT / "hud_menu_vm_graph_frontier_review.json"
HTML_OUT = WEB / "hud_menu_vm_graph_frontier_review.html"

GENERAL_VM_TABLE_VA = 0x00440538
OPCODE14_HANDLER_VA = 0x00403890

TOP_MENU_PARENT_CONTEXT_VA = 0x0047E624
TOP_MENU_PARENT_SELECTED_ROOT_STORE_VA = 0x0047E664
TOP_MENU_POST_SELECTED_ROOT_TABLE_VA = 0x0047E670
TOP_MENU_GATE_VA = 0x0047E688
TOP_MENU_WRAPPER_VA = 0x0047E6A0
TOP_MENU_SEQUENCE_VA = 0x004DDC6C
INTRO_TITLE_RESOURCE_LIST_VA = 0x004A2CB0


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 section_name_for(sections: list[dict[str, Any]], va: int | None) -> str | None:
    if va is None:
        return None
    for section in sections:
        start = int(section["va"])
        end = start + int(section["raw_size"])
        if start <= va < end:
            return str(section["name"])
    return None


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


def bytes_at(exe: bytes, sections: list[dict[str, Any]], va: int, size: int) -> bytes:
    off = va_to_offset(sections, va)
    if off is None:
        return b""
    return exe[off : off + size]


def dword_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 dword_rows(exe: bytes, sections: list[dict[str, Any]], start_va: int, count: int) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for index in range(count):
        entry_va = start_va + index * 4
        value = dword_at(exe, sections, entry_va)
        rows.append(
            {
                "index": index,
                "entryVa": entry_va,
                "entryVaHex": hx(entry_va),
                "value": value,
                "valueHex": hx(value),
                "isVa": va_to_offset(sections, value or 0) is not None if value is not None else False,
                "section": section_name_for(sections, value),
            }
        )
    return rows


def stream_preview(exe: bytes, sections: list[dict[str, Any]], va: int, commands: int = 18) -> dict[str, Any]:
    decoded = decode_stream(exe, sections, va, max_commands=commands, max_bytes=0x160)
    compact = []
    for row in decoded.get("commands", []):
        compact.append(
            {
                "vaHex": row.get("vaHex"),
                "opcodeHex": row.get("opcodeHex"),
                "opcodeName": row.get("opcodeName"),
                "length": row.get("length"),
                "summary": row.get("summary") or "",
                "targetVaHex": row.get("targetVaHex"),
                "payloadVaHex": row.get("payloadVaHex"),
                "pointerOperands": row.get("pointerOperands") or [],
            }
        )
    decoded["commands"] = compact
    return decoded


def resource_list_preview(exe: bytes, sections: list[dict[str, Any]], start_va: int, count: int = 10) -> list[str]:
    off = va_to_offset(sections, start_va)
    if off is None:
        return []
    strings: list[str] = []
    cursor = off
    while cursor < len(exe) and len(strings) < count:
        end = exe.find(b"\x00", cursor)
        if end < 0:
            break
        raw = exe[cursor:end]
        if len(raw) >= 4 and all(0x20 <= byte < 0x7F for byte in raw):
            try:
                strings.append(raw.decode("ascii"))
            except UnicodeDecodeError:
                strings.append(raw.decode("ascii", errors="replace"))
        cursor = end + 1
    return strings


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

    opcode14_dispatch = dword_at(exe, sections, GENERAL_VM_TABLE_VA + 0x14 * 4)
    post_rows = dword_rows(exe, sections, TOP_MENU_POST_SELECTED_ROOT_TABLE_VA, 6)
    first_dword = post_rows[0]["value"]
    first_dword_is_va = bool(first_dword is not None and va_to_offset(sections, first_dword) is not None)
    low_byte_looks_opcode14 = bool(first_dword is not None and (first_dword & 0xFF) == 0x14)
    gate_refs = dword_refs(exe, sections, TOP_MENU_GATE_VA)
    wrapper_refs = dword_refs(exe, sections, TOP_MENU_WRAPPER_VA)
    top_sequence_refs = dword_refs(exe, sections, TOP_MENU_SEQUENCE_VA)
    parent_refs = dword_refs(exe, sections, TOP_MENU_PARENT_CONTEXT_VA)

    parent_stream = stream_preview(exe, sections, TOP_MENU_PARENT_CONTEXT_VA)
    gate_stream = stream_preview(exe, sections, TOP_MENU_GATE_VA)
    wrapper_stream = stream_preview(exe, sections, TOP_MENU_WRAPPER_VA)
    top_sequence_stream = stream_preview(exe, sections, TOP_MENU_SEQUENCE_VA)

    child_scripts = []
    for command in parent_stream["commands"]:
        if command.get("opcodeHex") != "0x07":
            continue
        operands = command.get("pointerOperands") or []
        if not operands:
            continue
        child_va = operands[0].get("value")
        if isinstance(child_va, int):
            child_scripts.append(
                {
                    "sourceCommandVaHex": command.get("vaHex"),
                    "childScriptVaHex": hx(child_va),
                    "preview": stream_preview(exe, sections, child_va, commands=8),
                }
            )

    summary = {
        "opcode14DispatchVaHex": hx(opcode14_dispatch),
        "opcode14DispatchMatchesExpected": opcode14_dispatch == OPCODE14_HANDLER_VA,
        "postSelectedRootTableVaHex": hx(TOP_MENU_POST_SELECTED_ROOT_TABLE_VA),
        "postSelectedRootFirstDwordHex": hx(first_dword),
        "postSelectedRootFirstDwordLowByteLooksOpcode14": low_byte_looks_opcode14,
        "postSelectedRootFirstDwordIsVa": first_dword_is_va,
        "postSelectedRootRejectedAsLinearOpcode14": low_byte_looks_opcode14 and not first_dword_is_va,
        "gateDirectRefCount": len(gate_refs),
        "gateDirectRefsHex": [hx(ref) for ref in gate_refs],
        "wrapperDirectRefCount": len(wrapper_refs),
        "topSequenceDirectRefCount": len(top_sequence_refs),
        "topSequenceDirectRefsHex": [hx(ref) for ref in top_sequence_refs],
        "parentDirectRefCount": len(parent_refs),
        "introTitleResourcePreview": resource_list_preview(exe, sections, INTRO_TITLE_RESOURCE_LIST_VA),
        "normalFieldEscOpenerPromoted": False,
        "decision": (
            "The VM graph still does not prove the normal-field ESC/X opener. "
            "0x0047e624 creates supporting child objects, stores selectedRoot from the inline "
            "0x004a2cb0 intro/title resource list, and calls selectedRoot.  The only top-menu "
            "sequence reference remains the wrapper's opcode 0x04 operand at 0x0047e6ac. "
            "The apparent 0x14 byte at 0x0047e670 is rejected as a linear command because its "
            "overlapped first dword is not a valid VA; the later 0x0047e688 pointer is a table "
            "entry/reference to the wrapper gate, not opener proof."
        ),
    }

    return {
        "kind": "hwanse-hud-menu-vm-graph-frontier-review",
        "source": "tools/build_hud_menu_vm_graph_frontier_review.py",
        "status": "top-menu-vm-graph-grounded-opener-still-pending",
        "summary": summary,
        "watchedVa": {
            "parentContext": hx(TOP_MENU_PARENT_CONTEXT_VA),
            "selectedRootStore": hx(TOP_MENU_PARENT_SELECTED_ROOT_STORE_VA),
            "postSelectedRootTable": hx(TOP_MENU_POST_SELECTED_ROOT_TABLE_VA),
            "gate": hx(TOP_MENU_GATE_VA),
            "wrapper": hx(TOP_MENU_WRAPPER_VA),
            "topSequence": hx(TOP_MENU_SEQUENCE_VA),
            "introTitleResourceList": hx(INTRO_TITLE_RESOURCE_LIST_VA),
        },
        "postSelectedRootTableRows": post_rows,
        "streamPreviews": {
            "parentContext": parent_stream,
            "gate": gate_stream,
            "wrapper": wrapper_stream,
            "topSequence": top_sequence_stream,
        },
        "parentChildScripts": child_scripts,
        "negativeEvidence": [
            "0x0047e670 is not accepted as a linear opcode 0x14 command because dword[0x0047e670] is not a file-backed VA.",
            "0x0047e688 is referenced only as a table entry from 0x0047e678 in this graph.",
            "0x004ddc6c is only referenced by the wrapper call operand at 0x0047e6b0.",
            "0x004a2cb0 is the compile/aaa/logo/title/btl_k intro-title resource list, so the parent remains intro/title-context.",
        ],
        "nextFrontier": [
            "Find a non-intro field state/root stream that reaches a menu-equivalent wrapper or descriptor attachment.",
            "If opcode 0x14 is used elsewhere as a branch command, accept it only when dword[stream] is a file-backed target VA.",
            "Trace producers of object+0x40 stream pointers for the normal field mode rather than promoting nearby pointer tables.",
        ],
    }


def render_table(rows: list[dict[str, Any]], columns: list[tuple[str, str]]) -> str:
    body = []
    for row in rows:
        body.append(
            "<tr>"
            + "".join(f"<td>{h(row.get(key))}</td>" for key, _label in columns)
            + "</tr>"
        )
    head = "".join(f"<th>{h(label)}</th>" for _key, label in columns)
    return f"<table><thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody></table>"


def render_commands(stream: dict[str, Any]) -> str:
    rows = stream.get("commands") or []
    body = []
    for row in rows:
        body.append(
            "<tr>"
            f"<td><code>{h(row.get('vaHex'))}</code></td>"
            f"<td><code>{h(row.get('opcodeHex'))}</code><br>{h(row.get('opcodeName'))}</td>"
            f"<td>{h(row.get('length'))}</td>"
            f"<td>{h(row.get('summary'))}</td>"
            "</tr>"
        )
    return "<table><thead><tr><th>VA</th><th>opcode</th><th>len</th><th>summary</th></tr></thead><tbody>" + "".join(body) + "</tbody></table>"


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    cards = [
        ("status", report["status"]),
        ("op14 handler", summary["opcode14DispatchVaHex"]),
        ("post table rejected", summary["postSelectedRootRejectedAsLinearOpcode14"]),
        ("gate refs", summary["gateDirectRefCount"]),
        ("top refs", summary["topSequenceDirectRefCount"]),
        ("opener", summary["normalFieldEscOpenerPromoted"]),
    ]
    card_html = "".join(f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>" for k, v in cards)
    post_table = render_table(
        report["postSelectedRootTableRows"],
        [("index", "#"), ("entryVaHex", "entry"), ("valueHex", "value"), ("isVa", "is VA"), ("section", "section")],
    )
    streams = "".join(
        f"<section><h2>{h(name)}</h2>{render_commands(stream)}</section>"
        for name, stream in report["streamPreviews"].items()
    )
    children = "".join(
        f"<section><h2>Child {h(row['childScriptVaHex'])}</h2><p>from <code>{h(row['sourceCommandVaHex'])}</code></p>{render_commands(row['preview'])}</section>"
        for row in report["parentChildScripts"]
    )
    negatives = "".join(f"<li>{h(item)}</li>" for item in report["negativeEvidence"])
    next_items = "".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">
  <title>HUD Menu VM Graph Frontier</title>
  <style>
    body {{ margin: 0; padding: 24px; font-family: system-ui, sans-serif; background: #f7f2e8; color: #1f2430; }}
    h1 {{ margin: 0 0 8px; }}
    h2 {{ margin: 22px 0 8px; }}
    .sub {{ color: #5d6470; margin-top: 0; }}
    .cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin: 18px 0; }}
    .card {{ background: #fffaf0; border: 1px solid #d7c7a8; border-radius: 6px; padding: 10px 12px; }}
    .card b {{ display: block; color: #6b421a; font-size: 12px; }}
    .card span {{ font-weight: 700; }}
    section {{ background: white; border: 1px solid #decfb2; border-radius: 6px; padding: 14px; margin: 14px 0; overflow: auto; }}
    table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
    th, td {{ border-bottom: 1px solid #eadcc5; text-align: left; vertical-align: top; padding: 7px 8px; }}
    th {{ background: #f3e6d0; }}
    code {{ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }}
    pre {{ white-space: pre-wrap; background: #272822; color: #f8f8f2; padding: 12px; border-radius: 6px; }}
  </style>
</head>
<body>
  <h1>HUD Menu VM Graph Frontier</h1>
  <p class="sub">top-menu wrapper/sequence 주변 VM graph와 0x14/table false promotion 방지 리포트.</p>
  <div class="cards">{card_html}</div>
  <section>
    <h2>Decision</h2>
    <p>{h(summary["decision"])}</p>
  </section>
  <section>
    <h2>Post SelectedRoot Table</h2>
    {post_table}
  </section>
  {streams}
  {children}
  <section>
    <h2>Negative Evidence</h2>
    <ul>{negatives}</ul>
    <h2>Next Frontier</h2>
    <ul>{next_items}</ul>
  </section>
  <section>
    <h2>Raw JSON</h2>
    <pre id="payload"></pre>
  </section>
  <script>
    const payload = {payload};
    document.getElementById('payload').textContent = JSON.stringify(payload, null, 2);
  </script>
</body>
</html>
"""


def main() -> None:
    report = build_report()
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    JSON_OUT.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
    HTML_OUT.write_text(render_html(report), encoding="utf-8")
    print("hud_menu_vm_graph_frontier_review ok")


if __name__ == "__main__":
    main()
