#!/usr/bin/env python3
"""Focus-scan the generic object/script handler table.

This report intentionally stays narrow.  It scans the dispatch table used by
0x00402321 (`0x00440538`) and only promotes handlers that directly touch known
runtime surfaces: dialogue/text, map/collision, scenario condition state, active
object mutation, or battle-specific evidence.
"""
from __future__ import annotations

import argparse
import json
import struct
from collections import Counter
from pathlib import Path

from probe_exe_scene_tables import find_cns_strings, read_sections, va_to_offset


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

HANDLER_TABLE_VA = 0x00440538
SAVE_SELECTOR_SLICE_VA = 0x00440720
DEFAULT_HANDLER_VA = 0x0040239F
ENTRY_COUNT = 256


KNOWN_ADDRS = {
    0x00402321: ("generic-script-runner", "script-control"),
    0x00402360: ("nested-script-runner", "script-control"),
    0x0040239F: ("default-advance-handler", "script-control"),
    0x0055A1B8: ("script-stop-flag", "script-control"),
    0x00435C9D: ("active-object-remove", "active-object"),
    0x00435C04: ("active-object-callback-update", "active-object"),
    0x00432FF0: ("object-script-timer-update", "active-object"),
    0x00431FE8: ("active-object-add-by-stream-byte", "active-object-mutation"),
    0x00432541: ("active-object-remove-by-stream-byte", "active-object-mutation"),
    0x0042449C: ("map-loader", "map/collision"),
    0x00595AF0: ("map-layer0-grid", "map/collision"),
    0x0058D7D0: ("map-layer1-collision-flags", "map/collision"),
    0x0058D7CE: ("map-right-edge-collision-flags", "map/collision"),
    0x00595ADA: ("map-width", "map/collision"),
    0x00595ADC: ("map-height", "map/collision"),
    0x004576DC: ("camera-tile-x", "map/collision"),
    0x004576DE: ("camera-tile-y", "map/collision"),
    0x0043022D: ("actor-controller", "map/collision"),
    0x004319F8: ("collision-helper", "map/collision"),
    0x00409658: ("script-tile-step-handler", "map/collision"),
    0x0041B579: ("korean-text-routine", "dialogue/text"),
    0x0047F1D8: ("event-object-vm-table", "dialogue/text"),
    0x0041B687: ("event-object-dispatcher", "dialogue/text"),
    0x0041BB4C: ("event-text-handler-0b", "dialogue/text"),
    0x0041BB99: ("event-text-handler-0c", "dialogue/text"),
    0x0041BCA4: ("event-text-handler-0d", "dialogue/text"),
    0x0041FF44: ("battle-action-text-handler", "battle"),
    0x004D24AC: ("ataho-action-text-table", "battle"),
    0x0048B984: ("item-text-table", "dialogue/text"),
    0x0048B98C: ("drop-item-text-ref", "dialogue/text"),
    0x0059E370: ("primary-branch-state-table", "scenario-condition"),
    0x0059E33E: ("runtime-flag-59e33e", "scenario-condition"),
    0x0059E344: ("runtime-flag-59e344", "scenario-condition"),
    0x0059E345: ("runtime-flag-59e345", "scenario-condition"),
    0x0059E34D: ("runtime-flag-59e34d", "scenario-condition"),
    0x0059E2A8: ("runtime-list-state", "scenario-condition"),
    0x004576EC: ("party-candidate-low", "scenario-condition"),
    0x004576ED: ("party-candidate-high", "scenario-condition"),
    0x004576F8: ("party-candidate-table", "scenario-condition"),
    0x004576E8: ("active-object-count", "active-object"),
    0x004576E9: ("active-object-order", "active-object"),
    0x00457750: ("active-descriptor-slot-base", "active-object"),
    0x0059DB30: ("runtime-object-base-table", "active-object"),
    0x0059DD70: ("runtime-object-pointer-table", "active-object"),
    0x00574538: ("active-actor-slot-table", "active-object"),
    0x00574540: ("party-trail-index-table", "active-object"),
    0x00574550: ("party-trail-x-table", "active-object"),
    0x00574552: ("party-trail-y-table", "active-object"),
    0x00574554: ("party-trail-dir-table", "active-object"),
    0x0059E310: ("current-input-mask", "input"),
    0x0059E312: ("pressed-edge-mask", "input"),
}

KNOWN_CALL_RANGES = [
    (0x0041B771, 0x00420028, "event-object-vm-handler-range", "dialogue/text"),
    (0x0041DCCC, 0x0041DF2D, "branch-state-writer-object-stat", "scenario-condition"),
    (0x0041E0F2, 0x0041E1F6, "branch-state-writer-party-object", "scenario-condition"),
    (0x0041E390, 0x0041E431, "branch-state-writer-six-slot", "scenario-condition"),
    (0x0041FB36, 0x0041FB57, "branch-state-writer-list-selection", "scenario-condition"),
    (0x0042449C, 0x00424600, "map-loader-range", "map/collision"),
    (0x00430000, 0x00432050, "actor-controller-collision-range", "map/collision"),
]

FORCED_CATEGORY_BY_OPCODE = {
    0x00: "script-control",
    0x01: "script-control",
    0x02: "script-control",
    0x03: "script-control",
    0x04: "script-control",
    0x18: "sprite-frame",
    0x20: "sprite-frame",
    0x21: "sprite-frame",
    0x62: "active-object-mutation",
    0x63: "active-object-mutation",
    0x72: "map/collision",
}

FOCUS_CATEGORIES = {
    "dialogue/text",
    "map/collision",
    "scenario-condition",
    "active-object-mutation",
    "battle",
}


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


def section_for_va(sections: list[dict], va: int | None) -> str | None:
    if va is None:
        return None
    for section in sections:
        start = section["va"]
        end = start + section["raw_size"]
        if start <= va < end:
            return section["name"]
    return None


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


def handler_table(exe: bytes, sections: list[dict]) -> list[dict]:
    rows = []
    for opcode in range(ENTRY_COUNT):
        entry_va = HANDLER_TABLE_VA + opcode * 4
        handler_va = read_u32(exe, sections, entry_va)
        rows.append(
            {
                "opcode": opcode,
                "opcodeHex": f"0x{opcode:02x}",
                "entryVa": entry_va,
                "entryVaHex": hex32(entry_va),
                "handlerVa": handler_va,
                "handlerVaHex": hex32(handler_va),
                "handlerSection": section_for_va(sections, handler_va),
                "isDefaultHandler": handler_va == DEFAULT_HANDLER_VA,
            }
        )
    return rows


def handler_bytes(exe: bytes, sections: list[dict], handler_va: int, all_handlers: list[int]) -> bytes:
    offset = va_to_offset(sections, handler_va)
    if offset is None:
        return b""
    next_handlers = [va for va in all_handlers if va > handler_va]
    stop_va = min(next_handlers) if next_handlers else handler_va + 0x700
    size = max(0, min(0x700, stop_va - handler_va))
    code = exe[offset : offset + size]
    ret = code.find(b"\xc3")
    if ret >= 0:
        code = code[: ret + 1]
    return code


def range_label(target: int) -> tuple[str, str] | None:
    for start, end, label, category in KNOWN_CALL_RANGES:
        if start <= target <= end:
            return label, category
    return None


def scan_handler(
    exe: bytes,
    sections: list[dict],
    cns_strings: dict[int, str],
    row: dict,
    all_handlers: list[int],
) -> dict:
    handler_va = row["handlerVa"]
    categories: set[str] = set()
    refs = []
    calls = []
    if row["handlerSection"] == ".text" and handler_va is not None:
        code = handler_bytes(exe, sections, handler_va, all_handlers)
        row["scanBytes"] = len(code)
        for offset in range(max(0, len(code) - 3)):
            va = handler_va + offset
            value = struct.unpack_from("<I", code, offset)[0]
            known = KNOWN_ADDRS.get(value)
            if known:
                label, category = known
                categories.add(category)
                refs.append({"atVaHex": hex32(va), "targetVaHex": hex32(value), "label": label, "category": category})
                continue
            cns_name = cns_strings.get(value)
            if cns_name:
                if cns_name.startswith("btl_"):
                    category = "battle"
                elif cns_name.startswith("map"):
                    category = "map/collision"
                elif cns_name.startswith(("z", "boss_")):
                    category = "battle-resource"
                else:
                    category = "resource"
                categories.add(category)
                refs.append({"atVaHex": hex32(va), "targetVaHex": hex32(value), "label": f"cns:{cns_name}", "category": category})

        for offset in range(max(0, len(code) - 4)):
            if code[offset] != 0xE8:
                continue
            rel = struct.unpack_from("<i", code, offset + 1)[0]
            call_va = handler_va + offset
            target = call_va + 5 + rel
            label_category = KNOWN_ADDRS.get(target) or range_label(target)
            label = label_category[0] if label_category else None
            category = label_category[1] if label_category else None
            if category:
                categories.add(category)
            calls.append(
                {
                    "atVaHex": hex32(call_va),
                    "targetVaHex": hex32(target),
                    "targetSection": section_for_va(sections, target),
                    "label": label,
                    "category": category,
                }
            )
    forced = FORCED_CATEGORY_BY_OPCODE.get(row["opcode"])
    if forced:
        categories.add(forced)
    row["categories"] = sorted(categories)
    row["refs"] = refs
    row["calls"] = calls
    row["focus"] = bool(categories & FOCUS_CATEGORIES)
    return row


def build_summary(exe: bytes) -> dict:
    sections = read_sections(exe)
    rows = handler_table(exe, sections)
    all_handlers = sorted(
        {
            row["handlerVa"]
            for row in rows
            if row["handlerSection"] == ".text" and isinstance(row["handlerVa"], int)
        }
    )
    cns_strings = find_cns_strings(exe, sections)
    scanned = [scan_handler(exe, sections, cns_strings, row, all_handlers) for row in rows]
    focus_rows = [row for row in scanned if row["focus"] and not row["isDefaultHandler"]]
    by_category = Counter()
    for row in focus_rows:
        for category in row["categories"]:
            if category in FOCUS_CATEGORIES:
                by_category[category] += 1
    battle_direct = [row for row in focus_rows if "battle" in row["categories"]]
    text_direct = [row for row in focus_rows if "dialogue/text" in row["categories"]]
    return {
        "handlerTableVa": HANDLER_TABLE_VA,
        "handlerTableVaHex": hex32(HANDLER_TABLE_VA),
        "saveSelectorSliceVa": SAVE_SELECTOR_SLICE_VA,
        "saveSelectorSliceVaHex": hex32(SAVE_SELECTOR_SLICE_VA),
        "saveSelectorSliceOpcode": (SAVE_SELECTOR_SLICE_VA - HANDLER_TABLE_VA) // 4,
        "saveSelectorSliceOpcodeHex": f"0x{((SAVE_SELECTOR_SLICE_VA - HANDLER_TABLE_VA) // 4):02x}",
        "entryCount": len(scanned),
        "textEntryCount": sum(1 for row in scanned if row["handlerSection"] == ".text"),
        "defaultHandlerCount": sum(1 for row in scanned if row["isDefaultHandler"]),
        "nonDefaultTextHandlerCount": sum(1 for row in scanned if row["handlerSection"] == ".text" and not row["isDefaultHandler"]),
        "focusHandlerCount": len(focus_rows),
        "focusCategoryCounts": dict(sorted(by_category.items())),
        "battleDirectHandlerCount": len(battle_direct),
        "dialogueTextDirectHandlerCount": len(text_direct),
        "status": "generic-handler-focus-static-scan",
        "promotionNote": (
            "Direct static handler evidence only. This does not prove route-linked event execution, "
            "battle entry, or story flag mutation."
        ),
        "focusRows": focus_rows,
    }


def compact_refs(row: dict, category: str | None = None) -> str:
    parts = []
    for ref in row.get("refs", []):
        if category is None or ref.get("category") == category:
            parts.append(f"{ref['label']}@{ref['atVaHex']}")
    for call in row.get("calls", []):
        if call.get("label") and (category is None or call.get("category") == category):
            parts.append(f"call {call['label']}@{call['atVaHex']}")
    return ", ".join(parts[:6]) or "-"


def markdown(summary: dict) -> str:
    focus_rows = summary["focusRows"]
    lines = [
        "# Generic Script Handler Focus",
        "",
        f"Dispatcher table: `{summary['handlerTableVaHex']}` from `0x00402321`.",
        f"`0x00440720` is the same table's `{summary['saveSelectorSliceOpcodeHex']}` slice, so this report keeps it separate from the event/object VM table `0x0047f1d8`.",
        "",
        "This is a direct static scan only. It promotes only handlers that directly reference known text, map/collision, scenario-condition, active-object mutation, or battle surfaces.",
        "",
        "## Summary",
        "",
        "| field | value |",
        "| --- | --- |",
        f"| entries | {summary['entryCount']} |",
        f"| `.text` entries | {summary['textEntryCount']} |",
        f"| default handlers | {summary['defaultHandlerCount']} |",
        f"| non-default `.text` handlers | {summary['nonDefaultTextHandlerCount']} |",
        f"| focused handlers | {summary['focusHandlerCount']} |",
        f"| direct dialogue/text handlers | {summary['dialogueTextDirectHandlerCount']} |",
        f"| direct battle handlers | {summary['battleDirectHandlerCount']} |",
        "",
        "## Category Counts",
        "",
        "| category | handlers |",
        "| --- | ---: |",
    ]
    for category, count in summary["focusCategoryCounts"].items():
        lines.append(f"| {category} | {count} |")
    lines += [
        "",
        "## Focused Handlers",
        "",
        "| opcode | handler | categories | direct evidence |",
        "| --- | --- | --- | --- |",
    ]
    category_order = [
        "dialogue/text",
        "map/collision",
        "scenario-condition",
        "active-object-mutation",
        "battle",
    ]
    for row in sorted(focus_rows, key=lambda item: item["opcode"]):
        direct = []
        for category in category_order:
            if category in row["categories"]:
                evidence = compact_refs(row, category)
                direct.append(f"{category}: {evidence}")
        lines.append(
            f"| `{row['opcodeHex']}` | `{row['handlerVaHex']}` | "
            f"{', '.join(row['categories'])} | {'<br>'.join(direct) or '-'} |"
        )
    lines += [
        "",
        "## Narrow Conclusions",
        "",
        "- Dialogue/text in this generic table has direct evidence at opcode `0x44` and `0x45`, both calling the Korean text routine `0x0041b579`.",
        "- Map/collision candidates are concentrated around camera/tile-grid/collision globals and actor movement helpers; these are runtime map mechanics, not map-transition trigger proof.",
        "- Scenario-condition candidates directly read/write condition state tables such as `0x0059e370`, `0x0059e33e`, `0x0059e2a8`, and party/object candidate globals.",
        "- Active-object mutation has a small confirmed core at opcode `0x62`/`0x63`; many other active-object handlers are maintenance/object-list logic and are not shown as story proof.",
        "- No direct battle-entry handler was found in `0x00440538` by this scan. The known battle-action text evidence remains in the separate event/object VM handler `0x0041ff44` from table `0x0047f1d8`.",
        "",
        "## Boundary",
        "",
        "This scan does not decode full instruction lengths or operand layouts, and it does not prove route-linked event execution. It only gives a smaller list of handlers worth inspecting next.",
        "",
    ]
    return "\n".join(lines)


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--out", type=Path, default=OUT)
    args = parser.parse_args()

    exe = args.exe.read_bytes()
    summary = build_summary(exe)
    args.out.mkdir(parents=True, exist_ok=True)
    (args.out / "generic_script_handler_focus.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    print(f"wrote generic script handler focus -> {args.out / 'generic_script_handler_focus.json'}")


if __name__ == "__main__":
    main()
