#!/usr/bin/env python3
"""Scan map1_01a manifest record neighborhoods for strict source point tables."""
from __future__ import annotations

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

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

from extract_scene_events import 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"
MAP = "map1_01a"
FRONTIER_TARGET = "map2_02d"
SCAN_BEFORE = 0x60
SCAN_AFTER = 0x140
REF_TEXT_SECTIONS = {".text"}
REF_DATA_SECTIONS = {".rdata", ".data"}


def va_hex(value: int) -> str:
    return f"0x{value:08x}"


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 dword_at_va(data: bytes, sections: list[dict], va: int) -> int | None:
    offset = va_to_offset(sections, va)
    if offset is None or offset + 4 > len(data):
        return None
    return struct.unpack_from("<I", data, offset)[0]


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


def scan_refs_to_range(
    data: bytes,
    sections: list[dict],
    start_va: int,
    end_va: int,
    section_names: set[str],
    sample_limit: int = 8,
) -> dict:
    count = 0
    samples = []
    for section in sections:
        if section["name"] not in section_names:
            continue
        raw_start = section["raw"]
        raw_end = section["raw"] + section["raw_size"]
        raw = data[raw_start:raw_end]
        for index in range(0, max(0, len(raw) - 3), 4):
            value = struct.unpack_from("<I", raw, index)[0]
            if not (start_va <= value < end_va):
                continue
            count += 1
            if len(samples) >= sample_limit:
                continue
            samples.append({
                "section": section["name"],
                "refVa": section["va"] + index,
                "refVaHex": va_hex(section["va"] + index),
                "value": value,
                "valueHex": va_hex(value),
            })
    return {"count": count, "samples": samples}


def record_hex(row: dict) -> str:
    value = row.get("recordVaHex")
    if isinstance(value, str):
        return value
    value = row.get("recordVa")
    return va_hex(value) if isinstance(value, int) else "none"


def role_by_record(entry_context: dict) -> dict[str, str]:
    return {
        row.get("recordVaHex"): row.get("role", "unknown")
        for row in entry_context.get("recordContexts") or []
    }


def event_targets_by_record(event_transitions: list[dict]) -> dict[int, list[str]]:
    return {
        int(row["recordVa"]): row.get("targets") or []
        for row in event_transitions
        if isinstance(row.get("recordVa"), int)
    }


def event_owner_for_field(scene_events: list[dict], event_targets: dict[int, list[str]], field_va: int) -> dict | None:
    for event in scene_events:
        record_va = event.get("recordVa")
        point_end_va = event.get("pointTableEndVa")
        if not isinstance(record_va, int) or not isinstance(point_end_va, int):
            continue
        if record_va <= field_va < point_end_va:
            return {
                "kind": "strict-event-record",
                "map": event.get("map"),
                "recordVa": record_va,
                "recordVaHex": va_hex(record_va),
                "targets": event_targets.get(record_va, []),
                "pointTableVa": event.get("pointTableVa"),
                "pointTableVaHex": va_hex(event["pointTableVa"]) if isinstance(event.get("pointTableVa"), int) else None,
                "pointTableEndVa": point_end_va,
                "pointTableEndVaHex": va_hex(point_end_va),
            }
    return None


def manifest_record_rows(scene_manifest: list[dict]) -> list[dict]:
    rows = []
    for row in scene_manifest:
        if row.get("map") != MAP:
            continue
        record_va = row.get("recordVa")
        if not isinstance(record_va, int):
            continue
        rows.append({
            "recordVa": record_va,
            "recordVaHex": va_hex(record_va),
            "sceneIdHex": row.get("sceneIdHex"),
            "tilesets": row.get("tilesets") or [],
            "followingResourceNames": [item.get("name") for item in row.get("followingResources") or []],
            "nextMapRefVaHex": row.get("nextMapRefVaHex"),
        })
    rows.sort(key=lambda row: row["recordVa"])
    return rows


def source_record_for_field(records: list[dict], roles: dict[str, str], field_va: int) -> dict | None:
    previous = None
    for record in records:
        if record["recordVa"] <= field_va:
            previous = record
            continue
        break
    if previous is None:
        return None
    return {
        "recordVaHex": previous["recordVaHex"],
        "role": roles.get(previous["recordVaHex"], "unknown"),
        "sceneIdHex": previous.get("sceneIdHex"),
    }


def pointer_candidate(
    exe: bytes,
    sections: list[dict],
    source_map_size: dict,
    field_va: int,
    value: int,
    strings: dict[int, str],
    scene_events: list[dict],
    event_targets: dict[int, list[str]],
    source_records: list[dict],
    roles: dict[str, str],
) -> dict | None:
    if value in strings:
        return None
    if va_to_offset(sections, value) is None:
        return None
    raw_points, in_bounds_points = read_point_table(
        exe,
        sections,
        value,
        source_map_size["width"],
        source_map_size["height"],
    )
    if not raw_points:
        return None
    text_refs = scan_refs_to_range(exe, sections, value, value + 0x80, REF_TEXT_SECTIONS)
    data_refs = scan_refs_to_range(exe, sections, value, value + 0x80, REF_DATA_SECTIONS)
    owner_event = event_owner_for_field(scene_events, event_targets, field_va)
    source_record = source_record_for_field(source_records, roles, field_va)
    owner_map = (owner_event or {}).get("map")
    owner_targets = (owner_event or {}).get("targets") or []
    promotion = owner_map == MAP and FRONTIER_TARGET in owner_targets and bool(in_bounds_points)
    if owner_event:
        reason = (
            f"Point table is owned by strict event `{owner_event['map']}` `{owner_event['recordVaHex']}` "
            f"with targets {', '.join(owner_targets) or '-'}, not by a `{MAP}` source event."
        )
    else:
        reason = "Point-like pointer is not attached to a strict map1_01a event record or target map code path."
    return {
        "fieldVa": field_va,
        "fieldVaHex": va_hex(field_va),
        "payloadVa": value,
        "payloadVaHex": va_hex(value),
        "sourceRecord": source_record,
        "ownerEvent": owner_event,
        "rawPointCount": len(raw_points),
        "inBoundsPointCount": len(in_bounds_points),
        "firstRawPoints": raw_points[:8],
        "firstInBoundsPoints": in_bounds_points[:8],
        "rangeTextRefCount": text_refs["count"],
        "rangeDataRefCount": data_refs["count"],
        "rangeTextRefs": text_refs["samples"],
        "rangeDataRefs": data_refs["samples"],
        "promotionEvidence": promotion,
        "reason": reason,
    }


def scan_record_window(
    exe: bytes,
    sections: list[dict],
    strings: dict[int, str],
    source_map_size: dict,
    record: dict,
    source_records: list[dict],
    roles: dict[str, str],
    scene_events: list[dict],
    event_targets: dict[int, list[str]],
) -> dict:
    start_va = record["recordVa"] - SCAN_BEFORE
    end_va = record["recordVa"] + SCAN_AFTER
    candidates = []
    for field_va in range(start_va, end_va, 4):
        value = dword_at_va(exe, sections, field_va)
        if value is None:
            continue
        candidate = pointer_candidate(
            exe,
            sections,
            source_map_size,
            field_va,
            value,
            strings,
            scene_events,
            event_targets,
            source_records,
            roles,
        )
        if candidate:
            candidates.append(candidate)
    return {
        **record,
        "role": roles.get(record["recordVaHex"], "unknown"),
        "scanRangeHex": f"{va_hex(start_va)}..{va_hex(end_va)}",
        "pointLikePointerCount": len(candidates),
        "promotableSourcePointCount": sum(1 for row in candidates if row.get("promotionEvidence")),
        "pointLikePointers": candidates,
    }


def build_summary(
    exe: bytes,
    map_data: dict,
    scene_manifest: list[dict],
    scene_events: list[dict],
    event_transitions: list[dict],
    entry_context: dict,
) -> dict:
    sections = read_sections(exe)
    strings = find_cns_strings(exe, sections)
    source_map = map_data[MAP]
    source_map_size = {"width": source_map["width"], "height": source_map["height"]}
    source_records = manifest_record_rows(scene_manifest)
    roles = role_by_record(entry_context)
    event_targets = event_targets_by_record(event_transitions)
    records = [
        scan_record_window(
            exe,
            sections,
            strings,
            source_map_size,
            record,
            source_records,
            roles,
            scene_events,
            event_targets,
        )
        for record in source_records
    ]
    promotable = [
        candidate
        for record in records
        for candidate in record.get("pointLikePointers") or []
        if candidate.get("promotionEvidence")
    ]
    incoming = [
        candidate
        for record in records
        for candidate in record.get("pointLikePointers") or []
        if (candidate.get("ownerEvent") or {}).get("map") != MAP
        and MAP in ((candidate.get("ownerEvent") or {}).get("targets") or [])
    ]
    conclusion = (
        "The only strict-event-owned 24-point table found in the map1_01a manifest neighborhoods is owned "
        "by the strict map1_02b -> map1_01a event record at 0x00503350. It confirms the incoming route, but "
        "it is not a map1_01a source hotspot and it does not target map2_02d. Other short point-like reads "
        "come from tail/script blocks without a strict map1_01a event owner. No scanned record provides "
        "strict map1_01a source coordinate evidence for map1_01a -> map2_02d."
    )
    return {
        "map": MAP,
        "frontierTarget": FRONTIER_TARGET,
        "sourceMapSize": source_map_size,
        "scanWindowBeforeHex": va_hex(SCAN_BEFORE),
        "scanWindowAfterHex": va_hex(SCAN_AFTER),
        "records": records,
        "incomingPointTableCount": len(incoming),
        "strictSourceHotspotFound": bool(promotable),
        "promotionStatus": "blocked",
        "conclusion": conclusion,
    }


def html_page(summary: dict) -> str:
    record_rows = []
    pointer_rows = []
    for record in summary["records"]:
        record_rows.append(
            "<tr>"
            f"<td><code>{html.escape(record['recordVaHex'])}</code></td>"
            f"<td>{html.escape(record['role'])}</td>"
            f"<td><code>{html.escape(record['scanRangeHex'])}</code></td>"
            f"<td>{record['pointLikePointerCount']}</td>"
            f"<td>{record['promotableSourcePointCount']}</td>"
            f"<td>{html.escape(', '.join(record['followingResourceNames']) or '-')}</td>"
            "</tr>"
        )
        for candidate in record.get("pointLikePointers") or []:
            owner = candidate.get("ownerEvent") or {}
            owner_text = (
                f"{owner.get('map')} {owner.get('recordVaHex')} targets={','.join(owner.get('targets') or []) or '-'}"
                if owner
                else "no strict event owner"
            )
            points = ", ".join(f"{point['x']},{point['y']}" for point in candidate.get("firstInBoundsPoints") or [])
            pointer_rows.append(
                "<tr>"
                f"<td><code>{html.escape(record['recordVaHex'])}</code></td>"
                f"<td><code>{html.escape(candidate['fieldVaHex'])}</code></td>"
                f"<td><code>{html.escape(candidate['payloadVaHex'])}</code></td>"
                f"<td>{candidate['rawPointCount']} / {candidate['inBoundsPointCount']}</td>"
                f"<td>{html.escape(owner_text)}</td>"
                f"<td>{candidate['promotionEvidence']}</td>"
                f"<td>{html.escape(candidate['reason'])}</td>"
                f"<td>{html.escape(points or '-')}</td>"
                "</tr>"
            )
    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 Manifest Point Scan</title>",
        "  <style>",
        "    :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #101010; color: #eee; }",
        "    body { margin: 0; padding: 24px; }",
        "    h1 { margin: 0 0 8px; font-size: 24px; }",
        "    h2 { margin: 24px 0 8px; font-size: 18px; }",
        "    p { max-width: 1100px; color: #bbb; line-height: 1.45; }",
        "    table { width: 100%; border-collapse: collapse; margin: 12px 0 20px; font-size: 13px; }",
        "    th, td { border-bottom: 1px solid #303030; padding: 7px 8px; text-align: left; vertical-align: top; }",
        "    th { position: sticky; top: 0; background: #181818; color: #ddd; }",
        "    code { color: #d7f0ff; }",
        "  </style>",
        "</head>",
        "<body>",
        "  <h1>map1_01a Manifest Point Scan</h1>",
        f"  <p>target <code>{html.escape(summary['frontierTarget'])}</code>; strict source hotspot found: "
        f"{summary['strictSourceHotspotFound']}; promotion status <code>{html.escape(summary['promotionStatus'])}</code></p>",
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        "  <h2>Records</h2>",
        "  <table><thead><tr><th>record</th><th>role</th><th>scan range</th><th>point-like pointers</th><th>promotable source points</th><th>following resources</th></tr></thead><tbody>",
        *record_rows,
        "  </tbody></table>",
        "  <h2>Point-Like Pointers</h2>",
        "  <table><thead><tr><th>record</th><th>field</th><th>payload</th><th>raw/in-bounds</th><th>owner</th><th>promotion</th><th>reason</th><th>first points</th></tr></thead><tbody>",
        *pointer_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_manifest_point_scan.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\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("--scene-manifest", type=Path, default=OUT / "scene_manifest.json")
    parser.add_argument("--scene-events", type=Path, default=OUT / "scene_events.json")
    parser.add_argument("--event-transitions", type=Path, default=OUT / "event_transitions.json")
    parser.add_argument("--entry-context", type=Path, default=OUT / "map1_01a_entry_context.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),
        json.loads(args.scene_manifest.read_text(encoding="utf-8")),
        json.loads(args.scene_events.read_text(encoding="utf-8")),
        json.loads(args.event_transitions.read_text(encoding="utf-8")),
        json.loads(args.entry_context.read_text(encoding="utf-8")),
    )
    write_outputs(summary, args.out_dir)
    print(f"wrote map1_01a manifest point scan -> {args.out_dir / 'map1_01a_manifest_point_scan.json'}")


if __name__ == "__main__":
    main()
