#!/usr/bin/env python3
"""Probe whether the opening/title stream is the known event/object VM bytecode."""
from __future__ import annotations

import argparse
import html
import json
import struct
import sys
from pathlib import Path
from typing import Any

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

from probe_exe_scene_tables import offset_to_va, read_sections, va_to_offset
from summarize_opening_start_context import (
    OPENING_SCRIPT_END,
    OPENING_SCRIPT_START,
    file_hex,
    hex32,
)


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

EVENT_VM_DISPATCH_TABLE_VA = 0x0047F1D8
EVENT_VM_DISPATCHER_VA = 0x0041B687
EVENT_VM_DISPATCH_CALL_VA = 0x0041B6CD
OPENING_SELECTOR_RANGE_START = 0x00494C34
OPENING_SELECTOR_RANGE_END = OPENING_SCRIPT_END

OPENING_ANCHORS = {
    "opening selector range start": OPENING_SELECTOR_RANGE_START,
    "opening stream start": OPENING_SCRIPT_START,
    "opening stream end": OPENING_SCRIPT_END,
    "opening resource cluster": 0x004A2CB0,
    "opening resource cluster pointer ref": 0x0047E668,
    "MLK id 10 candidate row": 0x004A2DF4,
    "WLK id 35 candidate row 1": 0x004A3060,
    "WLK id 35 candidate row 2": 0x004A308C,
    "btl_k2 descriptor pointer candidate": 0x004A3CA4,
    "logo_00 descriptor pointer candidate": 0x004A3CF8,
    "title descriptor pointer candidate": 0x004A3D20,
}

EVENT_VM_OPCODE_META: dict[int, dict[str, Any]] = {
    0x02: {"name": "display-cursor", "length": 4},
    0x03: {"name": "display-state", "length": 4},
    0x04: {"name": "display-timer", "length": 4},
    0x06: {"name": "input-wait", "length": 4},
    0x07: {"name": "object-position", "length": 8},
    0x08: {"name": "display-style", "length": "variable"},
    0x09: {"name": "control-flow-call", "length": 8},
    0x0A: {"name": "control-flow-return", "length": 1},
    0x0B: {"name": "text-source-inherited", "length": 4},
    0x0C: {"name": "text-source-wide", "length": 20},
    0x0D: {"name": "text-source-pointer", "length": 8},
    0x0E: {"name": "cursor-relative", "length": 8},
    0x15: {"name": "cursor-origin-relative", "length": 8},
    0x1B: {"name": "data-bank-select", "length": 4},
    0x37: {"name": "global-timer", "length": 4},
}


def find_section(sections: list[dict], name: str) -> dict | None:
    for section in sections:
        if section["name"] == name:
            return section
    return None


def section_bytes(exe: bytes, section: dict) -> bytes:
    return exe[section["raw"] : section["raw"] + section["raw_size"]]


def find_value_refs_in_section(exe: bytes, sections: list[dict], section_name: str, value: int) -> list[int]:
    section = find_section(sections, section_name)
    if not section:
        return []
    raw = section_bytes(exe, section)
    needle = struct.pack("<I", value)
    refs: list[int] = []
    search = 0
    while True:
        hit = raw.find(needle, search)
        if hit < 0:
            break
        va = offset_to_va(sections, section["raw"] + hit)
        if va is not None:
            refs.append(va)
        search = hit + 1
    return refs


def find_range_refs_in_section(
    exe: bytes,
    sections: list[dict],
    section_name: str,
    start: int,
    end: int,
    limit: int = 200,
) -> list[dict[str, Any]]:
    section = find_section(sections, section_name)
    if not section:
        return []
    raw = section_bytes(exe, section)
    refs: list[dict[str, Any]] = []
    for index in range(0, len(raw) - 3):
        value = struct.unpack_from("<I", raw, index)[0]
        if start <= value <= end:
            va = offset_to_va(sections, section["raw"] + index)
            if va is not None:
                refs.append({"refVa": va, "refVaHex": hex32(va), "value": value, "valueHex": hex32(value)})
                if len(refs) >= limit:
                    break
    return refs


def event_vm_instruction_length(data: bytes, pos: int) -> tuple[int | None, str]:
    opcode = data[pos]
    meta = EVENT_VM_OPCODE_META.get(opcode)
    if not meta:
        return None, "unknown-opcode"
    length = meta["length"]
    if length == "variable":
        if pos + 3 >= len(data):
            return None, "truncated-variable-opcode"
        mode = data[pos + 2]
        if mode == 0:
            return 4, "mode0"
        if mode == 1:
            return 8, "mode1"
        return None, f"unsupported-mode-0x{mode:02x}"
    if pos + int(length) > len(data):
        return None, "truncated-opcode"
    return int(length), "fixed"


def decode_linear(data: bytes, start_index: int, max_commands: int = 80) -> dict[str, Any]:
    pos = start_index
    commands: list[dict[str, Any]] = []
    invalid_reason = ""
    while pos < len(data) and len(commands) < max_commands:
        opcode = data[pos]
        length, detail = event_vm_instruction_length(data, pos)
        if length is None:
            invalid_reason = detail
            break
        meta = EVENT_VM_OPCODE_META[opcode]
        raw = data[pos : pos + length]
        commands.append(
            {
                "rangeOffset": pos,
                "streamVaHex": hex32(OPENING_SCRIPT_START + pos),
                "opcodeHex": f"0x{opcode:02x}",
                "name": meta["name"],
                "length": length,
                "detail": detail,
                "rawHex": raw.hex(" "),
            }
        )
        pos += length
    return {
        "startOffset": start_index,
        "startVaHex": hex32(OPENING_SCRIPT_START + start_index),
        "decodedBytes": max(0, pos - start_index),
        "decodedCommandCount": len(commands),
        "coverageFromStart": (max(0, pos - start_index) / max(1, len(data) - start_index)),
        "invalidReason": invalid_reason or ("max-command-limit" if len(commands) >= max_commands else "end-of-range"),
        "commands": commands,
    }


def scan_best_linear_starts(data: bytes, sample_size: int = 512) -> list[dict[str, Any]]:
    probes: list[dict[str, Any]] = []
    for start in range(min(len(data), sample_size)):
        decoded = decode_linear(data, start, max_commands=64)
        if decoded["decodedCommandCount"] == 0:
            continue
        probes.append(
            {
                "startOffset": decoded["startOffset"],
                "startVaHex": decoded["startVaHex"],
                "decodedBytes": decoded["decodedBytes"],
                "decodedCommandCount": decoded["decodedCommandCount"],
                "coverageFromStart": decoded["coverageFromStart"],
                "invalidReason": decoded["invalidReason"],
            }
        )
    return sorted(probes, key=lambda item: (-item["decodedBytes"], item["startOffset"]))[:20]


def scan_dword_low_bytes(data: bytes) -> dict[str, Any]:
    counts: dict[int, int] = {}
    known_event_rows: list[dict[str, Any]] = []
    for pos in range(0, len(data) - 3, 4):
        value = struct.unpack_from("<I", data, pos)[0]
        low = value & 0xFF
        counts[low] = counts.get(low, 0) + 1
        if low in EVENT_VM_OPCODE_META:
            known_event_rows.append(
                {
                    "streamVaHex": hex32(OPENING_SCRIPT_START + pos),
                    "rangeOffset": pos,
                    "valueHex": hex32(value),
                    "lowByteHex": f"0x{low:02x}",
                    "eventVmName": EVENT_VM_OPCODE_META[low]["name"],
                }
            )
    rows = len(data) // 4
    return {
        "rowCount": rows,
        "knownEventVmLowByteRowCount": len(known_event_rows),
        "knownEventVmLowByteRatio": len(known_event_rows) / max(1, rows),
        "knownEventVmLowByteRowsSample": known_event_rows[:80],
        "lowByteTop20": [
            {"lowByteHex": f"0x{low:02x}", "count": count}
            for low, count in sorted(counts.items(), key=lambda item: (-item[1], item[0]))[:20]
        ],
    }


def collect_code_refs(exe: bytes, sections: list[dict]) -> dict[str, Any]:
    anchor_refs: list[dict[str, Any]] = []
    for label, value in OPENING_ANCHORS.items():
        text_refs = find_value_refs_in_section(exe, sections, ".text", value)
        data_refs = find_value_refs_in_section(exe, sections, ".data", value)
        anchor_refs.append(
            {
                "label": label,
                "valueHex": hex32(value),
                "textRefCount": len(text_refs),
                "textRefsHex": [hex32(ref) for ref in text_refs[:20]],
                "dataRefCount": len(data_refs),
                "dataRefsHex": [hex32(ref) for ref in data_refs[:20]],
            }
        )
    dispatcher_refs = []
    for label, value in {
        "event VM dispatch table": EVENT_VM_DISPATCH_TABLE_VA,
        "event VM dispatcher": EVENT_VM_DISPATCHER_VA,
        "event VM dispatch call": EVENT_VM_DISPATCH_CALL_VA,
    }.items():
        text_refs = find_value_refs_in_section(exe, sections, ".text", value)
        data_refs = find_value_refs_in_section(exe, sections, ".data", value)
        dispatcher_refs.append(
            {
                "label": label,
                "valueHex": hex32(value),
                "textRefCount": len(text_refs),
                "textRefsHex": [hex32(ref) for ref in text_refs[:20]],
                "dataRefCount": len(data_refs),
                "dataRefsHex": [hex32(ref) for ref in data_refs[:20]],
            }
        )
    return {
        "openingRangeTextRefs": find_range_refs_in_section(
            exe, sections, ".text", OPENING_SCRIPT_START, OPENING_SCRIPT_END
        ),
        "openingRangeDataRefs": find_range_refs_in_section(
            exe, sections, ".data", OPENING_SCRIPT_START, OPENING_SCRIPT_END
        ),
        "selectorRangeTextRefs": find_range_refs_in_section(
            exe, sections, ".text", OPENING_SELECTOR_RANGE_START, OPENING_SELECTOR_RANGE_END
        ),
        "selectorRangeDataRefs": find_range_refs_in_section(
            exe, sections, ".data", OPENING_SELECTOR_RANGE_START, OPENING_SELECTOR_RANGE_END
        ),
        "openingAnchorRefs": anchor_refs,
        "eventVmDispatcherRefs": dispatcher_refs,
    }


def build_probe() -> dict[str, Any]:
    if not EXE.exists():
        return {"available": False, "reason": "Hwanse2.exe not found"}
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    start = va_to_offset(sections, OPENING_SCRIPT_START)
    end = va_to_offset(sections, OPENING_SCRIPT_END)
    if start is None or end is None:
        return {"available": False, "reason": "opening range does not map to a file range"}
    stream = exe[start:end]
    linear = decode_linear(stream, 0)
    best = scan_best_linear_starts(stream)
    dword_scan = scan_dword_low_bytes(stream)
    refs = collect_code_refs(exe, sections)
    looks_like_event_vm = linear["decodedBytes"] >= 32 or (best and best[0]["decodedBytes"] >= 128)
    consumer_found = any(row["textRefCount"] for row in refs["openingAnchorRefs"])
    dispatcher_bound = any(
        row["textRefCount"] or row["dataRefCount"] for row in refs["eventVmDispatcherRefs"]
    ) and looks_like_event_vm
    return {
        "available": True,
        "openingRangeVaHex": f"{hex32(OPENING_SCRIPT_START)}..{hex32(OPENING_SCRIPT_END)}",
        "openingSelectorRangeVaHex": f"{hex32(OPENING_SELECTOR_RANGE_START)}..{hex32(OPENING_SELECTOR_RANGE_END)}",
        "openingRangeFileOffsetHex": f"{file_hex(start)}..{file_hex(end)}",
        "openingRangeBytes": len(stream),
        "eventVmDispatcherKnown": True,
        "eventVmDispatchTableVaHex": hex32(EVENT_VM_DISPATCH_TABLE_VA),
        "eventVmDispatcherVaHex": hex32(EVENT_VM_DISPATCHER_VA),
        "eventVmDispatchCallVaHex": hex32(EVENT_VM_DISPATCH_CALL_VA),
        "linearDecodeFromOpeningStart": linear,
        "bestLinearDecodeStarts": best,
        "dwordLowByteScan": dword_scan,
        "codeRefs": refs,
        "openingLooksLikeEventVmBytecode": bool(looks_like_event_vm),
        "openingResourceStreamConsumerFound": bool(consumer_found),
        "eventVmDispatcherOpeningBound": bool(dispatcher_bound),
        "openingEventVmPromoted": bool(looks_like_event_vm and consumer_found and dispatcher_bound),
        "scenarioExtractionStatus": (
            "event-vm-opening-promoted"
            if looks_like_event_vm and consumer_found and dispatcher_bound
            else "dispatcher-known-opening-consumer-missing"
        ),
        "interpretation": (
            "The event/object VM dispatcher is grounded, but the selected opening range does not decode "
            "as that VM from 0x004a2d38 and no .text consumer for the exact opening range was promoted. "
            "The opening remains a resource/action descriptor stream candidate, not an extracted scenario script."
        ),
        "nextEvidenceNeeded": [
            "Find the code path that consumes the opening selected pointer 0x004a2d38 or its parent selector table.",
            "Trace the 0x004a2df4 MIDI row and 0x004a3060/0x004a308c WLK rows to audio calls.",
            "Bind opening actor/action indices to grounded skill payloads for 맹호스페셜/선렬각/쾌진격 신기.",
            "Capture runtime execution at title/opening if static refs remain indirect.",
        ],
    }


def build_markdown(probe: dict[str, Any]) -> str:
    if not probe.get("available"):
        return f"# Opening Event VM Probe\n\nUnavailable: {probe.get('reason')}\n"
    linear = probe["linearDecodeFromOpeningStart"]
    dword = probe["dwordLowByteScan"]
    refs = probe["codeRefs"]
    lines = [
        "# Opening Event VM Probe",
        "",
        "오프닝 선택 포인터 범위가 기존 `event/object VM` 바이트코드인지 좁게 검증한 보고서다.",
        "",
        "## Verdict",
        "",
        f"- opening range: `{probe['openingRangeVaHex']}` / file `{probe['openingRangeFileOffsetHex']}`",
        f"- event VM dispatch table: `{probe['eventVmDispatchTableVaHex']}`",
        f"- event VM dispatcher/call: `{probe['eventVmDispatcherVaHex']}` / `{probe['eventVmDispatchCallVaHex']}`",
        f"- opening looks like event VM bytecode: `{probe['openingLooksLikeEventVmBytecode']}`",
        f"- opening resource stream consumer found in `.text`: `{probe['openingResourceStreamConsumerFound']}`",
        f"- event VM dispatcher bound to opening: `{probe['eventVmDispatcherOpeningBound']}`",
        f"- opening event VM promoted: `{probe['openingEventVmPromoted']}`",
        f"- scenario extraction status: `{probe['scenarioExtractionStatus']}`",
        "",
        probe["interpretation"],
        "",
        "## Linear Decode From 0x004a2d38",
        "",
        f"- decoded bytes before failure: `{linear['decodedBytes']}`",
        f"- decoded command count: `{linear['decodedCommandCount']}`",
        f"- failure/end reason: `{linear['invalidReason']}`",
        "",
    ]
    if linear["commands"]:
        lines.extend(["| VA | opcode | name | len | raw |", "| --- | --- | --- | ---: | --- |"])
        for row in linear["commands"][:24]:
            lines.append(
                f"| `{row['streamVaHex']}` | `{row['opcodeHex']}` | {row['name']} | "
                f"{row['length']} | `{row['rawHex']}` |"
            )
    else:
        lines.append("Known event VM opcode로 시작하지 않는다.")
    lines.extend(
        [
            "",
            "## Best Offset Probes",
            "",
            "선택 범위 내부의 다른 시작 오프셋도 시험했지만, 이 검사는 byte-stream처럼 보이는지 확인하는 보조 지표일 뿐이다.",
            "",
            "| start | decoded bytes | commands | reason |",
            "| --- | ---: | ---: | --- |",
        ]
    )
    for row in probe["bestLinearDecodeStarts"][:12]:
        lines.append(
            f"| `{row['startVaHex']}` | {row['decodedBytes']} | {row['decodedCommandCount']} | "
            f"`{row['invalidReason']}` |"
        )
    lines.extend(
        [
            "",
            "## Dword Low-Byte Collision Scan",
            "",
            f"- dword rows: `{dword['rowCount']}`",
            f"- rows whose low byte matches a known event VM opcode: `{dword['knownEventVmLowByteRowCount']}`",
            f"- ratio: `{dword['knownEventVmLowByteRatio']:.3f}`",
            "",
            "이 값은 충돌 가능성이 크다. 오프닝 기존 리뷰의 `0x24`, `0x26` 같은 값도 실행 opcode가 아니라 packed operand일 수 있다.",
            "",
            "| low byte | count |",
            "| --- | ---: |",
        ]
    )
    for row in dword["lowByteTop20"]:
        lines.append(f"| `{row['lowByteHex']}` | {row['count']} |")
    lines.extend(
        [
            "",
            "## Opening Anchor References",
            "",
            "| anchor | value | .text refs | .data refs |",
            "| --- | --- | ---: | ---: |",
        ]
    )
    for row in refs["openingAnchorRefs"]:
        lines.append(
            f"| {row['label']} | `{row['valueHex']}` | {row['textRefCount']} | {row['dataRefCount']} |"
        )
    lines.extend(
        [
            "",
            "## Opening Range References",
            "",
            f"- `.text` refs into opening range: `{len(refs['openingRangeTextRefs'])}`",
            f"- `.data` refs into opening range: `{len(refs['openingRangeDataRefs'])}`",
            f"- parent selector range: `{probe['openingSelectorRangeVaHex']}`",
            f"- `.text` refs into parent selector range: `{len(refs['selectorRangeTextRefs'])}`",
            f"- `.data` refs into parent selector range: `{len(refs['selectorRangeDataRefs'])}`",
            "",
        ]
    )
    if refs["openingRangeTextRefs"]:
        lines.extend(["| ref | value |", "| --- | --- |"])
        for row in refs["openingRangeTextRefs"][:40]:
            lines.append(f"| `{row['refVaHex']}` | `{row['valueHex']}` |")
    if refs["selectorRangeTextRefs"]:
        lines.extend(
            [
                "",
                "Parent selector range text refs exist, but they do not prove the exact opening stream consumer by themselves.",
                "",
                "| parent-range ref | value |",
                "| --- | --- |",
            ]
        )
        for row in refs["selectorRangeTextRefs"][:40]:
            lines.append(f"| `{row['refVaHex']}` | `{row['valueHex']}` |")
    lines.extend(
        [
            "",
            "## Event VM Dispatcher References",
            "",
            "| target | value | .text refs | .data refs |",
            "| --- | --- | ---: | ---: |",
        ]
    )
    for row in refs["eventVmDispatcherRefs"]:
        lines.append(
            f"| {row['label']} | `{row['valueHex']}` | {row['textRefCount']} | {row['dataRefCount']} |"
        )
    lines.extend(["", "## Next Evidence Needed", ""])
    lines.extend(f"- {item}" for item in probe["nextEvidenceNeeded"])
    return "\n".join(lines) + "\n"


def build_html(probe: dict[str, Any], md: str) -> str:
    summary = {}
    if probe.get("available"):
        summary = {
            "openingEventVmPromoted": probe["openingEventVmPromoted"],
            "openingLooksLikeEventVmBytecode": probe["openingLooksLikeEventVmBytecode"],
            "openingResourceStreamConsumerFound": probe["openingResourceStreamConsumerFound"],
            "eventVmDispatcherOpeningBound": probe["eventVmDispatcherOpeningBound"],
            "scenarioExtractionStatus": probe["scenarioExtractionStatus"],
        }
    return f"""<!doctype html>
<html lang=\"ko\">
<head>
  <meta charset=\"utf-8\">
  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
  <title>Opening Event VM Probe</title>
  <style>
    body {{ margin: 0; font: 14px/1.55 system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif; color: #202124; background: #f6f8fb; }}
    main {{ max-width: 1120px; margin: 0 auto; padding: 24px 18px 48px; }}
    a {{ color: #0b57d0; }}
    pre {{ white-space: pre-wrap; overflow-wrap: anywhere; background: #fff; border: 1px solid #d8dee8; border-radius: 8px; padding: 16px; }}
    .summary {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px; margin: 16px 0; }}
    .card {{ background: #fff; border: 1px solid #d8dee8; border-radius: 8px; padding: 12px; }}
    .label {{ color: #5f6368; font-size: 12px; text-transform: uppercase; }}
    .value {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; margin-top: 4px; }}
  </style>
</head>
<body>
<main>
  <h1>Opening Event VM Probe</h1>
  <p><a href=\"../web/index.html\">Home</a> · <a href=\"opening_opcode_review.html\">opening opcode review</a> · <a href=\"opening_start_context.md\">opening start context</a></p>
  <div class=\"summary\">
    {''.join(f'<div class=\"card\"><div class=\"label\">{html.escape(key)}</div><div class=\"value\">{html.escape(str(value))}</div></div>' for key, value in summary.items())}
  </div>
  <pre>{html.escape(md)}</pre>
</main>
<script>
window.HWANSE_OPENING_EVENT_VM_PROBE_READY = true;
window.hwanseOpeningEventVmProbe = {json.dumps(summary, ensure_ascii=False)};
</script>
</body>
</html>
"""


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--html-out", type=Path)
    args = parser.parse_args()
    OUT.mkdir(exist_ok=True)
    probe = build_probe()
    md = build_markdown(probe)
    (OUT / "opening_event_vm_probe.json").write_text(
        json.dumps(probe, ensure_ascii=False, separators=(",", ":")), encoding="utf-8"
    )
    if args.html_out is not None:
        args.html_out.parent.mkdir(parents=True, exist_ok=True)
        args.html_out.write_text(build_html(probe, md), encoding="utf-8")
    status = probe.get("scenarioExtractionStatus", "unavailable")
    promoted = probe.get("openingEventVmPromoted", False)
    print(f"opening event VM probe: {status}; promoted={promoted}")


if __name__ == "__main__":
    main()
