#!/usr/bin/env python3
"""Summarize scene payload context for the blocked map1_01a route."""
from __future__ import annotations

import argparse
import html
import json
import re
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"
SOURCE = "map1_01a"
TARGET = "map2_02d"
SOURCE_RECORD_VA = 0x00542B44
TARGET_RECORD_VA = 0x00542BAC
LOCAL_SCENE_DATA_START = 0x0053F000
LOCAL_SCENE_DATA_END = 0x00540000
REF_TEXT_SECTIONS = {".text"}
REF_DATA_SECTIONS = {".rdata", ".data"}


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


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


def dword_at_va(data: bytes, sections: list[dict], va: int) -> int:
    offset = va_to_offset(sections, va)
    if offset is None or offset + 4 > len(data):
        raise ValueError(f"VA {va_hex(va)} is not readable")
    return dword_at(data, offset)


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 offset_to_va(sections: list[dict], offset: int) -> int | None:
    for section in sections:
        start = section["raw"]
        end = section["raw"] + section["raw_size"]
        if start <= offset < end:
            return section["va"] + offset - start
    return None


def section_for_va(sections: list[dict], va: int) -> str | None:
    offset = va_to_offset(sections, va)
    if offset is None:
        return None
    section = section_for_offset(sections, offset)
    return section["name"] if section else None


def classify_cns(name: str) -> str:
    if re.fullmatch(r"map_[a-z][123]\.cns", name):
        return "tileset"
    if re.fullmatch(r"map\d+_\d+[a-z]\.cns", name):
        return "map"
    if name.startswith("cara_") or name.startswith("face_"):
        return "sprite"
    return "other"


def value_kind(value: int, strings: dict[int, str], sections: list[dict], map_size: dict) -> dict:
    lo = value & 0xFFFF
    hi = value >> 16
    if value in strings:
        return {
            "kind": "cns",
            "label": strings[value],
            "cnsKind": classify_cns(strings[value]),
        }
    if value == 0:
        return {"kind": "zero", "label": "0"}
    if hi == 0 and value <= 0x400:
        return {"kind": "small-scalar", "label": str(value)}
    if lo < map_size["width"] and hi < map_size["height"]:
        return {"kind": "packed-pair", "label": f"{lo},{hi}"}
    pointed_section = section_for_va(sections, value)
    if pointed_section is not None and 0x00400000 <= value <= 0x00600000:
        return {"kind": "pointer", "label": f"{pointed_section}+{value - 0x00400000:#x}"}
    return {"kind": "dword", "label": va_hex(value)}


def read_dword_rows(
    data: bytes,
    sections: list[dict],
    strings: dict[int, str],
    start_va: int,
    count: int,
    map_size: dict,
) -> list[dict]:
    rows = []
    for index in range(count):
        va = start_va + index * 4
        offset = va_to_offset(sections, va)
        if offset is None or offset + 4 > len(data):
            break
        value = dword_at(data, offset)
        lo = value & 0xFFFF
        hi = value >> 16
        kind = value_kind(value, strings, sections, map_size)
        rows.append({
            "index": index,
            "va": va,
            "vaHex": va_hex(va),
            "value": value,
            "valueHex": va_hex(value),
            "u16": [lo, hi],
            "kind": kind["kind"],
            "label": kind["label"],
            **({"cnsKind": kind["cnsKind"]} if "cnsKind" in kind else {}),
        })
    return rows


def scan_refs_to_value(
    data: bytes,
    sections: list[dict],
    value: int,
    section_names: set[str],
    sample_limit: int = 16,
) -> dict:
    needle = struct.pack("<I", value)
    count = 0
    samples = []
    search = 0
    while True:
        hit = data.find(needle, search)
        if hit < 0:
            break
        search = hit + 1
        section = section_for_offset(sections, hit)
        if section is None or section["name"] not in section_names:
            continue
        ref_va = offset_to_va(sections, hit)
        if ref_va is None:
            continue
        count += 1
        if len(samples) < sample_limit:
            samples.append({
                "section": section["name"],
                "refVa": ref_va,
                "refVaHex": va_hex(ref_va),
                "fileOffset": hit,
                "fileOffsetHex": f"0x{hit:06x}",
            })
    return {"count": count, "samples": samples}


def scan_refs_to_range(
    data: bytes,
    sections: list[dict],
    start_va: int,
    end_va: int,
    section_names: set[str],
    sample_limit: int = 16,
) -> 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)):
            value = struct.unpack_from("<I", raw, index)[0]
            if not (start_va <= value < end_va):
                continue
            count += 1
            if len(samples) >= sample_limit:
                continue
            ref_va = section["va"] + index
            samples.append({
                "section": section["name"],
                "refVa": ref_va,
                "refVaHex": va_hex(ref_va),
                "fileOffset": raw_start + index,
                "fileOffsetHex": f"0x{raw_start + index:06x}",
                "value": value,
                "valueHex": va_hex(value),
            })
    return {"count": count, "samples": samples}


def is_scene_local_payload_pointer(row: dict, strings: dict[int, str], sections: list[dict]) -> bool:
    value = row["value"]
    if value in strings:
        return False
    if not (LOCAL_SCENE_DATA_START <= value < LOCAL_SCENE_DATA_END):
        return False
    return va_to_offset(sections, value) is not None


def classify_payload_sample(sample: list[dict], point_scan: dict, range_text_refs: dict) -> str:
    scalar_count = sum(1 for row in sample if row["kind"] == "small-scalar" or row["kind"] == "zero")
    pointer_count = sum(1 for row in sample if row["kind"] == "pointer" or row["kind"] == "cns")
    if point_scan["inBoundsPointCount"] == 0 and range_text_refs["count"] == 0 and scalar_count >= 8:
        return "sprite-or-rect-payload-like"
    if pointer_count > scalar_count and range_text_refs["count"] == 0:
        return "resource-pointer-table-like"
    return "unclassified-resource-payload"


def payload_summary(
    data: bytes,
    sections: list[dict],
    strings: dict[int, str],
    field_row: dict,
    map_size: dict,
    sample_count: int = 24,
    ref_window_size: int = 0x60,
) -> dict:
    ptr_va = field_row["value"]
    sample = read_dword_rows(data, sections, strings, ptr_va, sample_count, map_size)
    raw_points, in_bounds = read_point_table(data, sections, ptr_va, map_size["width"], map_size["height"])
    exact_text_refs = scan_refs_to_value(data, sections, ptr_va, REF_TEXT_SECTIONS)
    exact_data_refs = scan_refs_to_value(data, sections, ptr_va, REF_DATA_SECTIONS)
    range_text_refs = scan_refs_to_range(data, sections, ptr_va, ptr_va + ref_window_size, REF_TEXT_SECTIONS)
    range_data_refs = scan_refs_to_range(data, sections, ptr_va, ptr_va + ref_window_size, REF_DATA_SECTIONS)
    point_scan = {
        "rawPointCount": len(raw_points),
        "inBoundsPointCount": len(in_bounds),
        "firstRawPoints": raw_points[:8],
        "firstInBoundsPoints": in_bounds[:8],
    }
    classification = classify_payload_sample(sample, point_scan, range_text_refs)
    return {
        "fieldIndex": field_row["index"],
        "fieldVa": field_row["va"],
        "fieldVaHex": field_row["vaHex"],
        "payloadVa": ptr_va,
        "payloadVaHex": va_hex(ptr_va),
        "refWindowHex": f"{va_hex(ptr_va)}..{va_hex(ptr_va + ref_window_size)}",
        "sampleDwords": sample,
        "pointScan": point_scan,
        "exactTextRefCount": exact_text_refs["count"],
        "exactDataRefCount": exact_data_refs["count"],
        "rangeTextRefCount": range_text_refs["count"],
        "rangeDataRefCount": range_data_refs["count"],
        "exactTextRefs": exact_text_refs["samples"],
        "exactDataRefs": exact_data_refs["samples"],
        "rangeTextRefs": range_text_refs["samples"],
        "rangeDataRefs": range_data_refs["samples"],
        "classification": classification,
        "promotionEvidence": False,
        "reason": (
            "This scene-local payload has no in-bounds map1_01a coordinate table and no .text reference "
            "to the sampled payload window. It is resource/rect-like context, not transition evidence."
        ),
    }


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


def scene_evidence(event_transitions: list[dict], coordinate_candidates: list[dict], gaps: list[dict]) -> dict:
    source_events = [row for row in event_transitions if row.get("map") == SOURCE]
    target_events = [row for row in source_events if TARGET in (row.get("targets") or [])]
    return {
        "eventTransitionCount": len(target_events),
        "sourceEventRecordCount": len(source_events),
        "coordinateCandidateCount": sum(1 for row in coordinate_candidates if row.get("map") == SOURCE),
        "extractionGapCount": sum(1 for row in gaps if row.get("map") == SOURCE),
    }


def build_summary(
    exe: bytes,
    map_data: dict,
    event_transitions: list[dict],
    coordinate_candidates: list[dict],
    transition_gaps: list[dict],
) -> dict:
    sections = read_sections(exe)
    strings = find_cns_strings(exe, sections)
    source_map = map_data[SOURCE]
    map_size = {"width": source_map["width"], "height": source_map["height"]}
    window_count = (TARGET_RECORD_VA - SOURCE_RECORD_VA) // 4 + 3
    scene_window = read_dword_rows(exe, sections, strings, SOURCE_RECORD_VA, window_count, map_size)
    source_scene_id = dword_at_va(exe, sections, SOURCE_RECORD_VA + 4)
    target_scene_id = dword_at_va(exe, sections, TARGET_RECORD_VA + 4)
    payload_fields = [
        row
        for row in scene_window
        if row["va"] < TARGET_RECORD_VA and is_scene_local_payload_pointer(row, strings, sections)
    ]
    payloads = [
        payload_summary(exe, sections, strings, row, map_size)
        for row in payload_fields
    ]
    evidence = scene_evidence(event_transitions, coordinate_candidates, transition_gaps)
    conclusion = (
        "The current map1_01a selector-only scene cluster still looks like resource/scene-list payload context. "
        "The scene-local payload pointers near 0x00542b44 do not decode as in-bounds map1_01a coordinate tables "
        "and their sampled windows have no .text refs, so they do not prove map1_01a -> map2_02d as a normal tile transition."
    )
    return {
        "source": SOURCE,
        "target": TARGET,
        "sourceMapSize": map_size,
        "sourceRecordVa": SOURCE_RECORD_VA,
        "sourceRecordVaHex": va_hex(SOURCE_RECORD_VA),
        "sourceSceneId": source_scene_id,
        "sourceSceneIdHex": f"0x{source_scene_id:04x}",
        "targetRecordVa": TARGET_RECORD_VA,
        "targetRecordVaHex": va_hex(TARGET_RECORD_VA),
        "targetSceneId": target_scene_id,
        "targetSceneIdHex": f"0x{target_scene_id:04x}",
        "sceneWindow": scene_window,
        "payloads": payloads,
        "sceneEvidence": evidence,
        "strictHotspotFound": False,
        "promotionStatus": "blocked",
        "conclusion": conclusion,
    }


def markdown(summary: dict) -> str:
    lines = [
        "# map1_01a Scene Payload Context",
        "",
        f"- route: `{summary['source']} -> {summary['target']}`",
        f"- source record: `{summary['sourceRecordVaHex']}` scene `{summary['sourceSceneIdHex']}`",
        f"- target record: `{summary['targetRecordVaHex']}` scene `{summary['targetSceneIdHex']}`",
        f"- promotion status: `{summary['promotionStatus']}`",
        "",
        summary["conclusion"],
        "",
        "## Payload Pointers",
        "",
        "| field | payload | class | point scan | refs | reason |",
        "| ---: | --- | --- | --- | --- | --- |",
    ]
    for payload in summary["payloads"]:
        point_scan = payload["pointScan"]
        refs = f"text={payload['rangeTextRefCount']}, data={payload['rangeDataRefCount']}"
        points = f"raw={point_scan['rawPointCount']}, inBounds={point_scan['inBoundsPointCount']}"
        lines.append(
            f"| {payload['fieldIndex']} | `{payload['payloadVaHex']}` | {payload['classification']} | "
            f"{points} | {refs} | {payload['reason']} |"
        )
    lines.extend([
        "",
        "## Scene Window",
        "",
        "| index | VA | value | kind | label |",
        "| ---: | --- | --- | --- | --- |",
    ])
    for row in summary["sceneWindow"]:
        lines.append(
            f"| {row['index']} | `{row['vaHex']}` | `{row['valueHex']}` | {row['kind']} | {row['label']} |"
        )
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    payload_rows = []
    for payload in summary["payloads"]:
        point_scan = payload["pointScan"]
        refs = f"text={payload['rangeTextRefCount']}, data={payload['rangeDataRefCount']}"
        points = f"raw={point_scan['rawPointCount']}, inBounds={point_scan['inBoundsPointCount']}"
        payload_rows.append(
            "<tr>"
            f"<td>{payload['fieldIndex']}</td>"
            f"<td><code>{html.escape(payload['payloadVaHex'])}</code></td>"
            f"<td>{html.escape(payload['classification'])}</td>"
            f"<td>{html.escape(points)}</td>"
            f"<td>{html.escape(refs)}</td>"
            f"<td>{html.escape(payload['reason'])}</td>"
            "</tr>"
        )
    scene_rows = [
        "<tr>"
        f"<td>{row['index']}</td>"
        f"<td><code>{html.escape(row['vaHex'])}</code></td>"
        f"<td><code>{html.escape(row['valueHex'])}</code></td>"
        f"<td>{html.escape(row['kind'])}</td>"
        f"<td>{html.escape(row['label'])}</td>"
        "</tr>"
        for row in summary["sceneWindow"]
    ]
    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 Scene Payload Context</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 Scene Payload Context</h1>",
        f"  <p>route <code>{html.escape(summary['source'])} -&gt; {html.escape(summary['target'])}</code>; "
        f"source record <code>{html.escape(summary['sourceRecordVaHex'])}</code>; "
        f"target record <code>{html.escape(summary['targetRecordVaHex'])}</code>; "
        f"promotion status <code>{html.escape(summary['promotionStatus'])}</code></p>",
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        "  <h2>Payload Pointers</h2>",
        "  <table><thead><tr><th>field</th><th>payload</th><th>class</th><th>point scan</th><th>refs</th><th>reason</th></tr></thead><tbody>",
        *payload_rows,
        "  </tbody></table>",
        "  <h2>Scene Window</h2>",
        "  <table><thead><tr><th>index</th><th>VA</th><th>value</th><th>kind</th><th>label</th></tr></thead><tbody>",
        *scene_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_scene_payload_context.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("--event-transitions", type=Path, default=OUT / "event_transitions.json")
    parser.add_argument("--coordinate-candidates", type=Path, default=OUT / "scene_coordinate_candidates.json")
    parser.add_argument("--transition-gaps", type=Path, default=OUT / "transition_extraction_gaps.json")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(
        args.exe.read_bytes(),
        load_map_data_from_js(args.maps),
        json.loads(args.event_transitions.read_text(encoding="utf-8")),
        json.loads(args.coordinate_candidates.read_text(encoding="utf-8")),
        json.loads(args.transition_gaps.read_text(encoding="utf-8")),
    )
    write_outputs(summary, args.out_dir)
    print(f"wrote map1_01a scene payload context -> {args.out_dir / 'map1_01a_scene_payload_context.json'}")


if __name__ == "__main__":
    main()
