#!/usr/bin/env python3
"""Probe whether map_*3 source rects imply destination placement.

This deliberately stays conservative:
- source rects are already known from out/map_extra_rects.json
- destination placement is accepted only if there is a concrete coordinate table
  or a low-noise rule that maps source rects to field-map cells
"""
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 probe_exe_scene_tables import find_cns_strings, offset_to_va, read_sections, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
TILE_COLUMNS = 40


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


def load_maps(path: Path) -> dict[str, dict]:
    text = path.read_text(encoding="utf-8")
    return json.loads(text.split("=", 1)[1].strip().rstrip(";"))


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


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


def rect_tile_values(rect: dict[str, Any]) -> set[int]:
    x1, y1, x2, y2 = rect["tileBox"]
    return {
        y * TILE_COLUMNS + x
        for y in range(y1, y2 + 1)
        for x in range(x1, x2 + 1)
    }


def component_count(points: list[tuple[int, int]]) -> int:
    remaining = set(points)
    count = 0
    while remaining:
        count += 1
        stack = [remaining.pop()]
        while stack:
            x, y = stack.pop()
            for nxt in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
                if nxt in remaining:
                    remaining.remove(nxt)
                    stack.append(nxt)
    return count


def map_tile_overlap(maps: dict[str, dict], map_name: str, rect: dict[str, Any]) -> dict[str, Any]:
    map_row = maps.get(map_name)
    if not map_row:
        return {"map": map_name, "cellHitCount": 0, "componentCount": 0, "sample": []}
    values = rect_tile_values(rect)
    width = int(map_row["width"])
    layers = map_row.get("layers") or []
    hits: list[tuple[int, int, int, int]] = []
    for layer_index, layer in enumerate(layers[:2]):
        for index, value in enumerate(layer):
            if value in values:
                hits.append((layer_index, index % width, index // width, value))
    points = [(x, y) for _layer, x, y, _value in hits]
    return {
        "map": map_name,
        "cellHitCount": len(hits),
        "componentCount": component_count(points) if points else 0,
        "sample": [
            {"layer": layer, "x": x, "y": y, "tile": value}
            for layer, x, y, value in hits[:12]
        ],
    }


def find_asset_refs(data: bytes, sections: list[dict], strings: dict[int, str], filename: str) -> list[dict[str, Any]]:
    string_vas = [va for va, name in strings.items() if name == filename]
    refs = []
    for string_va in string_vas:
        needle = struct.pack("<I", string_va)
        search = 0
        while True:
            hit = data.find(needle, search)
            if hit < 0:
                break
            search = hit + 1
            ref_va = offset_to_va(sections, hit)
            if ref_va is None:
                continue
            refs.append({"refVa": ref_va, "fileOffset": hit, "stringVa": string_va})
    return refs


def nearby_pair_hits(
    data: bytes,
    sections: list[dict],
    strings: dict[int, str],
    ref: dict[str, Any],
    map_row: dict[str, Any] | None,
    window: int = 0x360,
) -> dict[str, Any]:
    """Find low-level coordinate-shaped values near a map_*3 ref.

    This is intentionally reported as weak evidence. Isolated small constants
    are common in script bytecode and are not enough to promote placement.
    """
    if not map_row:
        return {"packedPairCount": 0, "isolatedSmallConstantCount": 0, "sample": []}
    width = int(map_row.get("width") or 0)
    height = int(map_row.get("height") or 0)
    start = max(0, int(ref["fileOffset"]) - window)
    end = min(len(data) - 4, int(ref["fileOffset"]) + window)
    packed = []
    isolated = 0
    for offset in range(start, end, 4):
        value = dword_at(data, offset)
        if value is None or value in strings:
            continue
        lo = value & 0xFFFF
        hi = value >> 16
        if 0 <= lo < width and 0 <= hi < height and value != 0:
            packed.append({"vaHex": hex32(offset_to_va(sections, offset)), "x": lo, "y": hi, "valueHex": hex32(value)})
        elif value < max(width, height):
            isolated += 1
    return {
        "packedPairCount": len(packed),
        "isolatedSmallConstantCount": isolated,
        "sample": packed[:10],
    }


def records_for_asset(manifest: list[dict], asset: str) -> list[dict[str, Any]]:
    return [
        row
        for row in manifest
        if asset in (row.get("tilesets") or [])
    ]


def coordinate_records_for_maps(coordinate_candidates: list[dict], map_names: set[str]) -> list[dict[str, Any]]:
    return [row for row in coordinate_candidates if row.get("map") in map_names]


def build_report(exe_path: Path) -> dict[str, Any]:
    data = exe_path.read_bytes()
    sections = read_sections(data)
    strings = find_cns_strings(data, sections)
    maps = load_maps(OUT / "maps.js")
    map_extra = load_json(OUT / "map_extra_rects.json", {}).get("byAsset", {})
    manifest = load_json(OUT / "scene_manifest.json", [])
    coord_candidates = load_json(OUT / "scene_coordinate_candidates.json", [])

    assets = []
    for asset, rect_info in sorted(map_extra.items()):
        if not re.fullmatch(r"map_[a-z]3", asset):
            continue
        scene_records = records_for_asset(manifest, asset)
        map_names = {row["map"] for row in scene_records}
        coord_rows = coordinate_records_for_maps(coord_candidates, map_names)
        refs = find_asset_refs(data, sections, strings, f"{asset}.cns")
        ref_probe_samples = []
        for ref in refs[:12]:
            owner = None
            for row in scene_records:
                if abs(int(row["recordVa"]) - int(ref["refVa"])) < 0x500:
                    owner = row
                    break
            map_row = maps.get(owner["map"]) if owner else None
            pair_probe = nearby_pair_hits(data, sections, strings, ref, map_row)
            if pair_probe["packedPairCount"] or pair_probe["isolatedSmallConstantCount"]:
                ref_probe_samples.append({
                    "refVaHex": hex32(ref["refVa"]),
                    "ownerMap": owner.get("map") if owner else None,
                    **pair_probe,
                })
        overlap_rows = []
        sample_maps = sorted(map_names)[:4]
        for rect in rect_info.get("rects") or []:
            per_map = [map_tile_overlap(maps, map_name, rect) for map_name in sample_maps]
            overlap_rows.append({
                "label": rect.get("label"),
                "tileRange": rect.get("tileRange"),
                "tileBox": rect.get("tileBox"),
                "mapCellHitCounts": [
                    {"map": row["map"], "cells": row["cellHitCount"], "components": row["componentCount"]}
                    for row in per_map
                ],
            })

        coord_candidate_count = sum(len(row.get("candidates") or []) for row in coord_rows)
        low_noise_overlap = [
            item
            for item in overlap_rows
            for row in item["mapCellHitCounts"]
            if 0 < row["cells"] <= 16 and row["components"] <= 3
        ]
        assets.append({
            "asset": asset,
            "sourceRectCount": rect_info.get("uniqueRectCount", 0),
            "rawRectCount": rect_info.get("rawUniqueRectCount", 0),
            "sceneRecordCount": len(scene_records),
            "sceneMaps": sorted(map_names),
            "coordinateCandidateRecordCount": len(coord_rows),
            "coordinateCandidateCount": coord_candidate_count,
            "resourceRefCount": len(refs),
            "nearbyPackedPairSampleCount": sum(row["packedPairCount"] for row in ref_probe_samples),
            "nearbyIsolatedSmallConstantSampleCount": sum(row["isolatedSmallConstantCount"] for row in ref_probe_samples),
            "lowNoiseTileIndexOverlapCount": len(low_noise_overlap),
            "tileIndexOverlap": overlap_rows,
            "nearbyPairProbeSamples": ref_probe_samples[:6],
        })

    return {
        "status": "placement-not-promoted",
        "scope": "map_*3 destination placement inference",
        "source": [str(exe_path), "out/map_extra_rects.json", "out/scene_manifest.json", "out/scene_coordinate_candidates.json"],
        "assetCount": len(assets),
        "assetsWithSceneRecords": sum(1 for row in assets if row["sceneRecordCount"]),
        "assetsWithCoordinateCandidates": sum(1 for row in assets if row["coordinateCandidateCount"]),
        "assetsWithLowNoiseTileIndexOverlap": sum(1 for row in assets if row["lowNoiseTileIndexOverlapCount"]),
        "conclusion": (
            "The EXE scene/resource records identify which maps load each map_*3 source tileset, "
            "but the current scans do not expose a stable destination placement table. "
            "Map tile-index overlap is usually too dense to infer placement by itself. "
            "Manual destination labels for several assets would help test whether a hidden table "
            "uses tile coordinates, pixel coordinates, or script operands."
        ),
        "assets": assets,
    }


def html_page(report: dict[str, Any]) -> str:
    rows = []
    for row in report["assets"]:
        maps = ", ".join(row["sceneMaps"][:10])
        if len(row["sceneMaps"]) > 10:
            maps += f" (+{len(row['sceneMaps']) - 10})"
        rows.append(
            "<tr>"
            f"<td><code>{html.escape(row['asset'])}</code></td>"
            f"<td>{row['sourceRectCount']}</td>"
            f"<td>{html.escape(maps or '-')}</td>"
            f"<td>{row['coordinateCandidateCount']}</td>"
            f"<td>{row['nearbyPackedPairSampleCount']}</td>"
            f"<td>{row['lowNoiseTileIndexOverlapCount']}</td>"
            "</tr>"
        )
    return "\n".join([
        "<!doctype html>",
        '<html lang="ko">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>map_*3 placement inference</title>",
        "  <style>body{font:14px system-ui,sans-serif;margin:24px;background:#f6f7f9;color:#17202a}table{border-collapse:collapse;width:100%;background:white}th,td{border:1px solid #d8dee6;padding:7px 9px;vertical-align:top}th{background:#eef2f6}code{background:#f8fafc;border:1px solid #d5dce5;border-radius:4px;padding:1px 4px}</style>",
        "</head>",
        "<body>",
        "  <h1>map_*3 placement inference</h1>",
        f"  <p>Status: <code>{html.escape(report['status'])}</code>. {html.escape(report['conclusion'])}</p>",
        "  <table><thead><tr><th>asset</th><th>rects</th><th>scene maps</th><th>coord candidates</th><th>nearby packed pairs</th><th>low-noise tile-index overlap</th></tr></thead>",
        f"  <tbody>{''.join(rows)}</tbody></table>",
        "</body>",
        "</html>",
        "",
    ])


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    args = parser.parse_args()
    report = build_report(args.exe)
    (OUT / "map3_placement_inference_scan.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(f"wrote {OUT / 'map3_placement_inference_scan.json'}")


if __name__ == "__main__":
    main()
