#!/usr/bin/env python3
"""Classify static references to the generic display/object VM root.

The generic runner at 0x00402321 is intentionally broad: battle effects,
status/menu descriptors, active objects, and generic script objects can all
install it as their update callback.  This pass classifies every immediate
reference to that root so we do not mistake generic object creation for a
normal-field ESC/X HUD opener or a scene-route producer.
"""
from __future__ import annotations

import html
import json
import struct
from collections import Counter
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"

DISPLAY_VM_ROOT_VA = 0x00402321
OBJECT_ALLOCATOR_VA = 0x00435B5B


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


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


def section_for_offset(sections: list[dict[str, Any]], offset: int) -> dict[str, Any] | None:
    for section in sections:
        start = int(section["raw"])
        end = start + int(section["raw_size"])
        if start <= offset < end:
            return section
    return None


def section_name_for_va(sections: list[dict[str, Any]], va: int) -> str:
    offset = va_to_offset(sections, va)
    if offset is None:
        return ""
    section = section_for_offset(sections, offset)
    return "" if section is None else str(section.get("name", ""))


def function_ranges(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    text = next(section for section in sections if section.get("name") == ".text")
    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):
        next_va = starts[index + 1] if index + 1 < len(starts) else int(text["va"]) + int(text["raw_size"])
        rows.append({"startVa": start_va, "endVa": next_va})
    return rows


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


def classify_owner(start_va: int | None) -> str:
    if start_va is None:
        return "unknown-owner"
    if start_va == 0x004022F0:
        return "generic-vm-object-wrapper"
    if start_va == 0x00402549:
        return "generic-vm-child-object-factory"
    if 0x00406A00 <= start_va <= 0x00407B20:
        return "generic-object-script-handler-family"
    if 0x0040E800 <= start_va <= 0x0040F050:
        return "save/menu/display-helper-family"
    if 0x00411700 <= start_va <= 0x00415100:
        return "battle-effect/helper-object-spawner-family"
    if 0x0041B600 <= start_va <= 0x0041FF50:
        return "text-status/menu-display-object-family"
    if 0x00428000 <= start_va <= 0x00428700:
        return "resource-or-display-object-factory"
    if 0x00431F00 <= start_va <= 0x00432900:
        return "active-descriptor/materializer-family"
    if 0x00432F00 <= start_va <= 0x00433200:
        return "active-object-script-update-family"
    return "unclassified-owner"


def call_target_after(exe: bytes, sections: list[dict[str, Any]], offset: int, limit: int = 40) -> tuple[int, int] | None:
    end = min(len(exe) - 4, offset + limit)
    for call_off in range(offset, end):
        if exe[call_off] != 0xE8:
            continue
        call_va = offset_to_va(sections, call_off)
        if call_va is None:
            continue
        rel = struct.unpack_from("<i", exe, call_off + 1)[0]
        return call_va, call_va + 5 + rel
    return None


def previous_object_kind_arg(exe: bytes, offset: int) -> str:
    if offset >= 3 and exe[offset - 3] == 0x6A and exe[offset - 1] == 0x68:
        return str(exe[offset - 2])
    if offset >= 2 and exe[offset - 2] == 0x50 and exe[offset - 1] == 0x68:
        return "dynamic-stack-byte"
    if offset >= 6 and exe[offset - 6] == 0x68 and exe[offset - 1] == 0x68:
        return hx(struct.unpack_from("<I", exe, offset - 5)[0])
    return "unknown"


def classify_ref(exe: bytes, sections: list[dict[str, Any]], offset: int) -> dict[str, Any]:
    ref_va = offset_to_va(sections, offset)
    if ref_va is None:
        raise ValueError(f"raw offset {offset:#x} is not mapped to a VA")

    if offset > 0 and exe[offset - 1] == 0x68:
        kind_arg = previous_object_kind_arg(exe, offset)
        call = call_target_after(exe, sections, offset + 4)
        call_va, target_va = call if call else (None, None)
        if target_va == OBJECT_ALLOCATOR_VA:
            classification = "object-create/install-display-vm-root"
            if kind_arg == "0":
                domain = "generic/root display object"
            elif kind_arg == "1":
                domain = "descriptor/materialized child object"
            elif kind_arg == "2":
                domain = "battle/effect/display helper object"
            elif kind_arg == "dynamic-stack-byte":
                domain = "runtime-selected object kind"
            else:
                domain = "unknown object kind"
        else:
            classification = "push-reference-nonallocator"
            domain = "unclassified push reference"
        return {
            "refVa": ref_va,
            "refVaHex": hx(ref_va),
            "section": section_name_for_va(sections, ref_va),
            "refKind": classification,
            "objectKindArg": kind_arg,
            "objectKindDomain": domain,
            "allocatorCallVa": hx(call_va),
            "allocatorTargetVa": hx(target_va),
            "contextHex": exe[max(0, offset - 12) : offset + 28].hex(" "),
        }

    if offset >= 3 and exe[offset - 3 : offset] == b"\xC7\x40\x10":
        return {
            "refVa": ref_va,
            "refVaHex": hx(ref_va),
            "section": section_name_for_va(sections, ref_va),
            "refKind": "object-field-callback-store",
            "objectKindArg": "",
            "objectKindDomain": "stores 0x00402321 into [eax+0x10]",
            "allocatorCallVa": "-",
            "allocatorTargetVa": "-",
            "contextHex": exe[max(0, offset - 12) : offset + 28].hex(" "),
        }

    return {
        "refVa": ref_va,
        "refVaHex": hx(ref_va),
        "section": section_name_for_va(sections, ref_va),
        "refKind": "unclassified-dword-reference",
        "objectKindArg": "",
        "objectKindDomain": "unclassified",
        "allocatorCallVa": "-",
        "allocatorTargetVa": "-",
        "contextHex": exe[max(0, offset - 12) : offset + 28].hex(" "),
    }


def find_dword_refs(exe: bytes, sections: list[dict[str, Any]], functions: list[dict[str, Any]]) -> list[dict[str, Any]]:
    needle = struct.pack("<I", DISPLAY_VM_ROOT_VA)
    rows: list[dict[str, Any]] = []
    pos = exe.find(needle)
    while pos != -1:
        section = section_for_offset(sections, pos)
        if section is not None:
            row = classify_ref(exe, sections, pos)
            owner = owner_function(functions, int(row["refVa"]))
            owner_start = None if owner is None else int(owner["startVa"])
            row["ownerStartVa"] = owner_start
            row["ownerStartVaHex"] = hx(owner_start)
            row["ownerClass"] = classify_owner(owner_start)
            rows.append(row)
        pos = exe.find(needle, pos + 1)
    return rows


def find_direct_calls(exe: bytes, sections: list[dict[str, Any]], functions: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    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 != DISPLAY_VM_ROOT_VA:
                continue
            owner = owner_function(functions, call_va)
            owner_start = None if owner is None else int(owner["startVa"])
            rows.append(
                {
                    "callVa": call_va,
                    "callVaHex": hx(call_va),
                    "ownerStartVa": owner_start,
                    "ownerStartVaHex": hx(owner_start),
                    "ownerClass": classify_owner(owner_start),
                    "contextHex": exe[max(start, off - 16) : min(end, off + 24)].hex(" "),
                    "classification": classify_direct_call(call_va),
                }
            )
    return rows


def classify_direct_call(call_va: int) -> str:
    if call_va == 0x0040237F:
        return "nested-runner-wrapper-self-call"
    if call_va == 0x004058AF:
        return "opcode-0x2a-linked-object-script-fanout-restore"
    if call_va == 0x004058D8:
        return "opcode-0x2a-linked-object-script-fanout-persistent"
    if call_va == 0x004330A9:
        return "active-object-delayed-script-route"
    return "unclassified-direct-call"


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

    ref_kind_counts = Counter(row["refKind"] for row in dword_refs)
    object_kind_counts = Counter(row["objectKindArg"] for row in dword_refs if row["refKind"] == "object-create/install-display-vm-root")
    owner_counts = Counter(row["ownerClass"] for row in dword_refs)
    direct_call_counts = Counter(row["classification"] for row in direct_calls)

    object_create_rows = [row for row in dword_refs if row["refKind"] == "object-create/install-display-vm-root"]
    allocator_rows = [row for row in object_create_rows if row["allocatorTargetVa"] == hx(OBJECT_ALLOCATOR_VA)]
    field_store_rows = [row for row in dword_refs if row["refKind"] == "object-field-callback-store"]

    report = {
        "version": 1,
        "kind": "hwanse-display-vm-root-ref-context-review",
        "source": "tools/build_display_vm_root_ref_context_review.py",
        "summary": {
            "displayVmRootVaHex": hx(DISPLAY_VM_ROOT_VA),
            "objectAllocatorVaHex": hx(OBJECT_ALLOCATOR_VA),
            "dwordRefCount": len(dword_refs),
            "objectCreateRefCount": len(object_create_rows),
            "allocatorBackedObjectCreateRefCount": len(allocator_rows),
            "fieldCallbackStoreCount": len(field_store_rows),
            "directCallCount": len(direct_calls),
            "refKindCounts": dict(ref_kind_counts),
            "objectKindArgCounts": dict(object_kind_counts),
            "ownerClassCounts": dict(owner_counts),
            "directCallClassCounts": dict(direct_call_counts),
            "battleEffectOwnerRefCount": owner_counts.get("battle-effect/helper-object-spawner-family", 0),
            "descriptorOwnerRefCount": owner_counts.get("active-descriptor/materializer-family", 0),
            "normalFieldHudOpenerPromoted": False,
            "sceneRouteProducerPromoted": False,
            "classification": "generic display/object VM root references classified; no opener producer promoted",
            "decision": (
                "Immediate references to 0x00402321 are overwhelmingly generic object creation sites "
                "that install the runner through allocator 0x00435b5b.  The only non-push reference stores "
                "the same runner into an object callback field.  These references prove display/object VM "
                "reuse across helper, descriptor, menu/status, and active-object domains; they do not prove "
                "the normal-field ESC/X opener or a scene/map route producer."
            ),
        },
        "directCallRows": direct_calls,
        "dwordRefRows": dword_refs,
        "fieldCallbackStoreRows": field_store_rows,
        "objectCreateRows": object_create_rows,
        "negativeEvidence": [
            "82/83 immediate dword references are allocator-backed object creation sites, not stream/root producers.",
            "67 allocator-backed refs use object kind 2 and cluster in battle/effect/helper spawner code.",
            "descriptor/materializer refs create active/display child objects, but still do not identify who schedules the normal-field HUD wrapper.",
            "the sole store reference writes 0x00402321 into [eax+0x10], a callback/root field, not object+0x40 stream content.",
            "direct calls to 0x00402321 remain the known nested runner, opcode 0x2a fanout, and active-object delayed script route; no new opener call appears here.",
        ],
        "nextFrontier": [
            "Treat 0x00402321 as a reusable callback/runner, not as a unique HUD opener signature.",
            "For ESC/X opener proof, trace which producer attaches the already-grounded top-menu wrapper stream, not every object that installs the generic runner.",
            "For scene route proof, require an upstream gameplay state/root writer that selects a stream, not allocator references to the generic runner.",
        ],
    }
    return report


def render_table(rows: list[dict[str, Any]], *, limit: int = 120) -> str:
    body = "".join(
        "<tr>"
        f"<td><code>{h(row.get('refVaHex') or row.get('callVaHex'))}</code></td>"
        f"<td><code>{h(row.get('ownerStartVaHex'))}</code><br>{h(row.get('ownerClass'))}</td>"
        f"<td>{h(row.get('refKind') or row.get('classification'))}</td>"
        f"<td>{h(row.get('objectKindArg', ''))}<br>{h(row.get('objectKindDomain', ''))}</td>"
        f"<td><code>{h(row.get('allocatorCallVa', ''))}</code><br><code>{h(row.get('allocatorTargetVa', ''))}</code></td>"
        f"<td><code>{h(row.get('contextHex'))}</code></td>"
        "</tr>"
        for row in rows[:limit]
    )
    if len(rows) > limit:
        body += f"<tr><td colspan='6'>... {len(rows) - limit} more rows in JSON</td></tr>"
    return body or "<tr><td colspan='6'>none</td></tr>"


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    payload = json.dumps(report, ensure_ascii=False)
    cards = "".join(
        f"<div class='card'><b>{h(key)}</b><span>{h(value)}</span></div>"
        for key, value in summary.items()
        if key != "decision"
    )
    negatives = "".join(f"<li>{h(item)}</li>" for item in report["negativeEvidence"])
    frontier = "".join(f"<li>{h(item)}</li>" for item in report["nextFrontier"])
    return f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Display VM Root Ref Context Review</title>
<style>
  body {{ margin:0; font-family:system-ui,-apple-system,Segoe UI,sans-serif; background:#101318; color:#edf1f7; }}
  main {{ max-width:1360px; margin:0 auto; padding:24px; }}
  h1 {{ margin:0 0 8px; font-size:28px; }}
  h2 {{ margin-top:28px; font-size:18px; }}
  p, li {{ line-height:1.45; }}
  code {{ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }}
  .cards {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:10px; margin:18px 0; }}
  .card {{ background:#171d26; border:1px solid #293241; border-radius:8px; padding:10px 12px; }}
  .card b {{ display:block; color:#9cb3d1; font-size:12px; }}
  .card span {{ display:block; margin-top:4px; overflow-wrap:anywhere; }}
  table {{ width:100%; border-collapse:collapse; background:#151a22; border:1px solid #293241; table-layout:fixed; }}
  th, td {{ border-bottom:1px solid #263040; padding:7px 8px; text-align:left; vertical-align:top; font-size:12px; }}
  th {{ background:#1d2633; color:#dbe7f7; }}
  td:nth-child(1) {{ width:105px; }}
  td:nth-child(2) {{ width:220px; }}
  td:nth-child(3) {{ width:230px; }}
  td:nth-child(4) {{ width:190px; }}
  td:nth-child(5) {{ width:130px; }}
  td:nth-child(6) {{ overflow-wrap:anywhere; }}
  pre {{ white-space:pre-wrap; background:#080b10; color:#edf1f7; padding:14px; border-radius:8px; max-height:360px; overflow:auto; }}
</style>
</head>
<body>
<main>
  <h1>Display VM Root Ref Context Review</h1>
  <p>{h(summary["decision"])}</p>
  <div class="cards">{cards}</div>
  <section>
    <h2>Negative Evidence</h2>
    <ul>{negatives}</ul>
  </section>
  <section>
    <h2>Next Frontier</h2>
    <ul>{frontier}</ul>
  </section>
  <section>
    <h2>Dword References</h2>
    <table><thead><tr><th>ref</th><th>owner</th><th>kind</th><th>object kind</th><th>allocator</th><th>bytes</th></tr></thead><tbody>{render_table(report["dwordRefRows"])}</tbody></table>
  </section>
  <section>
    <h2>Direct Calls</h2>
    <table><thead><tr><th>call</th><th>owner</th><th>kind</th><th>object kind</th><th>allocator</th><th>bytes</th></tr></thead><tbody>{render_table(report["directCallRows"])}</tbody></table>
  </section>
  <section>
    <h2>Raw JSON</h2>
    <pre id="json"></pre>
  </section>
</main>
<script>
window.HWANSE_DISPLAY_VM_ROOT_REF_CONTEXT_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_DISPLAY_VM_ROOT_REF_CONTEXT_REVIEW, null, 2);
</script>
</body>
</html>
"""


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


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