#!/usr/bin/env python3
"""Classify map1_01a/map2_02d scene refs against the strict event-record shape."""
from __future__ import annotations

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

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

from extract_scene_events import find_pointer_refs, read_point_table
from probe_exe_scene_tables import find_cns_strings, read_sections, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
SOURCE = "map1_01a"
TARGET = "map2_02d"
CONFIRMED_SOURCE = "map1_02b"
CONFIRMED_TARGET = "map1_01a"
CURRENT_FRONTIER_SOURCE_RECORD = 0x00542B44
CURRENT_FRONTIER_TARGET_RECORD = 0x00542BAC
CONFIRMED_EVENT_RECORD = 0x00503350
MAP_RE = re.compile(r"map\d+_\d+[a-z]\.cns$")
FIELD_MAP_RE = re.compile(r"map\d+_\d+[a-z]\.cns$")


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


def load_json(path: Path, fallback: Any) -> Any:
    if not path.exists():
        return fallback
    return json.loads(path.read_text(encoding="utf-8"))


def parse_maps_js(path: Path) -> dict:
    text = path.read_text(encoding="utf-8")
    prefix = "window.HWANSE_MAPS = "
    if not text.startswith(prefix):
        raise ValueError(f"{path} does not contain the expected maps.js wrapper")
    return json.loads(text[len(prefix):].rstrip(";\n"))


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


def offset_to_va_local(sections: list[dict], offset: int) -> int | None:
    section = section_for_offset(sections, offset)
    if not section:
        return None
    return section["va"] + offset - section["raw"]


def dword_at_offset(exe: bytes, offset: int) -> int | None:
    if offset < 0 or offset + 4 > len(exe):
        return None
    return struct.unpack_from("<I", exe, offset)[0]


def dword_at_va(exe: bytes, sections: list[dict], va: int) -> int | None:
    offset = va_to_offset(sections, va)
    if offset is None:
        return None
    return dword_at_offset(exe, offset)


def dword_rows(exe: bytes, sections: list[dict], strings: dict[int, str], va: int, count: int = 8) -> list[dict]:
    rows = []
    for index in range(count):
        field_va = va + index * 4
        value = dword_at_va(exe, sections, field_va)
        rows.append({
            "offsetHex": f"+0x{index * 4:02x}",
            "vaHex": hex32(field_va),
            "value": value,
            "valueHex": hex32(value),
            "string": strings.get(value or -1),
        })
    return rows


def candidate_ref_offsets(exe: bytes, sections: list[dict], strings: dict[int, str], filename: str) -> list[int]:
    string_vas = [va for va, name in strings.items() if name == filename]
    offsets = []
    for string_va in string_vas:
        needle = struct.pack("<I", string_va)
        cursor = 0
        while True:
            hit = exe.find(needle, cursor)
            if hit < 0:
                break
            cursor = hit + 1
            if section_for_offset(sections, hit):
                offsets.append(hit)
    return sorted(set(offsets))


def all_field_map_ref_offsets(exe: bytes, sections: list[dict], strings: dict[int, str]) -> list[int]:
    offsets = []
    for string_va, filename in strings.items():
        if not FIELD_MAP_RE.fullmatch(filename):
            continue
        needle = struct.pack("<I", string_va)
        cursor = 0
        while True:
            hit = exe.find(needle, cursor)
            if hit < 0:
                break
            cursor = hit + 1
            if section_for_offset(sections, hit):
                offsets.append(hit)
    return sorted(set(offsets))


def event_targets(event: dict) -> list[str]:
    targets = list(event.get("targets") or [])
    for ref in event.get("eventDispatchRefs") or []:
        for value in ref.get("conditionLinkedStrings") or []:
            if isinstance(value, str) and value.endswith(".cns"):
                targets.append(value[:-4])
    return sorted(set(targets))


def field_map_targets_from_dispatch_refs(dispatch_refs: list[dict]) -> list[str]:
    targets = set()
    for ref in dispatch_refs:
        for value in ref.get("conditionLinkedStrings") or []:
            if isinstance(value, str) and FIELD_MAP_RE.fullmatch(value):
                targets.add(value[:-4])
    return sorted(targets)


def linked_cns_from_dispatch_refs(dispatch_refs: list[dict]) -> list[str]:
    values = set()
    for ref in dispatch_refs:
        for value in ref.get("conditionLinkedStrings") or []:
            if isinstance(value, str) and value.endswith(".cns"):
                values.add(value)
    return sorted(values)


def classify_record(
    exe: bytes,
    sections: list[dict],
    strings: dict[int, str],
    map_data: dict,
    offset: int,
) -> dict:
    va = offset_to_va_local(sections, offset)
    filename = strings.get(dword_at_offset(exe, offset) or -1)
    map_stem = filename[:-4] if isinstance(filename, str) and filename.endswith(".cns") else filename
    scene_id = dword_at_offset(exe, offset + 4)
    zero = dword_at_offset(exe, offset + 8)
    event_kind = dword_at_offset(exe, offset + 12)
    sentinel = dword_at_offset(exe, offset + 16)
    point_ptr = dword_at_offset(exe, offset + 20)
    point_count_hint = dword_at_offset(exe, offset + 24)
    scene_record_like = bool(
        isinstance(filename, str)
        and MAP_RE.fullmatch(filename)
        and isinstance(scene_id, int)
        and 0 < scene_id < 0x100000
        and scene_id & 0xFF in {0x00, 0x18}
        and zero == 0
    )
    map_row = map_data.get(map_stem or "") or {}
    width = int(map_row.get("width") or 0)
    height = int(map_row.get("height") or 0)
    point_valid = isinstance(point_ptr, int) and va_to_offset(sections, point_ptr) is not None
    raw_points: list[dict] = []
    in_bounds: list[dict] = []
    if point_valid and width and height:
        raw_points, in_bounds = read_point_table(exe, sections, point_ptr, width, height)
    event_shape = bool(
        scene_record_like
        and isinstance(event_kind, int)
        and event_kind < 0x100
        and sentinel == 0x3F
        and point_valid
        and in_bounds
    )
    next_resource = strings.get(sentinel or -1)
    rejection_reasons = []
    if not scene_record_like:
        rejection_reasons.append("not a scene record")
    if scene_record_like and not (isinstance(event_kind, int) and event_kind < 0x100):
        rejection_reasons.append("field +0x0c is not a small event kind")
    if scene_record_like and sentinel != 0x3F:
        if next_resource:
            rejection_reasons.append(f"field +0x10 is resource {next_resource}, not event sentinel 0x3f")
        else:
            rejection_reasons.append("field +0x10 is not event sentinel 0x3f")
    if scene_record_like and sentinel == 0x3F and not point_valid:
        rejection_reasons.append("point table pointer is invalid")
    if scene_record_like and sentinel == 0x3F and point_valid and not in_bounds:
        rejection_reasons.append("point table has no in-bounds points")
    if event_shape:
        rejection_reasons = []
    return {
        "recordVa": va,
        "recordVaHex": hex32(va),
        "filename": filename,
        "map": map_stem,
        "sceneId": scene_id,
        "sceneIdHex": f"0x{scene_id:04x}" if isinstance(scene_id, int) else None,
        "eventKind": event_kind,
        "eventKindHex": hex32(event_kind),
        "sentinelHex": hex32(sentinel),
        "pointTableHex": hex32(point_ptr),
        "pointCountHint": point_count_hint,
        "nextResourceAtSentinelField": next_resource,
        "sceneRecordLike": scene_record_like,
        "eventRecordShape": event_shape,
        "rawPointCount": len(raw_points),
        "inBoundsPointCount": len(in_bounds),
        "firstInBoundsPoints": in_bounds[:8],
        "rejectionReasons": rejection_reasons,
        "dwords": dword_rows(exe, sections, strings, va or 0),
    }


def strict_event_scan_row(
    exe: bytes,
    sections: list[dict],
    row: dict,
    extracted_event_vas: set[int],
) -> dict:
    record_va = row.get("recordVa")
    dispatch_refs = []
    if isinstance(record_va, int):
        dispatch_refs = find_pointer_refs(exe, sections, record_va + 12, {".data", ".rdata"})
    field_targets = field_map_targets_from_dispatch_refs(dispatch_refs)
    return {
        "recordVa": record_va,
        "recordVaHex": row.get("recordVaHex"),
        "map": row.get("map"),
        "sceneIdHex": row.get("sceneIdHex"),
        "eventKindHex": row.get("eventKindHex"),
        "pointTableHex": row.get("pointTableHex"),
        "inBoundsPointCount": row.get("inBoundsPointCount"),
        "fieldMapTargets": field_targets,
        "allLinkedCns": linked_cns_from_dispatch_refs(dispatch_refs),
        "eventDispatchRefCount": len(dispatch_refs),
        "extractedEventPresent": record_va in extracted_event_vas if isinstance(record_va, int) else False,
    }


def relaxed_event_scan_row(row: dict) -> dict:
    return {
        "recordVa": row.get("recordVa"),
        "recordVaHex": row.get("recordVaHex"),
        "map": row.get("map"),
        "sceneIdHex": row.get("sceneIdHex"),
        "eventKindHex": row.get("eventKindHex"),
        "sentinelHex": row.get("sentinelHex"),
        "pointTableHex": row.get("pointTableHex"),
        "rawPointCount": row.get("rawPointCount"),
        "inBoundsPointCount": row.get("inBoundsPointCount"),
        "nextResourceAtSentinelField": row.get("nextResourceAtSentinelField"),
        "reason": compact_reason(row),
    }


def scan_all_event_shape_records(
    exe: bytes,
    sections: list[dict],
    strings: dict[int, str],
    map_data: dict,
    events: list[dict],
) -> dict:
    extracted_event_vas = {
        event.get("recordVa")
        for event in events
        if isinstance(event.get("recordVa"), int)
    }
    all_rows = [
        classify_record(exe, sections, strings, map_data, offset)
        for offset in all_field_map_ref_offsets(exe, sections, strings)
    ]
    scene_like_rows = [row for row in all_rows if row.get("sceneRecordLike")]
    strict_rows = [row for row in scene_like_rows if row.get("eventRecordShape")]
    strict_scan_rows = [
        strict_event_scan_row(exe, sections, row, extracted_event_vas)
        for row in strict_rows
    ]
    strict_event_vas = {
        row.get("recordVa")
        for row in strict_rows
        if isinstance(row.get("recordVa"), int)
    }
    relaxed_non_strict_rows = [
        row for row in scene_like_rows
        if not row.get("eventRecordShape")
        and isinstance(row.get("eventKind"), int)
        and row.get("eventKind") < 0x100
    ]
    source_strict_rows = [
        row for row in strict_scan_rows
        if row.get("map") == SOURCE
    ]
    target_strict_rows = [
        row for row in strict_scan_rows
        if TARGET in (row.get("fieldMapTargets") or [])
    ]
    any_target_mention_rows = [
        row for row in strict_scan_rows
        if f"{TARGET}.cns" in (row.get("allLinkedCns") or [])
    ]
    relaxed_scan_rows = [
        relaxed_event_scan_row(row)
        for row in relaxed_non_strict_rows
    ]
    relaxed_touching_source_or_target = [
        row for row in relaxed_scan_rows
        if row.get("map") in {SOURCE, TARGET}
    ]
    return {
        "allMapReferenceCount": len(all_rows),
        "allMapSceneRecordLikeCount": len(scene_like_rows),
        "allStrictEventShapeRecordCount": len(strict_rows),
        "allStrictEventShapesMatchExtractedEvents": strict_event_vas == extracted_event_vas,
        "missingFromExtractedEvents": [
            hex32(value)
            for value in sorted(strict_event_vas - extracted_event_vas)
        ],
        "extractedEventsMissingFromStrictShapeScan": [
            hex32(value)
            for value in sorted(extracted_event_vas - strict_event_vas)
        ],
        "allStrictEventSourceMap1Count": len(source_strict_rows),
        "allStrictEventTargetMap2_02dCount": len(target_strict_rows),
        "allStrictEventMap2_02dAnyMentionCount": len(any_target_mention_rows),
        "relaxedNonStrictSmallEventRecordCount": len(relaxed_non_strict_rows),
        "relaxedNonStrictMapCount": len({
            row.get("map")
            for row in relaxed_scan_rows
            if row.get("map")
        }),
        "relaxedRowsTouchingSourceOrTargetCount": len(relaxed_touching_source_or_target),
        "strictEventRecords": strict_scan_rows,
        "relaxedNonStrictRows": relaxed_scan_rows,
        "relaxedRowsTouchingSourceOrTarget": relaxed_touching_source_or_target,
    }


def build_summary(exe: bytes, map_data: dict, events: list[dict]) -> dict:
    sections = read_sections(exe)
    strings = find_cns_strings(exe, sections)
    source_records = [
        classify_record(exe, sections, strings, map_data, offset)
        for offset in candidate_ref_offsets(exe, sections, strings, f"{SOURCE}.cns")
    ]
    target_records = [
        classify_record(exe, sections, strings, map_data, offset)
        for offset in candidate_ref_offsets(exe, sections, strings, f"{TARGET}.cns")
    ]
    confirmed_record = classify_record(
        exe,
        sections,
        strings,
        map_data,
        va_to_offset(sections, CONFIRMED_EVENT_RECORD) or 0,
    )
    current_source_record = next(
        (row for row in source_records if row.get("recordVa") == CURRENT_FRONTIER_SOURCE_RECORD),
        {},
    )
    current_target_record = next(
        (row for row in target_records if row.get("recordVa") == CURRENT_FRONTIER_TARGET_RECORD),
        {},
    )
    source_events = [event for event in events if event.get("map") == SOURCE]
    target_strict_events = [
        event for event in events
        if TARGET in event_targets(event)
    ]
    direct_strict_events = [
        event for event in source_events
        if TARGET in event_targets(event)
    ]
    source_event_shape_records = [row for row in source_records if row.get("eventRecordShape")]
    target_event_shape_records = [row for row in target_records if row.get("eventRecordShape")]
    current_frontier_event_shape = bool(
        current_source_record.get("eventRecordShape")
        or current_target_record.get("eventRecordShape")
    )
    all_event_shape_scan = scan_all_event_shape_records(exe, sections, strings, map_data, events)
    conclusion = (
        "The confirmed map1_02b -> map1_01a event record has the strict shape "
        "map pointer, scene id, zero, small event kind, sentinel 0x3f, point-table pointer, hint. "
        "Every direct map1_01a and map2_02d scene reference in the current route scan is a load/scene-list "
        "record instead: field +0x0c is a resource-load word and field +0x10 points at a tileset resource. "
        "A broad scan of every field-map scene record finds the same strict event records as scene_events.json, "
        "with no strict event starting at map1_01a and no strict event targeting map2_02d. "
        "This keeps map1_01a -> map2_02d blocked."
    )
    return {
        "source": SOURCE,
        "target": TARGET,
        "confirmedReferenceRoute": f"{CONFIRMED_SOURCE} -> {CONFIRMED_TARGET}",
        "strictEventShape": {
            "field0": "map filename pointer",
            "field4": "scene id",
            "field8": "zero",
            "field12": "small event kind",
            "field16": "0x0000003f sentinel",
            "field20": "point table pointer",
            "field24": "point count hint",
        },
        "confirmedEventRecord": confirmed_record,
        "sourceReferenceCount": len(source_records),
        "targetReferenceCount": len(target_records),
        "sourceEventShapeRecordCount": len(source_event_shape_records),
        "targetEventShapeRecordCount": len(target_event_shape_records),
        "directStrictEventTransitionCount": len(direct_strict_events),
        "sourceStrictEventCount": len(source_events),
        "targetStrictEventIncomingCount": len(target_strict_events),
        "allEventShapeScan": all_event_shape_scan,
        "currentFrontierSourceRecord": current_source_record,
        "currentFrontierTargetRecord": current_target_record,
        "currentFrontierEventShapeFound": current_frontier_event_shape,
        "sourceRecords": source_records,
        "targetRecords": target_records,
        "directStrictEventTransitions": [
            {
                "map": event.get("map"),
                "recordVaHex": event.get("recordVaHex"),
                "targets": event_targets(event),
                "activePoints": event.get("activePoints") or [],
            }
            for event in direct_strict_events
        ],
        "promotionStatus": "blocked",
        "conclusion": conclusion,
    }


def compact_reason(row: dict) -> str:
    if row.get("eventRecordShape"):
        return "event-shaped"
    return "; ".join(row.get("rejectionReasons") or []) or "-"


def html_page(summary: dict) -> str:
    confirmed = summary.get("confirmedEventRecord") or {}
    broad = summary.get("allEventShapeScan") or {}

    def row_html(role: str, row: dict) -> str:
        return (
            "<tr>"
            f"<td>{html.escape(role)}</td>"
            f"<td><code>{html.escape(str(row.get('recordVaHex') or '-'))}</code></td>"
            f"<td><code>{html.escape(str(row.get('map') or '-'))}</code></td>"
            f"<td>{row.get('eventRecordShape')}</td>"
            f"<td><code>{html.escape(str(row.get('eventKindHex') or '-'))}</code></td>"
            f"<td><code>{html.escape(str(row.get('sentinelHex') or '-'))}</code></td>"
            f"<td>{html.escape(compact_reason(row))}</td>"
            "</tr>"
        )

    frontier_rows = "\n".join([
        row_html("source", summary.get("currentFrontierSourceRecord") or {}),
        row_html("target", summary.get("currentFrontierTargetRecord") or {}),
    ])
    ref_rows = "\n".join(
        row_html("source" if row.get("map") == SOURCE else "target", row)
        for row in [*summary["sourceRecords"], *summary["targetRecords"]]
    )
    strict_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('recordVaHex') or '-'))}</code></td>"
        f"<td><code>{html.escape(str(row.get('map') or '-'))}</code></td>"
        f"<td><code>{html.escape(str(row.get('eventKindHex') or '-'))}</code></td>"
        f"<td><code>{html.escape(str(row.get('pointTableHex') or '-'))}</code></td>"
        f"<td>{html.escape(', '.join(row.get('fieldMapTargets') or []) or '-')}</td>"
        f"<td>{row.get('extractedEventPresent')}</td>"
        "</tr>"
        for row in broad.get("strictEventRecords") or []
    )
    relaxed_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('recordVaHex') or '-'))}</code></td>"
        f"<td><code>{html.escape(str(row.get('map') or '-'))}</code></td>"
        f"<td><code>{html.escape(str(row.get('sceneIdHex') or '-'))}</code></td>"
        f"<td><code>{html.escape(str(row.get('eventKindHex') or '-'))}</code></td>"
        f"<td><code>{html.escape(str(row.get('sentinelHex') or '-'))}</code></td>"
        f"<td>{html.escape(str(row.get('inBoundsPointCount')))} / {html.escape(str(row.get('rawPointCount')))}</td>"
        f"<td>{html.escape(str(row.get('reason') or '-'))}</td>"
        "</tr>"
        for row in broad.get("relaxedNonStrictRows") or []
    )
    return "\n".join([
        "<!doctype html>",
        '<html lang="en">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>map1_01a Event Shape Scan</title>",
        "  <style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;margin:24px}table{border-collapse:collapse;width:100%;max-width:1280px}td,th{border:1px solid #333;padding:6px 8px;text-align:left;vertical-align:top}th{background:#1f1f1f}code{color:#9bd4ff}</style>",
        "</head>",
        "<body>",
        "  <h1>map1_01a Event Shape Scan</h1>",
        f"  <p>route <code>{summary['source']} -&gt; {summary['target']}</code>; source event-shaped records {summary['sourceEventShapeRecordCount']}; target event-shaped records {summary['targetEventShapeRecordCount']}; current frontier event shape found {summary['currentFrontierEventShapeFound']}; promotion <code>{summary['promotionStatus']}</code>.</p>",
        f"  <p>All map scene-like records {broad.get('allMapSceneRecordLikeCount')}; all strict event-shaped records {broad.get('allStrictEventShapeRecordCount')}; strict events match scene_events.json {broad.get('allStrictEventShapesMatchExtractedEvents')}; all strict event source map1_01a count {broad.get('allStrictEventSourceMap1Count')}; all strict event target map2_02d count {broad.get('allStrictEventTargetMap2_02dCount')}; relaxed non-strict small-event records {broad.get('relaxedNonStrictSmallEventRecordCount')}; relaxed non-strict source/target rows {broad.get('relaxedRowsTouchingSourceOrTargetCount')}.</p>",
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        f"  <p>confirmed event record <code>{html.escape(str(confirmed.get('recordVaHex') or '-'))}</code> <code>{html.escape(str(confirmed.get('map') or '-'))}</code>; kind <code>{html.escape(str(confirmed.get('eventKindHex') or '-'))}</code>; sentinel <code>{html.escape(str(confirmed.get('sentinelHex') or '-'))}</code>; point table <code>{html.escape(str(confirmed.get('pointTableHex') or '-'))}</code>; in-bounds points {confirmed.get('inBoundsPointCount')}.</p>",
        "  <h2>Current Frontier Records</h2>",
        "  <table><thead><tr><th>role</th><th>record</th><th>map</th><th>event shaped</th><th>+0x0c</th><th>+0x10</th><th>reason</th></tr></thead><tbody>",
        frontier_rows,
        "  </tbody></table>",
        "  <h2>Source/Target Scene References</h2>",
        "  <table><thead><tr><th>role</th><th>record</th><th>map</th><th>event shaped</th><th>+0x0c</th><th>+0x10</th><th>reason</th></tr></thead><tbody>",
        ref_rows,
        "  </tbody></table>",
        "  <h2>All Strict Event-Shaped Records</h2>",
        "  <table><thead><tr><th>record</th><th>source map</th><th>kind</th><th>point table</th><th>field-map targets</th><th>extracted event</th></tr></thead><tbody>",
        strict_rows,
        "  </tbody></table>",
        "  <h2>Relaxed Non-Strict Small-Event Records</h2>",
        "  <p>These rows have a small +0x0c value but fail the strict event shape. They are retained as extraction-gap context and do not promote this route.</p>",
        "  <table><thead><tr><th>record</th><th>map</th><th>scene</th><th>+0x0c</th><th>+0x10</th><th>points</th><th>reason</th></tr></thead><tbody>",
        relaxed_rows,
        "  </tbody></table>",
        "</body>",
        "</html>",
        "",
    ])


def write_outputs(summary: dict, out_dir: Path = OUT) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "map1_01a_event_shape_scan.json").write_text(
        json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--maps", type=Path, default=OUT / "maps.js")
    parser.add_argument("--events", type=Path, default=OUT / "scene_events.json")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(
        args.exe.read_bytes(),
        parse_maps_js(args.maps),
        load_json(args.events, []),
    )
    write_outputs(summary, args.out_dir)
    print(
        "wrote map1_01a event shape scan "
        f"(source event-shaped records={summary['sourceEventShapeRecordCount']}) "
        f"-> {args.out_dir / 'map1_01a_event_shape_scan.json'}"
    )


if __name__ == "__main__":
    main()
