#!/usr/bin/env python3
"""Build map_*3 destination placement labels and EXE initializer grounding.

The browser still consumes the historical ``map3_manual_placements`` file name
for compatibility. Rows now include active-object initializer evidence when
``field0x28`` encodes surface slot ``0x000c`` plus an EXE source-rect table
index. The table index is not always the browser-visible R-label index because
multiple EXE table entries can point at the same visible rectangle.
"""
from __future__ import annotations

import html
import json
import struct
import sys
from collections import Counter, defaultdict
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_SIZE = 16
SEARCH_WINDOW = 0x600


MANUAL_PLACEMENT_LABELS: list[dict[str, Any]] = [
    {
        "map": "map1_02b",
        "asset": "map_b3",
        "rectLabel": "R01",
        "dests": [(15, 8)],
        "note": "door",
        "source": "user-confirmed-prior",
    },
    {
        "map": "map2_02d",
        "asset": "map_d3",
        "rectLabel": "R01",
        "dests": [(8, 7), (20, 7), (36, 7)],
        "note": "door",
        "source": "user-corrected-prior",
    },
    {
        "map": "map2_02d",
        "asset": "map_d3",
        "rectLabel": "R02",
        "dests": [(71, 6)],
        "note": "door",
        "source": "user-corrected-prior",
    },
    {
        "map": "map2_18d",
        "asset": "map_d3",
        "rectLabel": "R01",
        "dests": [(8, 7), (20, 7), (36, 7)],
        "note": "door",
        "source": "user-confirmed-same-as-map2_02d",
    },
    {
        "map": "map2_18d",
        "asset": "map_d3",
        "rectLabel": "R02",
        "dests": [(71, 6)],
        "note": "door",
        "source": "user-confirmed-same-as-map2_02d",
    },
    {
        "map": "map2_09g",
        "asset": "map_g3",
        "rectLabel": "R02",
        "dests": [(23, 7)],
        "note": "door",
        "source": "user-confirmed-current",
    },
    {
        "map": "map2_10g",
        "asset": "map_g3",
        "rectLabel": "R01",
        "dests": [(8, 4), (18, 4), (28, 4), (38, 4)],
        "note": "door",
        "source": "user-confirmed-current",
    },
    {
        "map": "map2_14j",
        "asset": "map_j3",
        "rectLabel": "R01",
        "dests": [(20, 10), (24, 10)],
        "note": "door",
        "source": "user-confirmed-current",
    },
    {
        "map": "map3_02f",
        "asset": "map_f3",
        "rectLabel": "R01",
        "dests": [(46, 20), (62, 20), (82, 20), (98, 20), (46, 41), (62, 41), (82, 41), (98, 41)],
        "note": "door",
        "source": "user-confirmed-current",
    },
    {
        "map": "map3_02f",
        "asset": "map_f3",
        "rectLabel": "R02",
        "dests": [(19, 20)],
        "note": "door",
        "source": "user-confirmed-current",
    },
]


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


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


def load_active_object_inventory() -> list[dict[str, Any]]:
    path = OUT / "active_object_script_inventory.json"
    if not path.exists():
        return []
    data = load_json(path)
    if isinstance(data, dict):
        rows = data.get("inventory", [])
        return rows if isinstance(rows, list) else []
    return data if isinstance(data, list) else []


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


def find_rect(extra: dict[str, Any], asset: str, label: str) -> dict[str, Any]:
    for rect in extra["byAsset"][asset]["rects"]:
        if rect.get("label") == label:
            return rect
    raise KeyError(f"missing {asset} {label}")


def layer_value(map_row: dict[str, Any], layer_index: int, x: int, y: int) -> int | None:
    if x < 0 or y < 0 or x >= map_row["width"] or y >= map_row["height"]:
        return None
    layers = map_row.get("layers") or []
    if layer_index >= len(layers):
        return None
    return layers[layer_index][y * map_row["width"] + x]


def rect_table_vas(rect: dict[str, Any]) -> list[str]:
    return sorted({
        str(ref.get("tableStartVaHex"))
        for ref in rect.get("tableRefs") or []
        if ref.get("tableStartVaHex")
    })


def rect_table_indices(rect: dict[str, Any], fallback_label: str) -> list[int]:
    indices = sorted({
        int(ref["rectIndex"])
        for ref in rect.get("tableRefs") or []
        if isinstance(ref, dict) and isinstance(ref.get("rectIndex"), int)
    })
    if indices:
        return indices
    if fallback_label.startswith("R"):
        try:
            return [int(fallback_label[1:]) - 1]
        except ValueError:
            return []
    return []


def match_active_initializer(
    inventory: list[dict[str, Any]],
    rect_label: str,
    x: int,
    y: int,
    rect: dict[str, Any],
) -> dict[str, Any]:
    rect_indices = rect_table_indices(rect, rect_label)
    expected_fields = {f"0x000c{index:04x}" for index in rect_indices}
    expected_w = int(rect["w"]) // TILE_SIZE
    expected_h = int(rect["h"]) // TILE_SIZE
    matches = []
    for row in inventory:
        if row.get("xCandidate") != x or row.get("yCandidate") != y:
            continue
        if row.get("wCandidate") != expected_w or row.get("hCandidate") != expected_h:
            continue
        if expected_fields and row.get("field0x28Hex") not in expected_fields:
            continue
        matches.append(row)
    return {
        "expectedField0x28Hex": sorted(expected_fields)[0] if len(expected_fields) == 1 else "",
        "candidateField0x28Hexes": sorted(expected_fields),
        "candidateRectTableIndices": rect_indices,
        "exePlacementStatus": "grounded" if matches else "review-only",
        "exeInitializerMatchCount": len(matches),
        "exeInitializerVas": [row.get("initializerVaHex") for row in matches[:12]],
        "exeScriptVas": [row.get("scriptVaHex") for row in matches[:12]],
        "exeInitializerField0x16Values": sorted({str(row.get("field0x16Hex") or "") for row in matches if row.get("field0x16Hex")}),
        "exeInitializerClasses": sorted({str(row.get("scriptClassification") or "") for row in matches if row.get("scriptClassification")}),
    }


def row_runs(points: list[tuple[int, int]]) -> list[dict[str, Any]]:
    by_y: dict[int, list[int]] = defaultdict(list)
    for x, y in points:
        by_y[y].append(x)
    runs = []
    for y, xs in sorted(by_y.items()):
        ordered = sorted(xs)
        steps = [ordered[index + 1] - ordered[index] for index in range(len(ordered) - 1)]
        runs.append({"y": y, "x": ordered, "steps": steps})
    return runs


def scene_resource_refs(manifest: list[dict[str, Any]], map_name: str, asset: str) -> list[int]:
    refs = []
    for row in manifest:
        if row.get("map") != map_name:
            continue
        for resource in row.get("resources") or []:
            if resource.get("name") == asset and isinstance(resource.get("refVa"), int):
                refs.append(resource["refVa"])
    return refs


def coordinate_probe_for_label(
    data: bytes,
    sections: list[dict[str, Any]],
    ref_vas: list[int],
    dests: list[tuple[int, int]],
) -> dict[str, Any]:
    patterns = []
    for x, y in dests:
        px = x * TILE_SIZE
        py = y * TILE_SIZE
        patterns.extend(
            [
                ("tile-u16xy", x | (y << 16)),
                ("tile-u16yx", y | (x << 16)),
                ("pixel-u16xy", px | (py << 16)),
                ("pixel-u16yx", py | (px << 16)),
            ]
        )
    pattern_values = [(kind, value, struct.pack("<I", value)) for kind, value in patterns]
    hits = []
    for ref_va in ref_vas:
        ref_offset = va_to_offset(sections, ref_va)
        if ref_offset is None:
            continue
        start = max(0, ref_offset - SEARCH_WINDOW)
        end = min(len(data), ref_offset + SEARCH_WINDOW)
        window = data[start:end]
        for kind, value, needle in pattern_values:
            search = 0
            while True:
                hit = window.find(needle, search)
                if hit < 0:
                    break
                absolute = start + hit
                hits.append({
                    "kind": kind,
                    "valueHex": hex32(value),
                    "refVaHex": hex32(ref_va),
                    "hitVaHex": hex32(offset_to_va(sections, absolute)),
                    "delta": absolute - ref_offset,
                })
                search = hit + 1
    return {
        "exactCoordinatePatternHitCount": len(hits),
        "exactCoordinatePatternKinds": sorted(Counter(hit["kind"] for hit in hits)),
        "sampleHits": hits[:8],
    }


def build() -> dict[str, Any]:
    maps = load_maps()
    extra = load_json(OUT / "map_extra_rects.json")
    manifest = load_json(OUT / "scene_manifest.json")
    inventory = load_active_object_inventory()
    exe_path = ROOT / "Hwanse2.exe"
    data = exe_path.read_bytes()
    sections = read_sections(data)
    # Keep this call as a sanity check that we are scanning EXE-shaped data.
    find_cns_strings(data, sections)

    grouped: dict[str, dict[str, list[dict[str, Any]]]] = defaultdict(lambda: defaultdict(list))
    flat_rows = []
    analysis_rows = []

    for label in MANUAL_PLACEMENT_LABELS:
        map_name = label["map"]
        asset = label["asset"]
        rect_label = label["rectLabel"]
        rect = find_rect(extra, asset, rect_label)
        table_vas = rect_table_vas(rect)
        map_row = maps[map_name]
        rect_pixels = [rect["x"], rect["y"], rect["w"], rect["h"]]
        dests = [(int(x), int(y)) for x, y in label["dests"]]
        ref_vas = scene_resource_refs(manifest, map_name, asset)
        coord_probe = coordinate_probe_for_label(data, sections, ref_vas, dests)
        layer0_counter = Counter()
        layer1_counter = Counter()

        for index, (x, y) in enumerate(dests, start=1):
            layer0 = layer_value(map_row, 0, x, y)
            layer1 = layer_value(map_row, 1, x, y)
            layer0_counter[layer0] += 1
            layer1_counter[layer1] += 1
            initializer_match = match_active_initializer(inventory, rect_label, x, y, rect)
            matched_fields = sorted({
                str(row.get("field0x28Hex"))
                for row in inventory
                if row.get("xCandidate") == x
                and row.get("yCandidate") == y
                and row.get("wCandidate") == int(rect["w"]) // TILE_SIZE
                and row.get("hCandidate") == int(rect["h"]) // TILE_SIZE
                and row.get("field0x28Hex") in set(initializer_match.get("candidateField0x28Hexes") or [])
            })
            item = {
                "label": f"{label['note']} {rect_label}-{index:02d}",
                "rectLabel": rect_label,
                "rect": rect_pixels,
                "rectTileRange": rect.get("tileRange"),
                "rectTileBox": rect.get("tileBox"),
                "placement": [x * TILE_SIZE, y * TILE_SIZE],
                "placementTile": [x, y],
                "placementLayer0Tile": layer0,
                "placementLayer1Flag": layer1,
                "placementStatus": "confirmed",
                "placementSource": label["source"],
                "placementEvidence": label["source"],
                **initializer_match,
                "sourceRectTableVa": " / ".join(table_vas),
                "evidence": (
                    f"{asset}.cns {rect_label} source rect from EXE ref+4 table; "
                    f"destination tile {x},{y} "
                    + (
                        f"matches active object initializer {', '.join(matched_fields or initializer_match.get('candidateField0x28Hexes') or [])}"
                        if initializer_match["exePlacementStatus"] == "grounded"
                        else "is user review-only; active object initializer match not found yet"
                    )
                ),
            }
            grouped[map_name][asset].append(item)
            flat_rows.append({"map": map_name, "asset": asset, **item})

        analysis_rows.append({
            "map": map_name,
            "asset": asset,
            "rectLabel": rect_label,
            "destCount": len(dests),
            "dests": [[x, y] for x, y in dests],
            "rowRuns": row_runs(dests),
            "layer0TileCounts": {str(k): v for k, v in sorted(layer0_counter.items(), key=lambda item: (str(item[0]), item[1]))},
            "layer1FlagCounts": {str(k): v for k, v in sorted(layer1_counter.items(), key=lambda item: (str(item[0]), item[1]))},
            "resourceRefVas": [hex32(ref) for ref in ref_vas],
            **coord_probe,
        })

    by_map = {map_name: dict(assets) for map_name, assets in sorted(grouped.items())}
    exe_grounded_count = sum(1 for row in flat_rows if row.get("exePlacementStatus") == "grounded")
    review_only_count = len(flat_rows) - exe_grounded_count
    stats = {
        "status": "map-extra-placement-labels-with-exe-initializer-grounding",
        "placementCount": len(flat_rows),
        "mapCount": len(by_map),
        "assetCount": len({row["asset"] for row in flat_rows}),
        "confirmedCount": len(flat_rows),
        "inferredCount": 0,
        "exeGroundedCount": exe_grounded_count,
        "reviewOnlyCount": review_only_count,
        "field0x28Interpretation": "high word 0x000c = map_*3 surface slot, low word = EXE source-rect table index (not browser R-label index)",
        "note": "Destination placements are visual review labels; rows with exePlacementStatus=grounded are matched to active object initializer bounds/hotspot candidates, not full draw-placement proof.",
    }
    return {
        "schema": "map -> asset -> source rect with user-confirmed destination tile",
        "tileSize": TILE_SIZE,
        "stats": stats,
        "placements": by_map,
        "flatRows": flat_rows,
        "analysisRows": analysis_rows,
    }


def write_markdown(report: dict[str, Any]) -> str:
    stats = report["stats"]
    lines = [
        "# map_*3 Object Placements",
        "",
        f"- status: `{stats['status']}`",
        f"- placements: `{stats['placementCount']}`",
        f"- maps: `{stats['mapCount']}`",
        f"- assets: `{stats['assetCount']}`",
        f"- initializer-matched labels: `{stats['exeGroundedCount']}`",
        f"- review-only placements: `{stats['reviewOnlyCount']}`",
        "",
        stats["note"],
        "",
        stats["field0x28Interpretation"],
        "",
        "| map | asset | rect | dests | row pattern | layer0 tiles | layer1 flags | EXE init matches | EXE coord hits |",
        "| --- | --- | --- | --- | --- | --- | --- | ---: | ---: |",
    ]
    for row in report["analysisRows"]:
        dests = ", ".join(f"{x},{y}" for x, y in row["dests"])
        runs = "; ".join(
            f"y={run['y']} x={','.join(map(str, run['x']))} steps={','.join(map(str, run['steps'])) or '-'}"
            for run in row["rowRuns"]
        )
        lines.append(
            f"| `{row['map']}` | `{row['asset']}` | `{row['rectLabel']}` | {dests} | "
            f"{runs or '-'} | `{row['layer0TileCounts']}` | `{row['layer1FlagCounts']}` | "
            f"{sum(1 for flat in report['flatRows'] if flat['map'] == row['map'] and flat['asset'] == row['asset'] and flat['rectLabel'] == row['rectLabel'] and flat.get('exePlacementStatus') == 'grounded')} | "
            f"{row['exactCoordinatePatternHitCount']} |"
        )
    lines.extend([
        "",
        "## Current Pattern Read",
        "",
        "- The same source rect can be placed multiple times on one map, so source rect tables are not placement tables.",
        "- Active object initializers now match a subset of known labels through `field0x28=0x000cNNNN` plus tile `x/y/w/h`; these are bounds/hotspot candidates, not full draw placement proof.",
        "- Rows without initializer matches remain review-only because their exact owner/range has not been tied yet.",
        "- Exact coordinate-pattern hits near resource refs are retained only as diagnostics.",
    ])
    return "\n".join(lines) + "\n"


def write_html(report: dict[str, Any]) -> str:
    rows = []
    for row in report["analysisRows"]:
        dests = ", ".join(f"{x},{y}" for x, y in row["dests"])
        runs = "<br>".join(
            html.escape(f"y={run['y']} x={','.join(map(str, run['x']))} steps={','.join(map(str, run['steps'])) or '-'}")
            for run in row["rowRuns"]
        )
        rows.append(
            "<tr>"
            f"<td><code>{html.escape(row['map'])}</code></td>"
            f"<td><code>{html.escape(row['asset'])}</code></td>"
            f"<td>{html.escape(row['rectLabel'])}</td>"
            f"<td>{html.escape(dests)}</td>"
            f"<td>{runs}</td>"
            f"<td><code>{html.escape(str(row['layer0TileCounts']))}</code></td>"
            f"<td><code>{html.escape(str(row['layer1FlagCounts']))}</code></td>"
            f"<td>{sum(1 for flat in report['flatRows'] if flat['map'] == row['map'] and flat['asset'] == row['asset'] and flat['rectLabel'] == row['rectLabel'] and flat.get('exePlacementStatus') == 'grounded')}</td>"
            f"<td>{row['exactCoordinatePatternHitCount']}</td>"
            "</tr>"
        )
    stats = report["stats"]
    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 object placements</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 object placements</h1>",
        f"  <p>Status: <code>{html.escape(stats['status'])}</code>. {html.escape(stats['note'])}</p>",
        f"  <p>initializer-matched: <code>{stats['exeGroundedCount']}</code>, review-only: <code>{stats['reviewOnlyCount']}</code>. {html.escape(stats['field0x28Interpretation'])}</p>",
        "  <table><thead><tr><th>map</th><th>asset</th><th>rect</th><th>dests</th><th>row pattern</th><th>layer0</th><th>layer1</th><th>EXE init matches</th><th>EXE coord hits</th></tr></thead>",
        f"  <tbody>{''.join(rows)}</tbody></table>",
        "</body>",
        "</html>",
        "",
    ])


def main() -> None:
    report = build()
    (OUT / "map3_manual_placements.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    (OUT / "map3_manual_placements.js").write_text(
        "window.HWANSE_MAP3_MANUAL_PLACEMENTS = "
        + json.dumps(report["placements"], ensure_ascii=False, separators=(",", ":"))
        + ";\nwindow.HWANSE_MAP3_MANUAL_PLACEMENT_STATS = "
        + json.dumps(report["stats"], ensure_ascii=False, separators=(",", ":"))
        + ";\n",
        encoding="utf-8",
    )
    print(f"wrote {OUT / 'map3_manual_placements.json'}")


if __name__ == "__main__":
    main()
