#!/usr/bin/env python3
"""Build a review of all map_*3 resource refs and slot 0x000c active initializers."""
from __future__ import annotations

import html
import json
import re
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
WEB = ROOT / "web"
TILE_SIZE = 16
MAP3_RE = re.compile(r"^map_[a-q]3$")


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


def field_low_word(field_hex: str) -> int | None:
    try:
        return int(field_hex, 16) & 0xFFFF
    except (TypeError, ValueError):
        return None


def load_scene_map3_refs() -> dict[str, dict[str, Any]]:
    manifest = load_json(OUT / "scene_manifest.json", [])
    grouped: dict[str, dict[str, Any]] = {}
    for row in manifest:
        map_name = row.get("map")
        if not map_name:
            continue
        assets = sorted({
            resource.get("name")
            for resource in row.get("resources") or []
            if MAP3_RE.match(str(resource.get("name") or ""))
        })
        if not assets:
            continue
        entry = grouped.setdefault(
            map_name,
            {
                "map": map_name,
                "sceneIds": set(),
                "recordVas": set(),
                "assets": set(),
                "refs": [],
            },
        )
        entry["sceneIds"].add(row.get("sceneIdHex"))
        entry["recordVas"].add(row.get("recordVaHex"))
        entry["assets"].update(assets)
        for resource in row.get("resources") or []:
            name = resource.get("name")
            if name in assets:
                entry["refs"].append(
                    {
                        "asset": name,
                        "refVaHex": f"0x{int(resource.get('refVa')):08x}" if isinstance(resource.get("refVa"), int) else None,
                    }
                )
    return {
        key: {
            **value,
            "sceneIds": sorted(v for v in value["sceneIds"] if v),
            "recordVas": sorted(v for v in value["recordVas"] if v),
            "assets": sorted(value["assets"]),
        }
        for key, value in sorted(grouped.items())
    }


def build_rect_index() -> dict[int, list[dict[str, Any]]]:
    scan = load_json(OUT / "map_extra_rect_pattern_scan.json", {})
    by_index: dict[int, list[dict[str, Any]]] = defaultdict(list)
    for asset, row in (scan.get("byAsset") or {}).items():
        for rect in row.get("rects") or []:
            refs = rect.get("tableRefs") or []
            indices = sorted({
                int(ref["rectIndex"])
                for ref in refs
                if isinstance(ref, dict) and isinstance(ref.get("rectIndex"), int)
            })
            for index in indices:
                by_index[index].append(
                    {
                        "asset": asset,
                        "label": rect.get("label"),
                        "x": rect.get("x"),
                        "y": rect.get("y"),
                        "w": rect.get("w"),
                        "h": rect.get("h"),
                        "tileRange": rect.get("tileRange"),
                        "source": "visible-filtered",
                    }
                )
        for rect in row.get("rawRects") or []:
            for ref in rect.get("tableRefs") or []:
                if not isinstance(ref, dict) or not isinstance(ref.get("rectIndex"), int):
                    continue
                index = int(ref["rectIndex"])
                by_index[index].append(
                    {
                        "asset": asset,
                        "label": f"raw#{index}",
                        "x": rect.get("x"),
                        "y": rect.get("y"),
                        "w": rect.get("w"),
                        "h": rect.get("h"),
                        "tileRange": rect.get("tileRange"),
                        "source": "raw-table",
                    }
                )
    deduped: dict[int, list[dict[str, Any]]] = {}
    for index, rows in by_index.items():
        seen = set()
        out = []
        for row in rows:
            key = (row.get("asset"), row.get("label"), row.get("x"), row.get("y"), row.get("w"), row.get("h"), row.get("source"))
            if key in seen:
                continue
            seen.add(key)
            out.append(row)
        deduped[index] = sorted(out, key=lambda item: (str(item.get("asset")), str(item.get("label")), str(item.get("source"))))
    return deduped


def load_active_slot_rows() -> list[dict[str, Any]]:
    data = load_json(OUT / "active_object_script_inventory.json", {})
    rows = data.get("inventory") if isinstance(data, dict) else data
    out = []
    for row in rows or []:
        field = str(row.get("field0x28Hex") or "")
        if not field.startswith("0x000c"):
            continue
        low = field_low_word(field)
        out.append({**row, "field0x28LowWord": low})
    return out


def candidate_rects_for_row(row: dict[str, Any], rect_index: dict[int, list[dict[str, Any]]]) -> list[dict[str, Any]]:
    low = row.get("field0x28LowWord")
    expected_w = int(row.get("wCandidate") or 0) * TILE_SIZE
    expected_h = int(row.get("hCandidate") or 0) * TILE_SIZE
    candidates = []
    for rect in rect_index.get(low, []):
        if expected_w and rect.get("w") != expected_w:
            continue
        if expected_h and rect.get("h") != expected_h:
            continue
        candidates.append(rect)
    return candidates


def parse_hex(value: Any) -> int | None:
    if not isinstance(value, str) or not value.startswith("0x"):
        return None
    try:
        return int(value, 16)
    except ValueError:
        return None


def load_selector_roots() -> list[dict[str, Any]]:
    data = load_json(OUT / "selector_root_structure_review.json", {})
    roots = data.get("roots") if isinstance(data, dict) else []
    return roots if isinstance(roots, list) else []


def roots_for_va(roots: list[dict[str, Any]], va: int | None) -> list[dict[str, Any]]:
    if va is None:
        return []
    hits = []
    for root in roots:
        start = root.get("rootVa")
        end = root.get("rangeEndVa")
        if isinstance(start, int) and isinstance(end, int) and start <= va < end:
            hits.append(root)
    return hits


def root_key(root: dict[str, Any]) -> str:
    return str(root.get("rootVaHex") or "")


def root_brief(root: dict[str, Any]) -> dict[str, Any]:
    return {
        "rootVaHex": root.get("rootVaHex"),
        "rangeEndVaHex": root.get("rangeEndVaHex"),
        "selectorKeys": root.get("selectorKeys") or [],
        "linkedCns": root.get("linkedCns") or [],
        "rootClass": root.get("rootClass"),
    }


def overlay_candidates_for_maps(
    scene_refs: dict[str, dict[str, Any]],
    slot_rows: list[dict[str, Any]],
    rect_index: dict[int, list[dict[str, Any]]],
    roots: list[dict[str, Any]],
) -> tuple[dict[str, dict[str, list[dict[str, Any]]]], dict[str, Any]]:
    """Project EXE initializer candidates onto every map that references map_*3.

    This is intentionally a review overlay, not a promoted placement table.
    Unlike the earlier broad pass, this only draws rows when the map_*3 resource
    ref and the active initializer/script live inside the same selector/root
    range.  That keeps asset-wide false positives out of map_review.
    """
    map_asset_root_keys: dict[tuple[str, str], set[str]] = defaultdict(set)
    map_asset_root_briefs: dict[tuple[str, str], dict[str, dict[str, Any]]] = defaultdict(dict)
    for map_name, ref in scene_refs.items():
        for resource in ref.get("refs") or []:
            asset = resource.get("asset")
            ref_va = parse_hex(resource.get("refVaHex"))
            for root in roots_for_va(roots, ref_va):
                key = root_key(root)
                if not key:
                    continue
                map_asset_root_keys[(map_name, str(asset))].add(key)
                map_asset_root_briefs[(map_name, str(asset))][key] = root_brief(root)

    grouped: dict[str, dict[str, list[dict[str, Any]]]] = defaultdict(lambda: defaultdict(list))
    total = 0
    broad_total = 0
    ambiguous_asset_withheld = 0
    per_map_counts: dict[str, int] = {}
    per_asset_counts: Counter[str] = Counter()
    for map_name, ref in scene_refs.items():
        map_assets = set(ref.get("assets") or [])
        seen = set()
        for row in slot_rows:
            row_root_map: dict[str, dict[str, Any]] = {}
            for va_hex in (row.get("initializerVaHex"), row.get("scriptVaHex")):
                for root in roots_for_va(roots, parse_hex(va_hex)):
                    key = root_key(root)
                    if key:
                        row_root_map[key] = root_brief(root)
            # Prefer visible-filtered rects for drawing. Raw table entries remain
            # analysis-only because they include hidden/empty candidates.
            visible_rects_for_row = [
                rect
                for rect in candidate_rects_for_row(row, rect_index)
                if rect.get("source") == "visible-filtered"
            ]
            visible_assets_for_row = {rect.get("asset") for rect in visible_rects_for_row if rect.get("asset")}
            if len(visible_assets_for_row) != 1:
                ambiguous_asset_withheld += 1
                continue
            rects = [
                rect
                for rect in visible_rects_for_row
                if rect.get("asset") in map_assets
            ]
            for rect in rects:
                x_tile = row.get("xCandidate")
                y_tile = row.get("yCandidate")
                if not isinstance(x_tile, int) or not isinstance(y_tile, int):
                    continue
                broad_total += 1
                asset = str(rect.get("asset"))
                resource_root_keys = map_asset_root_keys.get((map_name, asset), set())
                shared_root_keys = sorted(resource_root_keys & set(row_root_map))
                if not shared_root_keys:
                    continue
                key = (
                    map_name,
                    asset,
                    row.get("field0x28Hex"),
                    x_tile,
                    y_tile,
                    rect.get("x"),
                    rect.get("y"),
                    rect.get("w"),
                    rect.get("h"),
                )
                if key in seen:
                    continue
                seen.add(key)
                item = {
                    "label": f"{row.get('field0x28Hex')} {asset} {rect.get('label')} @ {x_tile},{y_tile}",
                    "tileset": asset,
                    "rectLabel": rect.get("label"),
                    "rect": [rect.get("x"), rect.get("y"), rect.get("w"), rect.get("h")],
                    "rectTileRange": rect.get("tileRange"),
                    "placement": [x_tile * TILE_SIZE, y_tile * TILE_SIZE],
                    "placementTile": [x_tile, y_tile],
                    "field0x28Hex": row.get("field0x28Hex"),
                    "field0x28LowWord": row.get("field0x28LowWord"),
                    "wTiles": row.get("wCandidate"),
                    "hTiles": row.get("hCandidate"),
                    "initializerCount": 1,
                    "initializerVas": [row.get("initializerVaHex")] if row.get("initializerVaHex") else [],
                    "scriptVas": [row.get("scriptVaHex")] if row.get("scriptVaHex") else [],
                    "classifications": [row.get("scriptClassification")] if row.get("scriptClassification") else [],
                    "sharedRootVas": shared_root_keys,
                    "sharedRoots": [row_root_map.get(key) or map_asset_root_briefs[(map_name, asset)].get(key) for key in shared_root_keys],
                    "overlayStatus": "exe-initializer-root-bound-candidate",
                    "overlayEvidence": "field0x28 slot 0x000c + active initializer tile bounds + same selector/root as this map's map_*3 resource ref",
                }
                grouped[map_name][asset].append(item)
                total += 1
                per_map_counts[map_name] = per_map_counts.get(map_name, 0) + 1
                per_asset_counts[asset] += 1
    out = {
        map_name: {asset: rows for asset, rows in sorted(assets.items())}
        for map_name, assets in sorted(grouped.items())
    }
    stats = {
        "status": "map3-active-initializer-root-bound-overlay-candidates",
        "mapCount": len(out),
        "candidateCount": total,
        "broadAssetMatchedCandidateCount": broad_total,
        "ambiguousAssetInitializerRowsWithheld": ambiguous_asset_withheld,
        "maxCandidatesOnMap": max(per_map_counts.values()) if per_map_counts else 0,
        "perAssetCounts": dict(sorted(per_asset_counts.items())),
        "source": "out/map3_active_initializer_review.json activeSlot0cPlacements + selector_root_structure_review root ranges",
        "caution": "Review overlay only. Rows require a single unambiguous visible map_*3 source asset and the active initializer/script plus this map's map_*3 resource ref to share a selector/root range. Ambiguous slot 0x000c rect-index reuse is withheld from map_review.",
    }
    return out, stats


def build() -> dict[str, Any]:
    scene_refs = load_scene_map3_refs()
    rect_index = build_rect_index()
    slot_rows = load_active_slot_rows()
    unique: dict[tuple[Any, ...], dict[str, Any]] = {}
    for row in slot_rows:
        key = (
            row.get("field0x28Hex"),
            row.get("xCandidate"),
            row.get("yCandidate"),
            row.get("wCandidate"),
            row.get("hCandidate"),
        )
        item = unique.setdefault(
            key,
            {
                "field0x28Hex": row.get("field0x28Hex"),
                "field0x28LowWord": row.get("field0x28LowWord"),
                "x": row.get("xCandidate"),
                "y": row.get("yCandidate"),
                "wTiles": row.get("wCandidate"),
                "hTiles": row.get("hCandidate"),
                "count": 0,
                "initializerVas": [],
                "scriptVas": [],
                "classifications": set(),
                "candidateRects": candidate_rects_for_row(row, rect_index),
            },
        )
        item["count"] += 1
        if row.get("initializerVaHex"):
            item["initializerVas"].append(row.get("initializerVaHex"))
        if row.get("scriptVaHex"):
            item["scriptVas"].append(row.get("scriptVaHex"))
        if row.get("scriptClassification"):
            item["classifications"].add(row.get("scriptClassification"))
    unique_rows = []
    for item in unique.values():
        item["initializerVas"] = sorted(set(item["initializerVas"]))
        item["scriptVas"] = sorted(set(item["scriptVas"]))
        item["classifications"] = sorted(item["classifications"])
        item["candidateAssets"] = sorted({rect.get("asset") for rect in item["candidateRects"] if rect.get("asset")})
        item["candidateLabels"] = sorted({
            f"{rect.get('asset')} {rect.get('label')}"
            for rect in item["candidateRects"]
            if rect.get("asset") and rect.get("label")
        })
        unique_rows.append(item)
    unique_rows.sort(key=lambda row: (int(row.get("field0x28LowWord") or 0), int(row.get("y") or 0), int(row.get("x") or 0)))
    roots = load_selector_roots()
    overlay_candidates, overlay_stats = overlay_candidates_for_maps(scene_refs, slot_rows, rect_index, roots)

    manual = load_json(OUT / "map3_manual_placements.json", {})
    field_counts = Counter(row.get("field0x28Hex") for row in slot_rows)
    return {
        "status": "map3-active-initializer-slot-review",
        "summary": {
            "map3ResourceMapCount": len(scene_refs),
            "map3ResourceAssetSet": sorted({asset for row in scene_refs.values() for asset in row["assets"]}),
            "map3ResourcePairCount": sum(len(row["assets"]) for row in scene_refs.values()),
            "activeSlot0cRowCount": len(slot_rows),
            "activeSlot0cUniquePlacementCount": len(unique_rows),
            "activeSlot0cUniqueBoundsCount": len(unique_rows),
            "activeSlot0cFieldCounts": dict(sorted(field_counts.items())),
            "manualPlacementCount": manual.get("stats", {}).get("placementCount"),
            "manualPlacementExeGroundedCount": manual.get("stats", {}).get("exeGroundedCount"),
            "manualPlacementReviewOnlyCount": manual.get("stats", {}).get("reviewOnlyCount"),
            "field0x28Interpretation": "high word 0x000c = map_*3 surface slot; low word = EXE source-rect/frame index. It is not the browser R-label index. The 0x00416ce2 overlay renderer consumes this field as a draw-source selector.",
            "mapBindingCaution": "active initializers provide tile x/y/w/h plus field0x28. The x/y/w/h values are grounded as active object bounds/hotspot coordinates by the movement/contact consumer, while the common overlay renderer uses object+0x1c/+0x20 as projected draw coordinates. Therefore x/y/w/h are not guaranteed final draw-placement rectangles by themselves. They also do not always carry a direct map name in this report; scene resource refs and initializer clusters must still be correlated.",
        },
        "sceneMap3Refs": scene_refs,
        "activeSlot0cPlacements": unique_rows,
        "initializerOverlayCandidates": overlay_candidates,
        "initializerOverlayStats": overlay_stats,
    }


def write_outputs(report: dict[str, Any]) -> None:
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    (OUT / "map3_active_initializer_review.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    (OUT / "map3_initializer_overlay_candidates.js").write_text(
        "window.HWANSE_MAP3_INITIALIZER_OVERLAY_CANDIDATES = "
        + json.dumps(report["initializerOverlayCandidates"], ensure_ascii=False, separators=(",", ":"))
        + ";\nwindow.HWANSE_MAP3_INITIALIZER_OVERLAY_STATS = "
        + json.dumps(report["initializerOverlayStats"], ensure_ascii=False, separators=(",", ":"))
        + ";\n",
        encoding="utf-8",
    )
    map_rows = "".join(
        f"<tr><td><code>{html.escape(name)}</code></td><td>{', '.join(f'<code>{html.escape(asset)}</code>' for asset in row['assets'])}</td><td>{html.escape(', '.join(row['sceneIds']))}</td><td>{html.escape(', '.join(row['recordVas'][:4]))}</td></tr>"
        for name, row in report["sceneMap3Refs"].items()
    )
    active_rows = "".join(
        f"<tr><td><code>{html.escape(str(row['field0x28Hex']))}</code></td><td>{row['x']},{row['y']}</td><td>{row['wTiles']}x{row['hTiles']}</td><td>{row['count']}</td><td>{html.escape(', '.join(row['candidateLabels'][:8]) or '-')}</td><td>{html.escape(', '.join(row['initializerVas'][:4]))}</td></tr>"
        for row in report["activeSlot0cPlacements"]
    )
    page = f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<link rel="icon" href="../favicon.ico" />
<title>map_*3 Active Initializer Review</title>
<style>
body{{font-family:system-ui,sans-serif;margin:24px;background:#f7f7f4;color:#202020;line-height:1.45}}
table{{border-collapse:collapse;width:100%;margin:16px 0;background:#fff}}
th,td{{border:1px solid #d0d0ca;padding:8px;vertical-align:top}}
th{{background:#ecece6;text-align:left;position:sticky;top:0}}
code{{background:#f0f0ec;padding:1px 3px;border-radius:3px}}
.tag{{display:inline-block;padding:2px 6px;border:1px solid #b8d3ef;background:#e9f2ff;border-radius:4px}}
</style>
</head>
<body>
<main data-page="map3-active-initializer-review">
<h1>map_*3 Active Initializer Review</h1>
<p><span class="tag">{html.escape(report['status'])}</span></p>
<ul>
<li>map_*3 resource maps: <code>{report['summary']['map3ResourceMapCount']}</code></li>
<li>map_*3 resource asset pairs: <code>{report['summary']['map3ResourcePairCount']}</code></li>
<li>active slot 0x000c rows: <code>{report['summary']['activeSlot0cRowCount']}</code></li>
<li>unique active draw-source + bounds/hotspot keys: <code>{report['summary']['activeSlot0cUniqueBoundsCount']}</code></li>
<li>manual labels with initializer match: <code>{report['summary']['manualPlacementExeGroundedCount']}/{report['summary']['manualPlacementCount']}</code></li>
<li>initializer overlay candidate maps: <code>{report['initializerOverlayStats']['mapCount']}</code></li>
<li>initializer overlay candidates: <code>{report['initializerOverlayStats']['candidateCount']}</code></li>
</ul>
<p>{html.escape(report['summary']['field0x28Interpretation'])}</p>
<p>{html.escape(report['summary']['mapBindingCaution'])}</p>
<h2>Active Initializer Draw Sources And Bounds/Hotspots</h2>
<table><thead><tr><th>field0x28</th><th>tile</th><th>size</th><th>count</th><th>candidate rects</th><th>initializer VAs</th></tr></thead><tbody>{active_rows}</tbody></table>
<h2>Maps Referencing map_*3</h2>
<table><thead><tr><th>map</th><th>map_*3 assets</th><th>scene ids</th><th>record VAs</th></tr></thead><tbody>{map_rows}</tbody></table>
<script>
window.HWANSE_MAP3_ACTIVE_INITIALIZER_REVIEW_READY = true;
window.HWANSE_MAP3_ACTIVE_INITIALIZER_REVIEW = {json.dumps(report['summary'], ensure_ascii=False)};
window.HWANSE_MAP3_ACTIVE_INITIALIZER_OVERLAY_STATS = {json.dumps(report['initializerOverlayStats'], ensure_ascii=False)};
</script>
</main>
</body>
</html>
"""
    (WEB / "map3_active_initializer_review.html").write_text(page, encoding="utf-8")


def main() -> None:
    write_outputs(build())


if __name__ == "__main__":
    main()
