#!/usr/bin/env python3
"""Bind active-object draw-source slots back to resource CNS slots.

This review keeps the evidence layers separate:

* scene/resource records say which CNS is loaded into which surface slot;
* active-object initializers say which draw-source slot/frame they want;
* root+slot matching can often identify the CNS family, but it is still a
  review binding unless the selected runtime root is proven.
"""
from __future__ import annotations

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

from probe_exe_scene_tables import read_sections, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
EXE = ROOT / "Hwanse2.exe"


def hx(value: int | None, width: int = 8) -> str:
    if value is None:
        return ""
    return f"0x{value:0{width}x}"


def esc(value: Any) -> str:
    return html.escape(str(value if value is not None else ""))


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


def u32_at(exe: bytes, sections: list[dict[str, Any]], 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 command_before_ref(exe: bytes, sections: list[dict[str, Any]], ref_va: int) -> dict[str, Any] | None:
    command = u32_at(exe, sections, ref_va - 4)
    if command is None:
        return None
    opcode = command & 0xFF
    mode = (command >> 8) & 0xFF
    slot = (command >> 16) & 0xFFFF
    if opcode > 0x20:
        return None
    return {
        "commandVa": ref_va - 4,
        "commandVaHex": hx(ref_va - 4),
        "rawDwordHex": hx(command),
        "opcode": opcode,
        "opcodeHex": f"0x{opcode:02x}",
        "mode": mode,
        "modeHex": f"0x{mode:02x}",
        "slot": slot,
        "slotHex": f"0x{slot:04x}",
    }


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 parse_hex(value: Any) -> int | None:
    if isinstance(value, str) and value.startswith("0x"):
        try:
            return int(value, 16)
        except ValueError:
            return None
    return value if isinstance(value, int) else None


def asset_kind(name: str, manifest_kind: str = "") -> str:
    if name == "face_01":
        return "face-ui"
    if name.startswith("cara_"):
        return "field-character-or-object"
    if name.startswith("map_") and name.endswith("3"):
        return "map-extra-object"
    if name.startswith("map_"):
        return "map-tile-layer"
    if name.startswith("btl_"):
        return "battle"
    if manifest_kind:
        return manifest_kind
    return "other"


def rect_index() -> dict[str, dict[int, dict[str, Any]]]:
    rows = load_json(OUT / "cns_rect_review_data.json", {}).get("rows") or []
    out: dict[str, dict[int, dict[str, Any]]] = defaultdict(dict)
    for row in rows:
        asset = row.get("asset")
        if not asset:
            continue
        for rect in row.get("rects") or []:
            index = rect.get("index")
            if isinstance(index, int) and index not in out[asset]:
                out[asset][index] = {
                    "index": index,
                    "label": rect.get("label") or f"#{index}",
                    "x": rect.get("x"),
                    "y": rect.get("y"),
                    "w": rect.get("w"),
                    "h": rect.get("h"),
                    "sourceLabel": rect.get("sourceLabel") or "",
                }
    # map_*3 rects live in the map-extra scan, not the canonical non-map table.
    map_scan = load_json(OUT / "map_extra_rect_pattern_scan.json", {})
    for asset, row in (map_scan.get("byAsset") or {}).items():
        for rect in (row.get("rects") or []) + (row.get("rawRects") or []):
            refs = rect.get("tableRefs") or []
            for ref in refs:
                index = ref.get("rectIndex") if isinstance(ref, dict) else None
                if isinstance(index, int) and index not in out[asset]:
                    out[asset][index] = {
                        "index": index,
                        "label": rect.get("label") or f"raw#{index}",
                        "x": rect.get("x"),
                        "y": rect.get("y"),
                        "w": rect.get("w"),
                        "h": rect.get("h"),
                        "sourceLabel": "map-extra-rect-table",
                    }
    return out


def resource_rows(exe: bytes, sections: list[dict[str, Any]], roots: list[dict[str, Any]]) -> list[dict[str, Any]]:
    manifest = load_json(OUT / "scene_manifest.json", [])
    rows = []
    seen = set()
    for scene in manifest:
        for resource in scene.get("resources") or []:
            ref_va = resource.get("refVa")
            if not isinstance(ref_va, int):
                continue
            command = command_before_ref(exe, sections, ref_va)
            if not command:
                continue
            key = (scene.get("map"), ref_va, resource.get("name"))
            if key in seen:
                continue
            seen.add(key)
            root_hits = roots_for_va(roots, ref_va)
            rows.append(
                {
                    "map": scene.get("map"),
                    "recordVaHex": scene.get("recordVaHex"),
                    "sceneIdHex": scene.get("sceneIdHex"),
                    "asset": resource.get("name"),
                    "filename": resource.get("filename"),
                    "manifestKind": resource.get("kind"),
                    "assetKind": asset_kind(str(resource.get("name") or ""), str(resource.get("kind") or "")),
                    "refVa": ref_va,
                    "refVaHex": hx(ref_va),
                    "rootVaHexes": [root.get("rootVaHex") for root in root_hits],
                    **command,
                }
            )
    return rows


def build_bindings(resources: list[dict[str, Any]], roots: list[dict[str, Any]], rects: dict[str, dict[int, dict[str, Any]]]) -> dict[str, Any]:
    inventory = load_json(OUT / "active_object_script_inventory.json", {}).get("inventory") or []

    root_slot_resources: dict[tuple[str, int], list[dict[str, Any]]] = defaultdict(list)
    for row in resources:
        slot = row.get("slot")
        if not isinstance(slot, int):
            continue
        for root_hex in row.get("rootVaHexes") or []:
            root_slot_resources[(root_hex, slot)].append(row)

    # Deduplicate root-slot resources by map/asset/ref.
    for key, rows in list(root_slot_resources.items()):
        seen = set()
        deduped = []
        for row in rows:
            dedupe_key = (row.get("map"), row.get("asset"), row.get("refVaHex"))
            if dedupe_key in seen:
                continue
            seen.add(dedupe_key)
            deduped.append(row)
        root_slot_resources[key] = deduped

    active_rows = []
    selector_slot_counts: Counter[str] = Counter()
    binding_counts: Counter[str] = Counter()
    asset_counts: Counter[str] = Counter()
    non_map_asset_counts: Counter[str] = Counter()
    blank_count = 0
    for row in inventory:
        field = str(row.get("field0x28Hex") or "")
        if not field.startswith("0x"):
            blank_count += 1
            active_rows.append(
                {
                    **row,
                    "drawSlotHex": "",
                    "drawFrameIndex": None,
                    "drawFrameIndexHex": "",
                    "rootVaHexes": [],
                    "boundAssets": [],
                    "bindingStatus": "no-draw-source-selector",
                    "bindingNote": "field0x28 is blank; likely invisible trigger/controller or dynamic draw source.",
                }
            )
            binding_counts["no-draw-source-selector"] += 1
            continue
        field_value = int(field, 16)
        slot = field_value >> 16
        frame = field_value & 0xFFFF
        slot_hex = f"0x{slot:04x}"
        selector_slot_counts[slot_hex] += 1
        row_roots: dict[str, dict[str, Any]] = {}
        for va_hex in (row.get("initializerVaHex"), row.get("scriptVaHex")):
            va = parse_hex(va_hex)
            for root in roots_for_va(roots, va):
                root_hex = root.get("rootVaHex")
                if root_hex:
                    row_roots[root_hex] = root
        matches: list[dict[str, Any]] = []
        for root_hex in row_roots:
            matches.extend(root_slot_resources.get((root_hex, slot), []))
        # Deduplicate by asset/ref/map.
        seen = set()
        deduped_matches = []
        for match in matches:
            key = (match.get("map"), match.get("asset"), match.get("refVaHex"))
            if key in seen:
                continue
            seen.add(key)
            deduped_matches.append(match)
        matches = deduped_matches
        asset_set = sorted({str(match.get("asset")) for match in matches if match.get("asset")})
        kind_set = sorted({asset_kind(asset) for asset in asset_set})
        if not matches:
            status = "root-slot-unmatched"
            note = "No resource command in the same selector/root uses this slot."
        elif len(asset_set) == 1:
            status = "root-slot-single-asset"
            note = "Same root+slot resolves to one CNS asset name; map variants may repeat that asset."
            asset_counts[asset_set[0]] += 1
            if not asset_set[0].startswith("map_"):
                non_map_asset_counts[asset_set[0]] += 1
        else:
            status = "root-slot-ambiguous-assets"
            note = "Same root+slot maps to multiple CNS assets; do not render as a fixed object without more evidence."
        binding_counts[status] += 1
        frame_rects = []
        for asset in asset_set:
            rect = rects.get(asset, {}).get(frame)
            if rect:
                frame_rects.append({"asset": asset, **rect})
        active_rows.append(
            {
                **row,
                "drawSlot": slot,
                "drawSlotHex": slot_hex,
                "drawFrameIndex": frame,
                "drawFrameIndexHex": f"0x{frame:04x}",
                "rootVaHexes": sorted(row_roots),
                "resourceMatchCount": len(matches),
                "boundAssets": asset_set,
                "boundAssetKinds": kind_set,
                "resourceMatches": [
                    {
                        "map": match.get("map"),
                        "asset": match.get("asset"),
                        "assetKind": match.get("assetKind"),
                        "refVaHex": match.get("refVaHex"),
                        "rootVaHexes": match.get("rootVaHexes"),
                        "opcodeHex": match.get("opcodeHex"),
                        "modeHex": match.get("modeHex"),
                        "slotHex": match.get("slotHex"),
                    }
                    for match in matches[:20]
                ],
                "frameRects": frame_rects,
                "bindingStatus": status,
                "bindingNote": note,
            }
        )

    slot_resource_summary = []
    for (root_hex, slot), rows in sorted(root_slot_resources.items(), key=lambda item: (item[0][0], item[0][1])):
        assets = sorted({str(row.get("asset")) for row in rows if row.get("asset")})
        maps = sorted({str(row.get("map")) for row in rows if row.get("map")})
        slot_resource_summary.append(
            {
                "rootVaHex": root_hex,
                "slot": slot,
                "slotHex": f"0x{slot:04x}",
                "assetCount": len(assets),
                "assets": assets,
                "assetKinds": sorted({asset_kind(asset) for asset in assets}),
                "mapCount": len(maps),
                "maps": maps[:20],
                "resourceRowCount": len(rows),
                "status": "single-asset" if len(assets) == 1 else "ambiguous-assets",
            }
        )

    loaded_sprite_slots = [
        row
        for row in slot_resource_summary
        if any(kind in {"field-character-or-object", "face-ui"} for kind in row["assetKinds"])
    ]
    active_non_map_slots = [
        row
        for row in active_rows
        if row.get("drawSlotHex")
        and row.get("boundAssets")
        and not any(str(asset).startswith("map_") for asset in row.get("boundAssets") or [])
    ]
    unmatched_draw_source_slots = [
        row
        for row in active_rows
        if row.get("drawSlotHex") and row.get("bindingStatus") == "root-slot-unmatched"
    ]

    summary = {
        "resourceRowCount": len(resources),
        "rootSlotCount": len(slot_resource_summary),
        "rootSlotSingleAssetCount": sum(1 for row in slot_resource_summary if row["status"] == "single-asset"),
        "rootSlotAmbiguousAssetCount": sum(1 for row in slot_resource_summary if row["status"] == "ambiguous-assets"),
        "activeInitializerCount": len(inventory),
        "activeInitializerBlankField0x28Count": blank_count,
        "activeInitializerWithDrawSourceCount": len(inventory) - blank_count,
        "selectorSlotCounts": dict(sorted(selector_slot_counts.items())),
        "bindingStatusCounts": dict(sorted(binding_counts.items())),
        "boundAssetCounts": dict(asset_counts.most_common()),
        "boundNonMapAssetCounts": dict(non_map_asset_counts.most_common()),
        "loadedSpriteSlotCount": len(loaded_sprite_slots),
        "activeNonMapSlotBindingCount": len(active_non_map_slots),
        "activeDrawSourceUnmatchedCount": len(unmatched_draw_source_slots),
        "field0x28Interpretation": "high word = resource surface slot, low word = source rect/frame index.",
        "caution": "Root+slot binding identifies loaded CNS candidates, not final placement or runtime-selected scene proof by itself.",
    }
    return {
        "kind": "hwanse-active-object-resource-slot-review",
        "summary": summary,
        "slotResourceSummary": slot_resource_summary,
        "activeObjectBindings": active_rows,
        "loadedSpriteSlots": loaded_sprite_slots,
        "activeNonMapSlotBindings": active_non_map_slots,
        "unmatchedDrawSourceSlots": unmatched_draw_source_slots,
        "nonClaims": [
            "Do not treat every active initializer as a door.",
            "field0x28 blank rows may still be triggers/controllers or dynamically assigned draw sources.",
            "face_01 resources are dialogue UI portraits, not field placement sprites.",
            "Root+slot single-asset binding is stronger than resource proximity, but still not a selected-runtime-root proof.",
        ],
    }


def render_html(payload: dict[str, Any]) -> str:
    s = payload["summary"]

    def frame_rect_label(row: dict[str, Any]) -> str:
        labels = []
        for rect in row.get("frameRects") or []:
            labels.append(
                f"{rect.get('asset')} {rect.get('label')} "
                f"{rect.get('x')},{rect.get('y')} {rect.get('w')}x{rect.get('h')}"
            )
        return "; ".join(labels) or "-"

    metrics = [
        ("resource rows", s["resourceRowCount"]),
        ("root/slot pairs", s["rootSlotCount"]),
        ("active draw-source rows", s["activeInitializerWithDrawSourceCount"]),
        ("blank field0x28", s["activeInitializerBlankField0x28Count"]),
        ("non-map bindings", s["activeNonMapSlotBindingCount"]),
    ]
    metric_html = "".join(
        f"<div class='metric'><span>{esc(label)}</span><strong>{esc(value)}</strong></div>"
        for label, value in metrics
    )
    slot_counts = "".join(
        f"<tr><td><code>{esc(slot)}</code></td><td>{count}</td></tr>"
        for slot, count in s["selectorSlotCounts"].items()
    )
    status_rows = "".join(
        f"<tr><td><code>{esc(status)}</code></td><td>{count}</td></tr>"
        for status, count in s["bindingStatusCounts"].items()
    )
    non_map_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row.get('initializerVaHex'))}</code><div class='muted'>{esc(row.get('scriptVaHex'))}</div></td>"
        f"<td><code>{esc(row.get('field0x28Hex'))}</code><div class='muted'>slot {esc(row.get('drawSlotHex'))} / frame {esc(row.get('drawFrameIndexHex'))}</div></td>"
        f"<td>{esc(row.get('xCandidate'))},{esc(row.get('yCandidate'))}<div class='muted'>{esc(row.get('wCandidate'))}x{esc(row.get('hCandidate'))} tiles</div></td>"
        f"<td>{esc(', '.join(row.get('rootVaHexes') or []))}</td>"
        f"<td>{esc(', '.join(row.get('boundAssets') or []))}<div class='muted'>{esc(row.get('bindingStatus'))}</div></td>"
        f"<td>{esc(frame_rect_label(row))}</td>"
        f"<td>{esc(row.get('scriptClassification'))}</td>"
        "</tr>"
        for row in payload["activeNonMapSlotBindings"]
    )
    slot_summary_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row.get('rootVaHex'))}</code></td>"
        f"<td><code>{esc(row.get('slotHex'))}</code></td>"
        f"<td>{esc(row.get('status'))}</td>"
        f"<td>{esc(', '.join(row.get('assets') or []))}</td>"
        f"<td>{esc(', '.join(row.get('assetKinds') or []))}</td>"
        f"<td>{esc(row.get('mapCount'))}</td>"
        "</tr>"
        for row in payload["slotResourceSummary"]
        if row.get("status") == "ambiguous-assets" or any(kind in {"field-character-or-object", "map-extra-object"} for kind in row.get("assetKinds", []))
    )
    non_claims = "".join(f"<li>{esc(item)}</li>" for item in payload["nonClaims"])
    data = json.dumps(
        {
            "summary": payload["summary"],
            "activeNonMapSlotBindings": payload["activeNonMapSlotBindings"],
        },
        ensure_ascii=False,
    )
    return f"""<!doctype html>
<meta charset="utf-8">
<title>Active Object Resource Slot Review</title>
<style>
body{{font-family:system-ui,sans-serif;background:#101214;color:#e5e7eb;margin:24px;line-height:1.45}}
a{{color:#93c5fd}} code{{color:#bfdbfe}} table{{border-collapse:collapse;width:100%;margin:14px 0}}
td,th{{border:1px solid #374151;padding:7px 9px;vertical-align:top}} th{{background:#1f2937}}
.metrics{{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;margin:14px 0}}
.metric{{background:#181c20;border:1px solid #2f3640;border-radius:6px;padding:10px}}
.metric span{{display:block;color:#9ca3af;font-size:12px}} .metric strong{{font-size:24px}}
.muted{{color:#9ca3af;font-size:12px}} .grid{{display:grid;grid-template-columns:1fr 1fr;gap:16px}}
@media (max-width:900px){{.grid{{grid-template-columns:1fr}}}}
</style>
<h1>Active Object Resource Slot Review</h1>
<p><code>field0x28 high word = resource surface slot</code> / <code>low word = source rect frame</code></p>
<div class="metrics">{metric_html}</div>
<p>문 전용 테이블로 보지 않고, resource command의 slot load와 active object draw-source selector를 분리해 대조한다.</p>
<div class="grid">
  <section><h2>Selector Slot Counts</h2><table><thead><tr><th>slot</th><th>active rows</th></tr></thead><tbody>{slot_counts}</tbody></table></section>
  <section><h2>Binding Status</h2><table><thead><tr><th>status</th><th>count</th></tr></thead><tbody>{status_rows}</tbody></table></section>
</div>
<h2>Non-map Active Slot Bindings</h2>
<p class="muted">현재 strict initializer에서 map_*3 외에 asset으로 묶이는 행은 대부분 cara_etc다. 이것은 NPC/상자/이벤트 소품 후보로 봐야 하며, face_01은 대화 UI 초상화라 배치 대상에서 제외한다.</p>
<table><thead><tr><th>initializer/script</th><th>field0x28</th><th>tile</th><th>root</th><th>asset</th><th>frame rect</th><th>script</th></tr></thead><tbody>{non_map_rows}</tbody></table>
<h2>Root/Slot Resource Summary</h2>
<table><thead><tr><th>root</th><th>slot</th><th>status</th><th>assets</th><th>kinds</th><th>maps</th></tr></thead><tbody>{slot_summary_rows}</tbody></table>
<h2>Non-claims</h2><ul>{non_claims}</ul>
<script>window.HWANSE_ACTIVE_OBJECT_RESOURCE_SLOT_REVIEW = {data};</script>
"""


def build() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    roots = load_json(OUT / "selector_root_structure_review.json", {}).get("roots") or []
    resources = resource_rows(exe, sections, roots)
    return build_bindings(resources, roots, rect_index())


def main() -> None:
    OUT.mkdir(parents=True, exist_ok=True)
    payload = build()
    (OUT / "active_object_resource_slot_review.json").write_text(
        json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    (OUT / "active_object_resource_slot_review.html").write_text(render_html(payload), encoding="utf-8")
    print(json.dumps(payload["summary"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
