#!/usr/bin/env python3
"""Review pointer-table VM records in the status/menu data region.

This report is deliberately narrower than the full scene/event VM work.  The
status/menu data around 0x004e8000 uses 0x40-prefixed records that are not the
same byte domain as the active-object opcode stream.  The goal here is to
separate grounded table shapes from still-unknown producers:

* ``40 14`` count/table records, including the 9-entry status-comment
  selector array.
* ``40 13 <kind> <id>`` inline pointer tables for menu/choice strings.
* ``40 1f`` local branch-list records.
"""
from __future__ import annotations

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

sys.path.insert(0, str(Path(__file__).resolve().parent))

from probe_exe_scene_tables import read_sections, va_to_offset  # noqa: E402


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

JSON_OUT = OUT / "status_menu_vm_table_review.json"
HTML_OUT = WEB / "status_menu_vm_table_review.html"
WEB_HTML_OUT = WEB / "status_menu_vm_table_review.html"

SCAN_START_VA = 0x004E8000
SCAN_END_VA = 0x004EC000
STATUS_COMMENT_SELECTOR_ARRAY_VA = 0x004E8610
TEXT_CONTROL_INTERPRETER_VA = 0x0041B66D
TEXT_CONTROL_HANDLER_TABLE_VA = 0x0047F1D8

CONTROL_HANDLER_SEMANTICS = {
    0x13: {
        "handlerVa": 0x0041D15D,
        "mnemonic": "select-jump",
        "meaning": (
            "Read selector byte from global/object/base source, clamp to count, "
            "then set object+0xb0 to the selected pointer. Does not save a continuation."
        ),
    },
    0x14: {
        "handlerVa": 0x0041D2B5,
        "mnemonic": "select-jump-with-continuation",
        "meaning": (
            "Read selector byte from global/object/base source, clamp to count, "
            "save the table fallthrough/continuation pointer to object+0xb4 stack "
            "using object+0xc8, then set object+0xb0 to the selected pointer."
        ),
    },
    0x1B: {
        "handlerVa": 0x0041D89D,
        "mnemonic": "set-selector-base",
        "meaning": (
            "Set object+0xa8 selector base. mode 0=0x0059e310, mode 1=0x004576d8, "
            "mode 2=0x00457750 + slot*0xd8, mode 3=pointer table 0x0059db30[object+0xf2]."
        ),
    },
}


def hx(value: int | None, width: int = 8) -> str:
    if value is None:
        return ""
    return f"0x{value:0{width}x}"


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


def byte_hex(raw: bytes) -> str:
    return " ".join(f"{byte:02x}" for byte in raw)


def read_at_va(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 u32_at_va(exe: bytes, sections: list[dict[str, Any]], va: int) -> int | None:
    raw = read_at_va(exe, sections, va, 4)
    if len(raw) < 4:
        return None
    return struct.unpack("<I", raw)[0]


def is_mapped_va(sections: list[dict[str, Any]], value: int | None) -> bool:
    return value is not None and va_to_offset(sections, value) is not None


def va_to_file_offset(sections: list[dict[str, Any]], va: int) -> int | None:
    return va_to_offset(sections, va)


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


def iter_pattern_vas(
    exe: bytes,
    sections: list[dict[str, Any]],
    pattern: bytes,
    start_va: int = SCAN_START_VA,
    end_va: int = SCAN_END_VA,
) -> list[int]:
    start = va_to_file_offset(sections, start_va)
    end = va_to_file_offset(sections, end_va)
    if start is None or end is None:
        return []
    rows: list[int] = []
    off = exe.find(pattern, start, end)
    while off != -1:
        va = file_offset_to_va(sections, off)
        if va is not None:
            rows.append(va)
        off = exe.find(pattern, off + 1, end)
    return rows


def decode_text_preview(exe: bytes, sections: list[dict[str, Any]], va: int, max_len: int = 96) -> str:
    raw = read_at_va(exe, sections, va, max_len)
    if not raw:
        return ""
    out = bytearray()
    index = 0
    while index < len(raw):
        if raw[index] == 0:
            break
        if raw[index] == 0x40 and index + 3 < len(raw) and raw[index + 2] == 0 and raw[index + 3] == 0:
            if out:
                out.extend(b" ")
            index += 4
            continue
        out.append(raw[index])
        index += 1
    text = out.decode("cp949", "ignore")
    text = " ".join(text.replace("\u3000", " ").split())
    if not any("\uac00" <= ch <= "\ud7a3" for ch in text):
        return ""
    return text


def pointer_target_kind(exe: bytes, sections: list[dict[str, Any]], ptr: int | None) -> dict[str, Any]:
    if ptr is None:
        return {"kind": "null", "preview": ""}
    if not is_mapped_va(sections, ptr):
        return {"kind": "unmapped", "preview": ""}
    raw = read_at_va(exe, sections, ptr, 16)
    if raw.startswith(b"\x40\x1b\x00\x00\x40\x1f\x00\x00"):
        return {"kind": "status-comment-block", "preview": ""}
    if raw.startswith(b"\x40\x13"):
        return {"kind": "nested-pointer-table", "preview": ""}
    preview = decode_text_preview(exe, sections, ptr)
    if preview:
        return {"kind": "text", "preview": preview}
    if raw.startswith(b"\x40"):
        return {"kind": "vm-record", "preview": ""}
    return {"kind": "pointer-data", "preview": ""}


def pointer_entries(
    exe: bytes,
    sections: list[dict[str, Any]],
    entry_va: int,
    count: int,
) -> list[dict[str, Any]]:
    entries: list[dict[str, Any]] = []
    for index in range(count):
        ptr = u32_at_va(exe, sections, entry_va + index * 4)
        kind = pointer_target_kind(exe, sections, ptr)
        entries.append(
            {
                "index": index,
                "pointerVaHex": hx(ptr),
                "kind": kind["kind"],
                "preview": kind["preview"],
            }
        )
    return entries


def decode_selector_source(source_mode: int, source_offset: int) -> dict[str, Any]:
    mode_meanings = {
        0: "constant-zero",
        1: "global-byte-table",
        2: "object-byte-offset",
        3: "selector-base-byte-offset",
    }
    if source_mode == 1:
        expression = f"BYTE [0x0059db60 + {source_offset}*4]"
    elif source_mode == 2:
        expression = f"BYTE [object + 0x{source_offset:02x}]"
    elif source_mode == 3:
        expression = f"BYTE [object+0xa8 + 0x{source_offset:02x}]"
    else:
        expression = "0"
    return {
        "sourceMode": source_mode,
        "sourceModeHex": hx(source_mode, 2),
        "sourceOffset": source_offset,
        "sourceOffsetHex": hx(source_offset, 2),
        "sourceKind": mode_meanings.get(source_mode, "unknown"),
        "sourceExpression": expression,
    }


def decode_base_selector_command(raw: bytes, va: int) -> dict[str, Any] | None:
    if len(raw) < 4 or raw[:2] != b"\x40\x1b":
        return None
    mode = raw[2]
    arg = raw[3]
    if mode == 0:
        expression = "object+0xa8 = 0x0059e310"
    elif mode == 1:
        expression = "object+0xa8 = 0x004576d8"
    elif mode == 2:
        expression = f"object+0xa8 = 0x00457750 + {arg}*0xd8"
    elif mode == 3:
        expression = "object+0xa8 = DWORD [0x0059db30 + object+0xf2*4]"
    else:
        expression = "unknown"
    return {
        "recordVaHex": hx(va),
        "rawHex": byte_hex(raw[:4]),
        "mode": mode,
        "modeHex": hx(mode, 2),
        "arg": arg,
        "argHex": hx(arg, 2),
        "baseExpression": expression,
    }


def nearest_preceding_base_selector(
    exe: bytes,
    sections: list[dict[str, Any]],
    record_va: int,
    search_bytes: int = 0x80,
) -> dict[str, Any] | None:
    start_va = max(SCAN_START_VA, record_va - search_bytes)
    start = va_to_file_offset(sections, start_va)
    end = va_to_file_offset(sections, record_va)
    if start is None or end is None or end <= start:
        return None
    raw = exe[start:end]
    last = raw.rfind(b"\x40\x1b")
    if last == -1:
        return None
    va = start_va + last
    return decode_base_selector_command(read_at_va(exe, sections, va, 4), va)


def read_control_handler_table(exe: bytes, sections: list[dict[str, Any]], count: int = 0x20) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for opcode in range(count):
        handler = u32_at_va(exe, sections, TEXT_CONTROL_HANDLER_TABLE_VA + opcode * 4)
        semantic = CONTROL_HANDLER_SEMANTICS.get(opcode, {})
        rows.append(
            {
                "opcode": opcode,
                "opcodeHex": hx(opcode, 2),
                "handlerVaHex": hx(handler),
                "mnemonic": semantic.get("mnemonic", ""),
                "meaning": semantic.get("meaning", ""),
                "groundedHandler": handler == semantic.get("handlerVa") if semantic else False,
            }
        )
    return rows


def scan_opcode14_tables(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for va in iter_pattern_vas(exe, sections, b"\x40\x14"):
        header = read_at_va(exe, sections, va, 4)
        if len(header) < 4:
            continue
        source_mode = header[2]
        source_offset = header[3]
        source = decode_selector_source(source_mode, source_offset)
        preceding_base = nearest_preceding_base_selector(exe, sections, va)
        field = u32_at_va(exe, sections, va + 4)
        if field is None:
            continue
        item_count = field & 0xFF
        table_mode = (field >> 8) & 0xFF
        if item_count <= 0 or item_count > 64:
            continue
        if table_mode == 0:
            entry_va = va + 8
            table_pointer_va = None
            command_length = 8 + item_count * 4
        elif table_mode == 1:
            table_pointer_va = u32_at_va(exe, sections, va + 8)
            if not is_mapped_va(sections, table_pointer_va):
                continue
            entry_va = int(table_pointer_va)
            command_length = 12
        else:
            continue
        entries = pointer_entries(exe, sections, entry_va, item_count)
        rows.append(
            {
                "recordVaHex": hx(va),
                "recordRawHex": byte_hex(read_at_va(exe, sections, va, min(command_length, 0x50))),
                "selectorKind": source_mode,
                "selectorKindHex": hx(source_mode, 2),
                "selectorId": source_offset,
                "selectorIdHex": hx(source_offset, 2),
                "sourceMode": source_mode,
                "sourceModeHex": source["sourceModeHex"],
                "sourceOffset": source_offset,
                "sourceOffsetHex": source["sourceOffsetHex"],
                "sourceKind": source["sourceKind"],
                "sourceExpression": source["sourceExpression"],
                "nearestPrecedingBaseSelector": preceding_base,
                "rawCountFieldHex": hx(field),
                "tableMode": table_mode,
                "itemCount": item_count,
                "entryTableVaHex": hx(entry_va),
                "externalTablePointerVaHex": hx(table_pointer_va),
                "commandLengthBytes": command_length,
                "entries": entries,
                "entryKindCounts": dict(Counter(entry["kind"] for entry in entries)),
                "classification": classify_opcode14(va, table_mode, entries),
            }
        )
    return rows


def classify_opcode14(va: int, table_mode: int, entries: list[dict[str, Any]]) -> str:
    kinds = Counter(entry["kind"] for entry in entries)
    if va + 8 == STATUS_COMMENT_SELECTOR_ARRAY_VA and kinds.get("status-comment-block") == len(entries):
        return "status-comment-block-selector-array"
    if table_mode == 1 and kinds.get("text") == len(entries):
        return "external-text-pointer-list"
    return "pointer-table-candidate"


def scan_opcode13_tables(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for va in iter_pattern_vas(exe, sections, b"\x40\x13"):
        raw = read_at_va(exe, sections, va, 12)
        if len(raw) < 8:
            continue
        kind = raw[2]
        table_id = raw[3]
        source = decode_selector_source(kind, table_id)
        count = struct.unpack_from("<I", raw, 4)[0]
        if count <= 0 or count > 64:
            continue
        entries = pointer_entries(exe, sections, va + 8, count)
        mapped_count = sum(1 for entry in entries if entry["kind"] not in {"null", "unmapped"})
        if mapped_count < max(1, count // 2):
            continue
        classification = classify_opcode13(kind, table_id, entries)
        rows.append(
            {
                "recordVaHex": hx(va),
                "kind": kind,
                "kindHex": hx(kind, 2),
                "tableId": table_id,
                "tableIdHex": hx(table_id, 2),
                "sourceMode": kind,
                "sourceModeHex": source["sourceModeHex"],
                "sourceOffset": table_id,
                "sourceOffsetHex": source["sourceOffsetHex"],
                "sourceKind": source["sourceKind"],
                "sourceExpression": source["sourceExpression"],
                "itemCount": count,
                "recordRawHex": byte_hex(read_at_va(exe, sections, va, min(8 + count * 4, 0x50))),
                "entries": entries,
                "entryKindCounts": dict(Counter(entry["kind"] for entry in entries)),
                "classification": classification,
            }
        )
    return rows


def classify_opcode13(kind: int, table_id: int, entries: list[dict[str, Any]]) -> str:
    kinds = Counter(entry["kind"] for entry in entries)
    if kind == 1:
        return "inline-status-comment-slot-table"
    if kind == 2 and table_id == 0x58 and kinds.get("text", 0) >= max(1, len(entries) - 2):
        return "skill-description-text-table"
    if kind == 3 and kinds.get("text", 0) == len(entries):
        return "inline-menu-text-table"
    if kind == 3:
        return "inline-menu-pointer-table"
    return "inline-pointer-table-candidate"


def scan_opcode1f_branch_lists(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for va in iter_pattern_vas(exe, sections, b"\x40\x1f"):
        mode = read_at_va(exe, sections, va + 2, 2)
        entries: list[dict[str, Any]] = []
        cursor = va + 4
        for index in range(12):
            ptr = u32_at_va(exe, sections, cursor)
            if not is_mapped_va(sections, ptr):
                break
            raw = read_at_va(exe, sections, int(ptr), 8)
            # Branch-list entries usually point to VM records, not text strings.
            if not raw.startswith(b"\x40"):
                break
            kind = pointer_target_kind(exe, sections, int(ptr))
            entries.append(
                {
                    "index": index,
                    "pointerVaHex": hx(ptr),
                    "kind": kind["kind"],
                    "preview": kind["preview"],
                }
            )
            cursor += 4
        if entries:
            rows.append(
                {
                    "recordVaHex": hx(va),
                    "modeRawHex": byte_hex(mode),
                    "pointerCount": len(entries),
                    "recordRawHex": byte_hex(read_at_va(exe, sections, va, 4 + len(entries) * 4)),
                    "entries": entries,
                    "classification": "local-branch-list-candidate",
                }
            )
    return rows


def build() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    opcode14 = scan_opcode14_tables(exe, sections)
    opcode13 = scan_opcode13_tables(exe, sections)
    opcode1f = scan_opcode1f_branch_lists(exe, sections)
    control_handlers = read_control_handler_table(exe, sections)
    summary = {
        "scope": "status/menu 0x40-prefixed VM pointer tables",
        "scanRangeVaHex": f"{hx(SCAN_START_VA)}..{hx(SCAN_END_VA)}",
        "textControlInterpreterVaHex": hx(TEXT_CONTROL_INTERPRETER_VA),
        "textControlHandlerTableVaHex": hx(TEXT_CONTROL_HANDLER_TABLE_VA),
        "opcode14TableCount": len(opcode14),
        "opcode14SelectorIds": dict(Counter(row["selectorIdHex"] for row in opcode14)),
        "opcode14ClassCounts": dict(Counter(row["classification"] for row in opcode14)),
        "opcode13TableCount": len(opcode13),
        "opcode13KindCounts": dict(Counter(row["kindHex"] for row in opcode13)),
        "opcode13ClassCounts": dict(Counter(row["classification"] for row in opcode13)),
        "opcode1fBranchListCount": len(opcode1f),
        "promotionStatus": "table-shapes-grounded-producers-pending",
        "conclusions": [
            "0x41b66d is the text/status payload interpreter. If the current payload byte is 0x40, the next byte indexes the handler table at 0x0047f1d8.",
            "40 13 and 40 14 are selector-table controls in that text/status payload domain. Both read a selector byte from global/object/base source mode; both clamp it to count.",
            "40 13 jumps to the selected pointer only. 40 14 also stores the fallthrough/continuation pointer in object+0xb4 using object+0xc8 before jumping.",
            "40 1b sets object+0xa8 selector base. In the status-comment payload, 40 1b 01 00 sets base 0x004576d8 before the 9-entry 40 14 selector array.",
            "0x004e8608 is the mode-0 9-entry status short-comment selector array and all entries point to grounded status-comment blocks.",
            "0x004e8ac4, 0x004e8b1a and 0x004e8ba2 are mode-1 external text pointer lists, so 40 14 is not globally just the short-comment selector.",
            "40 13 kind=3 has inline menu/choice pointer-table shapes in this range. 40 13 kind=2/table 0x58 maps to skill-description text tables for the #2 information panel.",
            "These 0x40-prefixed data records must be kept separate from the active-object unprefixed opcode 0x13 conditional branch stream.",
            "The selector source for the status-comment group is now narrowed to object+0xa8+0, with object+0xa8 set by 40 1b. The writer/meaning of the backing status byte at 0x004576d8 remains unresolved.",
        ],
    }
    return {
        "summary": summary,
        "opcode14Tables": opcode14,
        "opcode13Tables": opcode13,
        "opcode1fBranchLists": opcode1f,
        "textControlHandlers": control_handlers,
        "sourceArtifacts": {
            "statusCommentSelector": "out/status_comment_selector_binding_review.json",
            "sceneChoiceTargetReview": "out/scene_event_vm_choice_target_review.json",
            "opcode13ChoiceBranchReview": "out/opcode13_choice_branch_review.json",
        },
    }


def table_rows(rows: list[dict[str, Any]], columns: list[str], limit: int = 120) -> str:
    body = []
    for row in rows[:limit]:
        cells = []
        for column in columns:
            value = row.get(column)
            if isinstance(value, (dict, list)):
                value = json.dumps(value, ensure_ascii=False)
            cells.append(f"<td>{h(value)}</td>")
        body.append("<tr>" + "".join(cells) + "</tr>")
    head = "".join(f"<th>{h(column)}</th>" for column in columns)
    return f"<table><thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody></table>"


def html_doc(payload: dict[str, Any]) -> str:
    s = payload["summary"]
    metrics = "".join(
        f"<div class='metric'><div>{h(key)}</div><strong>{h(value)}</strong></div>"
        for key, value in s.items()
        if key not in {"conclusions"}
    )
    conclusions = "".join(f"<li>{h(item)}</li>" for item in s["conclusions"])
    opcode14_rows = table_rows(
        payload["opcode14Tables"],
        [
            "recordVaHex",
            "sourceModeHex",
            "sourceOffsetHex",
            "sourceKind",
            "sourceExpression",
            "rawCountFieldHex",
            "tableMode",
            "itemCount",
            "entryTableVaHex",
            "classification",
            "entryKindCounts",
        ],
    )
    opcode13_rows = table_rows(
        payload["opcode13Tables"],
        ["recordVaHex", "kindHex", "tableIdHex", "sourceExpression", "itemCount", "classification", "entryKindCounts"],
    )
    opcode1f_rows = table_rows(
        payload["opcode1fBranchLists"],
        ["recordVaHex", "modeRawHex", "pointerCount", "classification"],
    )
    handler_rows = table_rows(
        payload["textControlHandlers"],
        ["opcodeHex", "handlerVaHex", "mnemonic", "groundedHandler", "meaning"],
        limit=64,
    )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <title>Status/Menu VM Table Review</title>
  <style>
    body {{ font-family: system-ui, sans-serif; margin: 24px; color: #17202a; }}
    .metrics {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; }}
    .metric {{ border: 1px solid #d8dee8; border-radius: 6px; padding: 10px; background: #f7f9fc; }}
    .metric div {{ color: #667085; font-size: 12px; }}
    table {{ width: 100%; border-collapse: collapse; margin: 12px 0 28px; }}
    th, td {{ border: 1px solid #d8dee8; padding: 7px; vertical-align: top; font-size: 13px; }}
    th {{ background: #edf2f8; text-align: left; }}
    code {{ background: #f3f5f7; padding: 1px 3px; border-radius: 3px; }}
  </style>
</head>
<body>
  <h1>Status/Menu VM Table Review</h1>
  <div class="metrics">{metrics}</div>
  <h2>Conclusions</h2>
  <ul>{conclusions}</ul>
  <h2>0x40 Text/Status Control Handlers</h2>
  {handler_rows}
  <h2>40 14 tables</h2>
  {opcode14_rows}
  <h2>40 13 pointer tables</h2>
  {opcode13_rows}
  <h2>40 1f local branch lists</h2>
  {opcode1f_rows}
</body>
</html>
"""


def main() -> None:
    payload = build()
    OUT.mkdir(parents=True, exist_ok=True)
    WEB.mkdir(parents=True, exist_ok=True)
    JSON_OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    html = html_doc(payload)
    HTML_OUT.write_text(html, encoding="utf-8")
    WEB_HTML_OUT.write_text(html, encoding="utf-8")
    print(json.dumps(payload["summary"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
