#!/usr/bin/env python3
"""Review selected-root/add-descriptor candidates for the normal HUD menu opener.

The descriptor stack is grounded and the previous frontier excluded direct
input/descriptor intersections as the normal-field ESC/X opener.  This pass
checks the next static frontier:

* Is the top menu object-construction sequence duplicated anywhere else?
* Do selected-root tables point at that sequence or at its exact region
  child initializers?
* Do runtime descriptor add commands (opcode 0x62) occur in a context that
  also reaches the top menu object sequence?

Promotion is intentionally strict.  A row is not considered an opener unless
it connects descriptor add/root selection to the exact #6/#3/#4 top-menu
construction sequence, not merely to a prompt/event stream.
"""
from __future__ import annotations

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

from build_menu_descriptor_stack_review import (
    is_asset_ascii_false_opcode,
    monotonic_u16_table_around,
    script_data_sections,
)
from probe_exe_scene_tables import offset_to_va, read_sections, va_to_offset
from summarize_object_payload_442c75_callers import (
    c_string_preview,
    decode_command,
    decode_stream,
    is_va,
    section_name_for,
)


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

TOP_MENU_SEQUENCE_VA = 0x004DDC6C
TOP_MENU_REGION6_SCRIPT_VA = 0x004DEE18
TOP_MENU_REGION3_SCRIPT_VA = 0x004DEE58
TOP_MENU_REGION4_SCRIPT_VA = 0x004DEE98
SHIFTED_REGION3_SCRIPT_VA = 0x004DEF48
SHIFTED_REGION4_SCRIPT_VA = 0x004DEF88
TOP_MENU_PARENT_SELECTED_SCRIPT_VA = 0x004A2D38

EXACT_TOP_MENU_PATTERN = (
    b"\x07\x00\x00\x00\x18\xee\x4d\x00"
    b"\x28\x00\x07\x00"
    b"\x07\x00\x00\x00\x58\xee\x4d\x00"
    b"\x28\x00\x08\x00"
    b"\x07\x00\x00\x00\x98\xee\x4d\x00"
    b"\x28\x00\x09\x00"
)
EXACT_RIGHT_PANEL_PATTERN = (
    b"\x07\x00\x00\x00\x58\xee\x4d\x00"
    b"\x28\x00\x08\x00"
    b"\x07\x00\x00\x00\x98\xee\x4d\x00"
    b"\x28\x00\x09\x00"
)
SHIFTED_RIGHT_PANEL_PATTERN = (
    b"\x07\x00\x00\x00\x48\xef\x4d\x00"
    b"\x28\x00\x08\x00"
    b"\x07\x00\x00\x00\x88\xef\x4d\x00"
    b"\x28\x00\x09\x00"
)


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 scan_pattern(exe: bytes, sections: list[dict[str, Any]], pattern: bytes) -> list[int]:
    hits: list[int] = []
    for section in script_data_sections(sections):
        start = int(section["raw"])
        end = start + int(section["raw_size"])
        pos = exe.find(pattern, start, end)
        while pos != -1:
            va = offset_to_va(sections, pos)
            if va is not None:
                hits.append(va)
            pos = exe.find(pattern, pos + 1, end)
    return hits


def all_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 read_table_values(exe: bytes, sections: list[dict[str, Any]], table_va: int, max_values: int = 12) -> list[int]:
    off = va_to_offset(sections, table_va)
    if off is None:
        return []
    values: list[int] = []
    for index in range(max_values):
        if off + index * 4 + 4 > len(exe):
            break
        values.append(struct.unpack_from("<I", exe, off + index * 4)[0])
    return values


def preview_value(exe: bytes, sections: list[dict[str, Any]], value: int) -> dict[str, Any]:
    row: dict[str, Any] = {
        "value": value,
        "valueHex": hx(value),
        "section": section_name_for(sections, value),
        "isVa": is_va(sections, value),
    }
    if row["isVa"]:
        text = c_string_preview(exe, sections, value)
        if text:
            row["text"] = text
    return row


def scan_selected_root_stores(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for section in script_data_sections(sections):
        start = int(section["raw"])
        end = start + int(section["raw_size"])
        pos = exe.find(b"\x81", start, end)
        while pos != -1:
            va = offset_to_va(sections, pos)
            if va is not None:
                try:
                    cmd = decode_command(exe, sections, va)
                except Exception:
                    cmd = {}
                pointer_operands = cmd.get("pointerOperands") if isinstance(cmd, dict) else None
                if cmd.get("opcode") == 0x81 and pointer_operands:
                    table_va = int(pointer_operands[0]["value"])
                    values = read_table_values(exe, sections, table_va, 10)
                    value_set = set(values)
                    rows.append(
                        {
                            "commandVa": va,
                            "commandVaHex": hx(va),
                            "slot": cmd.get("slot"),
                            "tableVa": table_va,
                            "tableVaHex": hx(table_va),
                            "valuePreview": [preview_value(exe, sections, value) for value in values[:8]],
                            "containsTopMenuSequence": TOP_MENU_SEQUENCE_VA in value_set,
                            "containsTopMenuParentSelectedScript": TOP_MENU_PARENT_SELECTED_SCRIPT_VA in value_set,
                            "containsExactTopRegionScript": bool(
                                {
                                    TOP_MENU_REGION6_SCRIPT_VA,
                                    TOP_MENU_REGION3_SCRIPT_VA,
                                    TOP_MENU_REGION4_SCRIPT_VA,
                                }
                                & value_set
                            ),
                        }
                    )
            pos = exe.find(b"\x81", pos + 1, end)
    return rows


def classify_add_context(exe: bytes, sections: list[dict[str, Any]], va: int) -> str:
    window = b""
    off = va_to_offset(sections, va)
    if off is not None:
        window = exe[max(0, off - 96): min(len(exe), off + 160)]
    if TOP_MENU_SEQUENCE_VA in [struct.unpack("<I", window[index:index + 4])[0] for index in range(max(0, len(window) - 3))]:
        return "top-menu-pointer-nearby"
    if any(struct.pack("<I", value) in window for value in (TOP_MENU_REGION6_SCRIPT_VA, TOP_MENU_REGION3_SCRIPT_VA, TOP_MENU_REGION4_SCRIPT_VA)):
        return "top-menu-region-script-nearby"
    if 0x004A2D38 <= va <= 0x004A3CFF:
        return "intro-title-selected-root"
    if b"\x2f" in window and b"\x84" in window:
        return "prompt/event-context"
    if any(byte in window for byte in (0x64, 0x66, 0x72)):
        return "active-object-motion-context"
    return "generic-runtime-index-add"


def pointer_table_preview(exe: bytes, sections: list[dict[str, Any]], va: int) -> list[dict[str, Any]]:
    aligned_va = va - (va % 4)
    start_va = max(aligned_va - 16, 0)
    rows: list[dict[str, Any]] = []
    for current_va in range(start_va, aligned_va + 32, 4):
        off = va_to_offset(sections, current_va)
        if off is None or off + 4 > len(exe):
            continue
        value = struct.unpack_from("<I", exe, off)[0]
        rows.append(
            {
                "entryVaHex": hx(current_va),
                "valueHex": hx(value),
                "isVa": is_va(sections, value),
                "section": section_name_for(sections, value),
            }
        )
    return rows


def scan_descriptor_add_commands(exe: bytes, sections: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    rows: list[dict[str, Any]] = []
    rejected: list[dict[str, Any]] = []
    for section in script_data_sections(sections):
        start = int(section["raw"])
        end = start + int(section["raw_size"])
        pos = exe.find(b"\x62", start, end)
        while pos != -1:
            va = offset_to_va(sections, pos)
            if va is not None and pos + 4 <= len(exe):
                operand = exe[pos + 1]
                # Opcode 0x62 is a 4-byte VM command; command-form hits use
                # zero padding in the last two bytes.  This keeps ASCII/table
                # noise out before the more expensive filters.
                if exe[pos + 2:pos + 4] == b"\x00\x00":
                    if va % 4:
                        rejected.append(
                            {
                                "va": va,
                                "vaHex": hx(va),
                                "operand": operand,
                                "operandHex": f"0x{operand:02x}",
                                "reason": "unaligned-byte-inside-dword-table",
                                "alignedVaHex": hx(va - (va % 4)),
                                "section": section_name_for(sections, va),
                                "pointerTablePreview": pointer_table_preview(exe, sections, va),
                            }
                        )
                        pos = exe.find(b"\x62", pos + 1, end)
                        continue
                    ascii_false = is_asset_ascii_false_opcode(exe, sections, va)
                    numeric_false = monotonic_u16_table_around(exe, sections, va)
                    if not ascii_false and not numeric_false:
                        rows.append(
                            {
                                "va": va,
                                "vaHex": hx(va),
                                "operand": operand,
                                "operandHex": f"0x{operand:02x}",
                                "context": classify_add_context(exe, sections, va),
                                "section": section_name_for(sections, va),
                            }
                        )
            pos = exe.find(b"\x62", pos + 1, end)
    return rows, rejected


def decode_hit_contexts(exe: bytes, sections: list[dict[str, Any]], starts: list[int]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for start in starts:
        try:
            stream = decode_stream(exe, sections, start, max_commands=18)
            commands = stream.get("commands", [])
        except Exception as exc:
            rows.append({"startVa": start, "startVaHex": hx(start), "error": str(exc)})
            continue
        rows.append(
            {
                "startVa": start,
                "startVaHex": hx(start),
                "commands": [
                    {
                        "vaHex": command.get("vaHex"),
                        "opcodeHex": command.get("opcodeHex"),
                        "opcodeName": command.get("opcodeName"),
                        "summary": command.get("summary"),
                    }
                    for command in commands[:18]
                ],
            }
        )
    return rows


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

    exact_top_hits = scan_pattern(exe, sections, EXACT_TOP_MENU_PATTERN)
    exact_right_hits = scan_pattern(exe, sections, EXACT_RIGHT_PANEL_PATTERN)
    shifted_right_hits = scan_pattern(exe, sections, SHIFTED_RIGHT_PANEL_PATTERN)
    selected_root_rows = scan_selected_root_stores(exe, sections)
    descriptor_add_rows, rejected_descriptor_add_rows = scan_descriptor_add_commands(exe, sections)

    context_counts = Counter(row["context"] for row in descriptor_add_rows)
    operand_counts = Counter(row["operandHex"] for row in descriptor_add_rows)
    rejected_operand_counts = Counter(row["operandHex"] for row in rejected_descriptor_add_rows)
    promoted_add_rows = [
        row
        for row in descriptor_add_rows
        if row["context"] in {"top-menu-pointer-nearby", "top-menu-region-script-nearby"}
    ]
    selected_root_promotions = [
        row
        for row in selected_root_rows
        if row["containsTopMenuSequence"] or row["containsExactTopRegionScript"]
    ]
    intro_parent_rows = [
        row
        for row in selected_root_rows
        if row["containsTopMenuParentSelectedScript"] or row["commandVa"] == 0x0047E664
    ]

    summary = {
        "exactTopMenuPatternHitCount": len(exact_top_hits),
        "exactTopMenuPatternHitsHex": [hx(hit) for hit in exact_top_hits],
        "exactRightPanelPatternHitCount": len(exact_right_hits),
        "exactRightPanelPatternHitsHex": [hx(hit) for hit in exact_right_hits],
        "shiftedRightPanelPatternHitCount": len(shifted_right_hits),
        "shiftedRightPanelPatternHitsHex": [hx(hit) for hit in shifted_right_hits],
        "selectedRootStoreCount": len(selected_root_rows),
        "selectedRootPromotionCount": len(selected_root_promotions),
        "introTitleSelectedRootRows": len(intro_parent_rows),
        "descriptorAddCommandCount": len(descriptor_add_rows),
        "descriptorAddRejectedUnalignedCount": len(rejected_descriptor_add_rows),
        "descriptorAddPromotedContextCount": len(promoted_add_rows),
        "descriptorAddContextCounts": dict(sorted(context_counts.items())),
        "descriptorAddOperandCounts": dict(sorted(operand_counts.items())),
        "descriptorAddRejectedOperandCounts": dict(sorted(rejected_operand_counts.items())),
        "topMenuSequenceAllRefsHex": [hx(ref) for ref in all_dword_refs(exe, sections, TOP_MENU_SEQUENCE_VA)],
        "decision": (
            "selected-root/table scan과 global 0x62 add-descriptor command scan에서도 normal-field ESC/X opener는 "
            "아직 증명되지 않았다. 정확한 #6/#3/#4 top-menu 조립 패턴은 0x004ddc84 1건뿐이고, "
            "selected-root table 중 top-menu sequence나 exact region scripts로 승격되는 행은 없다. "
            "기존 0x47/0x4d operand 후보는 4-byte command가 아니라 dword pointer table 내부 unaligned byte로 재분류했다."
        ),
    }

    return {
        "kind": "hwanse-hud-menu-selected-root-frontier-review",
        "source": "tools/build_hud_menu_selected_root_frontier_review.py",
        "status": "normal-field-opener-still-unproven",
        "summary": summary,
        "patternHitContexts": {
            "exactTopMenu": decode_hit_contexts(exe, sections, exact_top_hits),
            "exactRightPanel": decode_hit_contexts(exe, sections, exact_right_hits),
            "shiftedRightPanel": decode_hit_contexts(exe, sections, shifted_right_hits),
        },
        "selectedRootRowsWithTopMenuEvidence": selected_root_promotions,
        "introTitleSelectedRootRows": intro_parent_rows,
        "descriptorAddPromotedContextRows": promoted_add_rows,
        "rejectedDescriptorAddFalsePositives": rejected_descriptor_add_rows,
        "descriptorAddSamplesByContext": {
            context: [row for row in descriptor_add_rows if row["context"] == context][:12]
            for context in sorted(context_counts)
        },
        "negativeEvidence": [
            "0x004ddc6c direct dword ref remains data-context only.",
            "Exact #6/#3/#4 top-menu construction pattern is not duplicated in another data stream.",
            "Selected-root tables do not contain 0x004ddc6c or the exact #6/#3/#4 child initializer addresses.",
            "0x62 add-descriptor command-like hits exist, but none occur in a top-menu pointer/region context.",
            "High operands 0x47 and 0x4d are rejected as unaligned bytes inside dword pointer tables, not real opcode 0x62 commands.",
        ],
        "nextFrontier": [
            "Trace the producer of the mode/state byte that chooses a selected-root table before descriptor add commands execute.",
            "Follow VM opcode 0x81 table selectors whose selected entries later execute 0x62, but do not promote them unless they connect to the exact menu region construction.",
            "Inspect higher-level field mode dispatcher roots that call or schedule command streams after cancel/ESC edge handling.",
        ],
    }


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    card_items = [
        ("status", report["status"]),
        ("exact top pattern", summary["exactTopMenuPatternHitCount"]),
        ("selected-root rows", summary["selectedRootStoreCount"]),
        ("selected-root promotions", summary["selectedRootPromotionCount"]),
        ("0x62 commands", summary["descriptorAddCommandCount"]),
        ("0x62 rejected", summary["descriptorAddRejectedUnalignedCount"]),
        ("0x62 promoted", summary["descriptorAddPromotedContextCount"]),
    ]
    cards = "".join(f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>" for k, v in card_items)
    exact_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['startVaHex'])}</code></td>"
        f"<td>{h(json.dumps(row.get('commands', [])[:10], ensure_ascii=False))}</td>"
        "</tr>"
        for row in report["patternHitContexts"]["exactTopMenu"]
    )
    context_rows = "".join(
        f"<tr><td>{h(key)}</td><td>{h(value)}</td></tr>"
        for key, value in summary["descriptorAddContextCounts"].items()
    )
    selected_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['commandVaHex'])}</code><br>slot {h(row.get('slot'))}</td>"
        f"<td><code>{h(row['tableVaHex'])}</code></td>"
        f"<td>{h(json.dumps(row['valuePreview'][:6], ensure_ascii=False))}</td>"
        "</tr>"
        for row in report["introTitleSelectedRootRows"][:12]
    )
    rejected_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['vaHex'])}</code><br>{h(row['operandHex'])}</td>"
        f"<td>{h(row['reason'])}<br>aligned <code>{h(row['alignedVaHex'])}</code></td>"
        f"<td>{h(json.dumps(row['pointerTablePreview'][:8], ensure_ascii=False))}</td>"
        "</tr>"
        for row in report["rejectedDescriptorAddFalsePositives"]
    )
    negatives = "".join(f"<li>{h(item)}</li>" for item in report["negativeEvidence"])
    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 Selected Root 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:960px; 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: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_menu_preview.html">HUD menu preview</a>
    <a class="chip" href="menu_descriptor_stack_review.html">descriptor stack</a>
    <a class="chip" href="../out/hud_menu_selected_root_frontier_review.json">JSON</a>
  </div>
  <h1>HUD Menu Selected Root Frontier Review</h1>
  <p>{h(summary["decision"])}</p>
  <div class="summary">{cards}</div>
  <section>
    <h2>Exact Top-Menu Pattern Hits</h2>
    <table><thead><tr><th>start</th><th>decoded commands</th></tr></thead><tbody>{exact_rows or "<tr><td colspan='2'>No exact top-menu pattern hits.</td></tr>"}</tbody></table>
  </section>
  <section>
    <h2>0x62 Descriptor Add Contexts</h2>
    <table><thead><tr><th>context</th><th>count</th></tr></thead><tbody>{context_rows}</tbody></table>
  </section>
  <section>
    <h2>Rejected 0x62 False Positives</h2>
    <table><thead><tr><th>hit</th><th>reason</th><th>pointer table preview</th></tr></thead><tbody>{rejected_rows or "<tr><td colspan='3'>No rejected unaligned hits</td></tr>"}</tbody></table>
  </section>
  <section>
    <h2>Intro/Title Selected-Root Rows</h2>
    <table><thead><tr><th>command</th><th>table</th><th>values</th></tr></thead><tbody>{selected_rows or "<tr><td colspan='3'>None</td></tr>"}</tbody></table>
  </section>
  <section>
    <h2>Negative Evidence</h2>
    <ul>{negatives}</ul>
  </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_SELECTED_ROOT_FRONTIER_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_HUD_MENU_SELECTED_ROOT_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_selected_root_frontier_review.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (WEB / "hud_menu_selected_root_frontier_review.html").write_text(render_html(report), encoding="utf-8")
    print("hud_menu_selected_root_frontier_review ok")
    return 0


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