#!/usr/bin/env python3
"""Scan the current save-selector root for hidden map1_01a point-table candidates."""
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 probe_exe_scene_tables import read_sections, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
MAP = "map1_01a"
TARGET = "map2_02d"
SELECTOR_GROUP = 2
SELECTOR_SLOT = 0
MAX_POINT_COUNT = 512
REPORT_LIMIT = 80
SCRIPTISH_LOW_WORDS = {
    0x00,
    0x01,
    0x02,
    0x03,
    0x08,
    0x0A,
    0x0B,
    0x0C,
    0x0D,
    0x10,
    0x11,
    0x12,
    0x13,
    0x14,
    0x16,
    0x20,
    0x21,
    0x24,
    0x2B,
    0x2C,
    0x2F,
    0x30,
    0x38,
    0x43,
    0x48,
    0x4B,
    0x5A,
    0x6D,
    0xFF,
}


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


def parse_hex_range(value: str | None) -> tuple[int, int] | None:
    if not isinstance(value, str) or ".." not in value:
        return None
    start, end = value.split("..", 1)
    return int(start, 16), int(end, 16)


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(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 read_point_run(exe: bytes, sections: list[dict], ptr_va: int, width: int, height: int) -> dict:
    offset = va_to_offset(sections, ptr_va)
    if offset is None:
        return {"rawPoints": [], "inBoundsPoints": [], "firstValues": []}
    raw_points = []
    in_bounds = []
    first_values = []
    for index in range(MAX_POINT_COUNT):
        item_offset = offset + index * 4
        if item_offset + 4 > len(exe):
            break
        value = struct.unpack_from("<I", exe, item_offset)[0]
        if index < 12:
            first_values.append(hex32(value))
        if value == 0:
            break
        x = value & 0xFFFF
        y = value >> 16
        if x >= 512 or y >= 512:
            break
        point = {"x": x, "y": y}
        raw_points.append(point)
        if x < width and y < height:
            in_bounds.append(point)
    return {
        "rawPoints": raw_points,
        "inBoundsPoints": in_bounds,
        "firstValues": first_values,
    }


def scan_refs_to_value(
    exe: bytes,
    sections: list[dict],
    value: int,
    section_names: set[str],
    sample_limit: int = 6,
) -> dict:
    needle = struct.pack("<I", value)
    count = 0
    samples = []
    search = 0
    while True:
        hit = exe.find(needle, search)
        if hit < 0:
            break
        search = hit + 1
        section = next(
            (
                item
                for item in sections
                if item["raw"] <= hit < item["raw"] + item["raw_size"]
                and item["name"] in section_names
            ),
            None,
        )
        if section is None:
            continue
        ref_va = section["va"] + hit - section["raw"]
        count += 1
        if len(samples) < sample_limit:
            samples.append({"section": section["name"], "refVaHex": hex32(ref_va)})
    return {"count": count, "samples": samples}


def selector_root_range(save_scene_selectors: list[dict]) -> dict:
    row = next(
        (
            item
            for item in save_scene_selectors
            if item.get("group") == SELECTOR_GROUP and item.get("slot") == SELECTOR_SLOT
        ),
        None,
    )
    if not row or not isinstance(row.get("selectedPointer"), int):
        raise ValueError("could not find save selector 2:0 root")
    root = row["selectedPointer"]
    all_roots = sorted(
        {
            item["selectedPointer"]
            for item in save_scene_selectors
            if isinstance(item.get("selectedPointer"), int)
        }
    )
    end = next((value for value in all_roots if value > root), None)
    if end is None:
        raise ValueError("could not compute save selector 2:0 root end")
    return {
        "selector": f"{SELECTOR_GROUP}:{SELECTOR_SLOT}",
        "rootVa": root,
        "rootVaHex": hex32(root),
        "rootEndVa": end,
        "rootRangeHex": f"{hex32(root)}..{hex32(end)}",
        "fieldMaps": row.get("fieldMaps") or [],
    }


def exit_samples(map_exit_candidates: list[dict]) -> list[dict]:
    rows = []
    for map_row in map_exit_candidates:
        if map_row.get("map") != MAP:
            continue
        if TARGET not in (map_row.get("selectorTargetCandidates") or []):
            continue
        for candidate in map_row.get("exitCandidates") or []:
            sample = candidate.get("sample") or {}
            x = sample.get("x")
            y = sample.get("y")
            if not isinstance(x, int) or not isinstance(y, int):
                continue
            rows.append({
                "side": candidate.get("side"),
                "x": x,
                "y": y,
                "componentIndex": candidate.get("componentIndex"),
            })
    unique = {}
    for row in rows:
        unique[(row["side"], row["x"], row["y"])] = row
    return list(unique.values())


def source_record_role(field_va: int, frontier_paths: dict) -> str:
    cluster = parse_hex_range(frontier_paths.get("frontierClusterRangeHex"))
    if cluster and cluster[0] <= field_va <= cluster[1]:
        return "current-frontier-cluster"
    return "current-root-outside-frontier-cluster"


def classify_candidate(candidate: dict) -> str:
    if candidate["exactExitHitCount"]:
        return "exact-exit-coordinate-hit"
    if candidate["scriptishPointRatio"] >= 0.5:
        return "script-like-point-run"
    if candidate["edgePointCount"]:
        return "edge-like-point-run"
    return "point-like-pointer"


def point_text(points: list[dict], limit: int = 8) -> str:
    return ", ".join(f"{point['x']},{point['y']}" for point in points[:limit]) or "-"


def build_summary(
    exe: bytes,
    map_data: dict,
    save_scene_selectors: list[dict],
    map_exit_candidates: list[dict],
    current_root_frontier_paths: dict,
) -> dict:
    sections = read_sections(exe)
    source_map = map_data[MAP]
    width = source_map["width"]
    height = source_map["height"]
    root = selector_root_range(save_scene_selectors)
    samples = exit_samples(map_exit_candidates)
    sample_points = {(row["x"], row["y"]) for row in samples}

    pointer_like_count = 0
    candidates = []
    for field_va in range(root["rootVa"], root["rootEndVa"], 4):
        value = dword_at_va(exe, sections, field_va)
        if value is None or not (0x00400000 <= value <= 0x00600000):
            continue
        run = read_point_run(exe, sections, value, width, height)
        raw_points = run["rawPoints"]
        if not raw_points:
            continue
        pointer_like_count += 1
        in_bounds = run["inBoundsPoints"]
        exact_hits = [
            {"x": point["x"], "y": point["y"]}
            for point in raw_points
            if (point["x"], point["y"]) in sample_points
        ]
        edge_points = [
            point
            for point in raw_points
            if point["x"] <= 2 or point["x"] >= width - 3 or point["y"] <= 2 or point["y"] >= height - 3
        ]
        scriptish = [
            point
            for point in raw_points
            if (point["x"] & 0xFF) in SCRIPTISH_LOW_WORDS or (point["y"] & 0xFF) in SCRIPTISH_LOW_WORDS
        ]
        if len(in_bounds) < 2 and not exact_hits and not edge_points:
            continue
        text_refs = scan_refs_to_value(exe, sections, value, {".text"})
        data_refs = scan_refs_to_value(exe, sections, value, {".data", ".rdata"})
        candidate = {
            "fieldVa": field_va,
            "fieldVaHex": hex32(field_va),
            "payloadVa": value,
            "payloadVaHex": hex32(value),
            "sourceRole": source_record_role(field_va, current_root_frontier_paths),
            "rawPointCount": len(raw_points),
            "inBoundsPointCount": len(in_bounds),
            "edgePointCount": len(edge_points),
            "exactExitHitCount": len(exact_hits),
            "exactExitHits": exact_hits,
            "scriptishPointCount": len(scriptish),
            "scriptishPointRatio": round(len(scriptish) / len(raw_points), 3),
            "firstRawPoints": raw_points[:8],
            "firstInBoundsPoints": in_bounds[:8],
            "firstValues": run["firstValues"],
            "textRefCount": text_refs["count"],
            "dataRefCount": data_refs["count"],
            "textRefs": text_refs["samples"],
            "dataRefs": data_refs["samples"],
        }
        candidate["classification"] = classify_candidate(candidate)
        candidates.append(candidate)

    candidates.sort(
        key=lambda row: (
            -row["exactExitHitCount"],
            -row["edgePointCount"],
            -row["inBoundsPointCount"],
            row["scriptishPointRatio"],
            row["fieldVa"],
        )
    )
    exact_exit_count = sum(1 for row in candidates if row["exactExitHitCount"])
    frontier_cluster_count = sum(1 for row in candidates if row["sourceRole"] == "current-frontier-cluster")
    script_like_count = sum(1 for row in candidates if row["classification"] == "script-like-point-run")
    conclusion = (
        "The full current selector-root pointer scan finds many point-shaped candidates, but none contains "
        "the geometry-only exit samples for map1_01a -> map2_02d. The highest-scoring rows are script-like "
        "runs whose first values decode as VM command words such as 0x00010021, not strict map event point "
        "tables. This closes the hidden current-root point-table path as promotion evidence for now."
    )
    return {
        "source": MAP,
        "target": TARGET,
        "sourceMapSize": {"width": width, "height": height},
        "selector": root["selector"],
        "rootVaHex": root["rootVaHex"],
        "rootRangeHex": root["rootRangeHex"],
        "fieldMaps": root["fieldMaps"],
        "frontierClusterRangeHex": current_root_frontier_paths.get("frontierClusterRangeHex"),
        "exitSamples": samples,
        "pointerLikeCount": pointer_like_count,
        "reportedCandidateCount": len(candidates),
        "frontierClusterCandidateCount": frontier_cluster_count,
        "exactExitCandidateCount": exact_exit_count,
        "scriptLikeCandidateCount": script_like_count,
        "strictSourceHotspotFound": False,
        "promotionStatus": "blocked",
        "conclusion": conclusion,
        "candidates": candidates,
    }


def html_page(summary: dict) -> str:
    sample_rows = [
        "<tr>"
        f"<td>{html.escape(str(row.get('side') or '-'))}</td>"
        f"<td>{row['x']},{row['y']}</td>"
        f"<td>{html.escape(str(row.get('componentIndex')))}</td>"
        "</tr>"
        for row in summary["exitSamples"]
    ]
    candidate_rows = []
    for candidate in summary["candidates"][:REPORT_LIMIT]:
        candidate_rows.append(
            "<tr>"
            f"<td><code>{html.escape(candidate['fieldVaHex'])}</code></td>"
            f"<td><code>{html.escape(candidate['payloadVaHex'])}</code></td>"
            f"<td>{html.escape(candidate['classification'])}</td>"
            f"<td>{html.escape(candidate['sourceRole'])}</td>"
            f"<td>{candidate['rawPointCount']}/{candidate['inBoundsPointCount']}</td>"
            f"<td>{candidate['edgePointCount']}</td>"
            f"<td>{candidate['exactExitHitCount']}</td>"
            f"<td>{candidate['scriptishPointRatio']}</td>"
            f"<td>{html.escape(point_text(candidate['firstRawPoints']))}</td>"
            f"<td>{html.escape(', '.join(candidate['firstValues'][:6]) or '-')}</td>"
            f"<td>text={candidate['textRefCount']} data={candidate['dataRefCount']}</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 Root 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: 1120px; 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 Root Point Scan</h1>",
        f"  <p>route <code>{summary['source']} -&gt; {summary['target']}</code>; "
        f"selector <code>{summary['selector']}</code> root <code>{summary['rootRangeHex']}</code>; "
        f"exact exit candidates: {summary['exactExitCandidateCount']}; "
        f"promotion <code>{summary['promotionStatus']}</code></p>",
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        "  <h2>Exit Samples</h2>",
        "  <table><thead><tr><th>side</th><th>tile</th><th>component</th></tr></thead><tbody>",
        *sample_rows,
        "  </tbody></table>",
        "  <h2>Top Candidates</h2>",
        "  <table><thead><tr><th>field</th><th>payload</th><th>class</th><th>role</th><th>raw/in-bounds</th><th>edge</th><th>exact exit</th><th>scriptish</th><th>first points</th><th>first values</th><th>refs</th></tr></thead><tbody>",
        *candidate_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_root_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("--save-scene-selectors", type=Path, default=OUT / "save_scene_selectors.json")
    parser.add_argument("--map-exit-candidates", type=Path, default=OUT / "map_exit_candidates.json")
    parser.add_argument("--current-root-frontier-paths", type=Path, default=OUT / "save_selector_current_root_frontier_paths.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.save_scene_selectors.read_text(encoding="utf-8")),
        json.loads(args.map_exit_candidates.read_text(encoding="utf-8")),
        json.loads(args.current_root_frontier_paths.read_text(encoding="utf-8")),
    )
    write_outputs(summary, args.out_dir)
    print(f"wrote map1_01a root point scan -> {args.out_dir / 'map1_01a_root_point_scan.json'}")


if __name__ == "__main__":
    main()
