#!/usr/bin/env python3
"""Extract probable map scene records from Hwanse2.exe.

The executable embeds repeated scene blocks as CNS resource-load entries followed
by a map CNS filename pointer, a small scene id, and zero. Older tooling looked
after the map record for tilesets, which actually picked up the next scene's
loads in route-adjacent streams. This manifest keeps both sides for audit, but
uses the resources before the map record when they are present.
"""
from __future__ import annotations

import argparse
import json
import re
import struct
import sys
from pathlib import Path

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

from probe_exe_scene_tables import (
    classify_cns,
    find_cns_strings,
    offset_to_va,
    read_sections,
    va_to_offset,
)


MAP_RE = re.compile(r"map\d+_\d+[a-z]\.cns$")
TILESET_RE = re.compile(r"map_[a-z][123]\.cns$")


def dword_at(data: bytes, offset: int) -> int:
    return struct.unpack_from("<I", data, offset)[0]


def looks_like_scene_id(value: int) -> bool:
    return 0 < value < 0x100000 and (value & 0xFF) in {0x18, 0x00}


def collect_after(
    data: bytes,
    sections: list[dict],
    strings: dict[int, str],
    start: int,
    max_dwords: int,
) -> tuple[list[dict], int | None]:
    resources: list[dict] = []
    next_map_va: int | None = None
    for index in range(max_dwords):
        off = start + index * 4
        if off + 4 > len(data):
            break
        value = dword_at(data, off)
        name = strings.get(value)
        if not name:
            continue
        if MAP_RE.fullmatch(name):
            next_map_va = offset_to_va(sections, off)
            break
        resources.append(
            {
                "name": name[:-4],
                "filename": name,
                "kind": classify_cns(name),
                "refVa": offset_to_va(sections, off),
            }
        )
    return resources, next_map_va


def looks_like_scene_record_at(data: bytes, strings: dict[int, str], off: int) -> bool:
    if off < 0 or off + 12 > len(data):
        return False
    name = strings.get(dword_at(data, off))
    if not name or not MAP_RE.fullmatch(name):
        return False
    return looks_like_scene_id(dword_at(data, off + 4)) and dword_at(data, off + 8) == 0


def collect_before(
    data: bytes,
    sections: list[dict],
    strings: dict[int, str],
    start: int,
    max_dwords: int,
) -> tuple[list[dict], int | None]:
    lower = max(0, start - max_dwords * 4)
    previous_map_va: int | None = None
    scan_start = lower
    for off in range(start - 4, lower - 1, -4):
        if looks_like_scene_record_at(data, strings, off):
            previous_map_va = offset_to_va(sections, off)
            scan_start = off + 12
            break

    resources: list[dict] = []
    for off in range(scan_start, start, 4):
        value = dword_at(data, off)
        name = strings.get(value)
        if not name or MAP_RE.fullmatch(name):
            continue
        resources.append(
            {
                "name": name[:-4],
                "filename": name,
                "kind": classify_cns(name),
                "refVa": offset_to_va(sections, off),
            }
        )
    return resources, previous_map_va


def extract_manifest(data: bytes, max_dwords: int) -> list[dict]:
    sections = read_sections(data)
    strings = find_cns_strings(data, sections)
    map_string_vas = {
        va: name for va, name in strings.items() if MAP_RE.fullmatch(name)
    }

    records = []
    seen: set[tuple[int, int]] = set()
    for map_va, map_name in map_string_vas.items():
        needle = struct.pack("<I", map_va)
        search = 0
        while True:
            hit = data.find(needle, search)
            if hit < 0:
                break
            search = hit + 1
            if hit + 12 > len(data):
                continue
            scene_id = dword_at(data, hit + 4)
            zero = dword_at(data, hit + 8)
            if zero != 0 or not looks_like_scene_id(scene_id):
                continue
            following_resources, next_map_ref_va = collect_after(
                data,
                sections,
                strings,
                hit + 12,
                max_dwords,
            )
            preceding_resources, previous_map_ref_va = collect_before(
                data,
                sections,
                strings,
                hit,
                max_dwords,
            )
            preceding_tilesets = [
                item["name"]
                for item in preceding_resources
                if TILESET_RE.fullmatch(item["filename"])
            ]
            following_tilesets = [
                item["name"]
                for item in following_resources
                if TILESET_RE.fullmatch(item["filename"])
            ]
            if len(preceding_tilesets) >= 2:
                resources = preceding_resources
                tilesets = preceding_tilesets
                resource_source = "preceding"
            else:
                resources = following_resources
                tilesets = following_tilesets
                resource_source = "following"
            if len(tilesets) < 2:
                continue
            ref_va = offset_to_va(sections, hit)
            if ref_va is None or (map_va, ref_va) in seen:
                continue
            seen.add((map_va, ref_va))
            records.append(
                {
                    "map": map_name[:-4],
                    "filename": map_name,
                    "mapStringVa": map_va,
                    "mapStringVaHex": f"0x{map_va:08x}",
                    "recordVa": ref_va,
                    "recordVaHex": f"0x{ref_va:08x}",
                    "recordFileOffset": hit,
                    "recordFileOffsetHex": f"0x{hit:06x}",
                    "sceneId": scene_id,
                    "sceneIdHex": f"0x{scene_id:04x}",
                    "sceneGroup": scene_id >> 8,
                    "sceneSlot": scene_id & 0xFF,
                    "resourceSource": resource_source,
                    "tilesets": tilesets,
                    "sprites": [
                        item["name"] for item in resources if item["kind"] == "sprite"
                    ],
                    "resources": resources,
                    "precedingResources": preceding_resources,
                    "followingResources": following_resources,
                    "previousMapRefVa": previous_map_ref_va,
                    "previousMapRefVaHex": f"0x{previous_map_ref_va:08x}" if previous_map_ref_va is not None else None,
                    "nextMapRefVa": next_map_ref_va,
                    "nextMapRefVaHex": f"0x{next_map_ref_va:08x}" if next_map_ref_va is not None else None,
                }
            )
    records.sort(key=lambda item: (item["recordVa"], item["map"]))
    return records


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=Path("Hwanse2.exe"))
    parser.add_argument("--out", type=Path, default=Path("out/scene_manifest.json"))
    parser.add_argument("--max-dwords", type=int, default=80)
    parser.add_argument("--map", dest="map_filter", help="only print records for one map stem")
    args = parser.parse_args()

    manifest = extract_manifest(args.exe.read_bytes(), args.max_dwords)
    args.out.parent.mkdir(parents=True, exist_ok=True)
    args.out.write_text(json.dumps(manifest, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")

    shown = [item for item in manifest if not args.map_filter or item["map"] == args.map_filter]
    print(f"wrote {len(manifest)} scene records -> {args.out}")
    for item in shown[:100]:
        print(
            f"{item['sceneIdHex']} {item['map']} "
            f"record=0x{item['recordVa']:08x} tilesets={','.join(item['tilesets'])}"
        )
    if len(shown) > 100:
        print(f"... {len(shown) - 100} more")


if __name__ == "__main__":
    main()
