#!/usr/bin/env python3
"""Build a focused visual review for strict cara_etc active-object bindings."""
from __future__ import annotations

import html
import json
from collections import Counter
from pathlib import Path
from typing import Any


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


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 first_frame_rect(row: dict[str, Any]) -> dict[str, Any] | None:
    for rect in row.get("frameRects") or []:
        if rect.get("asset") == "cara_etc":
            return rect
    return None


def build_scene_resource_index(scene_manifest: list[dict[str, Any]]) -> dict[int, dict[str, Any]]:
    index: dict[int, dict[str, Any]] = {}
    for scene in scene_manifest:
        resource_names = [item.get("name") for item in scene.get("resources") or []]
        for resource_index, resource in enumerate(scene.get("resources") or []):
            ref = resource.get("refVa")
            if ref is None:
                continue
            index[int(ref)] = {
                "map": scene.get("map"),
                "sceneRecordVaHex": scene.get("recordVaHex"),
                "sceneIdHex": scene.get("sceneIdHex"),
                "resourceSource": scene.get("resourceSource"),
                "resourceIndex": resource_index,
                "resourceName": resource.get("name"),
                "resourceKind": resource.get("kind"),
                "resourceNames": resource_names,
                "sprites": scene.get("sprites") or [],
                "tilesets": scene.get("tilesets") or [],
            }
    return index


def rank_candidates(row: dict[str, Any], maps: list[dict[str, Any]]) -> dict[str, Any]:
    if not maps:
        return {
            "status": "no-resource-match",
            "promotedMap": None,
            "selectorRootDiscriminatorFound": False,
            "reason": "No resource match was found for this draw-source slot.",
        }
    distances = []
    init = row.get("initializerVa")
    for candidate in maps:
        ref = candidate.get("refVa")
        if ref is None or init is None:
            continue
        distances.append((int(ref) - int(init), candidate))
    distances.sort(key=lambda item: (abs(item[0]), item[0]))
    nearest = distances[0] if distances else None
    next_nearest = distances[1] if len(distances) > 1 else None
    if len(maps) == 1:
        candidate = maps[0]
        return {
            "status": "single-map-grounded",
            "promotedMap": candidate.get("map"),
            "promotedSceneRecordVaHex": candidate.get("sceneRecordVaHex"),
            "promotedSceneIdHex": candidate.get("sceneIdHex"),
            "selectorRootDiscriminatorFound": True,
            "nearestMap": candidate.get("map"),
            "nearestForwardDistance": nearest[0] if nearest else None,
            "nearestGapToNext": None,
            "reason": "Only one exact scene resource record shares this root+slot and cara_etc draw source.",
        }
    return {
        "status": "multi-map-shared-root-slot",
        "promotedMap": None,
        "promotedSceneRecordVaHex": None,
        "promotedSceneIdHex": None,
        "selectorRootDiscriminatorFound": False,
        "nearestMap": nearest[1].get("map") if nearest else None,
        "nearestForwardDistance": nearest[0] if nearest else None,
        "nearestGapToNext": abs(next_nearest[0] - nearest[0]) if nearest and next_nearest else None,
        "reason": (
            "Multiple exact scene resource records share the same root+slot and asset. "
            "Address-nearest ranking is shown as a weak hint only; no selector/condition consumer promoted one map."
        ),
    }


def build() -> dict[str, Any]:
    slot_review = load_json(OUT / "active_object_resource_slot_review.json", {})
    scene_manifest = load_json(OUT / "scene_manifest.json", [])
    scene_resource_index = build_scene_resource_index(scene_manifest)
    rows = [
        row
        for row in slot_review.get("activeNonMapSlotBindings") or []
        if row.get("boundAssets") == ["cara_etc"]
    ]
    normalized = []
    map_counter: Counter[str] = Counter()
    frame_counter: Counter[str] = Counter()
    root_counter: Counter[str] = Counter()
    status_counter: Counter[str] = Counter()
    exact_scene_record_counter: Counter[str] = Counter()
    for index, row in enumerate(rows, start=1):
        maps = []
        seen_maps = set()
        for match in row.get("resourceMatches") or []:
            name = match.get("map")
            if not name or name in seen_maps:
                continue
            seen_maps.add(name)
            ref_va = int(match["refVaHex"], 16) if match.get("refVaHex") else None
            scene_hit = scene_resource_index.get(ref_va) if ref_va is not None else None
            if scene_hit and scene_hit.get("sceneRecordVaHex"):
                exact_scene_record_counter[str(scene_hit["sceneRecordVaHex"])] += 1
            maps.append(
                {
                    "map": name,
                    "refVaHex": match.get("refVaHex"),
                    "refVa": ref_va,
                    "rootVaHexes": match.get("rootVaHexes") or [],
                    "slotHex": match.get("slotHex"),
                    "sceneRecordVaHex": scene_hit.get("sceneRecordVaHex") if scene_hit else None,
                    "sceneIdHex": scene_hit.get("sceneIdHex") if scene_hit else None,
                    "resourceIndex": scene_hit.get("resourceIndex") if scene_hit else None,
                    "resourceSource": scene_hit.get("resourceSource") if scene_hit else None,
                    "sceneSprites": scene_hit.get("sprites") if scene_hit else [],
                    "sceneTilesets": scene_hit.get("tilesets") if scene_hit else [],
                    "resourceNames": scene_hit.get("resourceNames") if scene_hit else [],
                    "distanceFromInitializer": (ref_va - row.get("initializerVa"))
                    if ref_va is not None and row.get("initializerVa") is not None
                    else None,
                }
            )
            map_counter[name] += 1
        narrowing = rank_candidates(row, maps)
        status_counter[narrowing["status"]] += 1
        frame = first_frame_rect(row)
        frame_key = row.get("drawFrameIndexHex") or ""
        frame_counter[frame_key] += 1
        for root in row.get("rootVaHexes") or []:
            root_counter[root] += 1
        normalized.append(
            {
                "id": f"cara-etc-{index:02d}",
                "initializerVaHex": row.get("initializerVaHex"),
                "scriptVaHex": row.get("scriptVaHex"),
                "field0x28Hex": row.get("field0x28Hex"),
                "drawSlotHex": row.get("drawSlotHex"),
                "drawFrameIndex": row.get("drawFrameIndex"),
                "drawFrameIndexHex": row.get("drawFrameIndexHex"),
                "rootVaHexes": row.get("rootVaHexes") or [],
                "tileBounds": {
                    "x": row.get("xCandidate"),
                    "y": row.get("yCandidate"),
                    "w": row.get("wCandidate"),
                    "h": row.get("hCandidate"),
                },
                "frameRect": frame,
                "candidateMaps": maps,
                "candidateMapCount": len(maps),
                "narrowing": narrowing,
                "scriptClassification": row.get("scriptClassification"),
                "bindingStatus": row.get("bindingStatus"),
                "bindingNote": row.get("bindingNote"),
            }
        )
    summary = {
        "strictCaraEtcBindingCount": len(normalized),
        "uniqueCandidateMapCount": len(map_counter),
        "frameUseCounts": dict(frame_counter.most_common()),
        "rootUseCounts": dict(root_counter.most_common()),
        "narrowingStatusCounts": dict(status_counter.most_common()),
        "singleMapGroundedCount": status_counter.get("single-map-grounded", 0),
        "multiMapSharedRootSlotCount": status_counter.get("multi-map-shared-root-slot", 0),
        "exactSceneResourceRecordCount": len(exact_scene_record_counter),
        "candidateMapUseCounts": dict(map_counter.most_common()),
        "evidence": (
            "Rows come from active_object_resource_slot_review: field0x28 high word binds to a resource surface slot, "
            "and same selector/root+slot resolves to a single CNS asset, cara_etc."
        ),
        "narrowingEvidence": (
            "Each resource refVa is joined back to scene_manifest resource records. "
            "This narrows repeated map names to exact scene/resource records. Multi-map rows stay unpromoted unless only one "
            "exact root+slot record exists."
        ),
        "caution": (
            "The tile x/y/w/h values are active object bounds/hotspot candidates. "
            "They are not promoted here to final draw placement coordinates."
        ),
    }
    return {
        "kind": "hwanse-active-object-cara-etc-review",
        "summary": summary,
        "rows": normalized,
        "nonClaims": [
            "This page does not claim every cara_etc row is an NPC.",
            "Candidate maps are root/slot resource matches, not proof that every candidate map selects the row at runtime.",
            "Address-nearest map candidates are weak hints only and are not promoted when multiple scene records share the slot.",
            "The overlay rectangle is tile bounds/hotspot evidence, not final object draw position proof.",
        ],
    }


def render_html(payload: dict[str, Any]) -> str:
    s = payload["summary"]
    data = json.dumps(payload, ensure_ascii=False)
    rows_json = json.dumps(payload["rows"], ensure_ascii=False)
    metric_html = "".join(
        f"<div class='metric'><span>{esc(label)}</span><strong>{esc(value)}</strong></div>"
        for label, value in [
            ("strict cara_etc bindings", s["strictCaraEtcBindingCount"]),
            ("unique candidate maps", s["uniqueCandidateMapCount"]),
            ("single-map grounded", s["singleMapGroundedCount"]),
            ("multi-map unresolved", s["multiMapSharedRootSlotCount"]),
            ("exact scene records", s["exactSceneResourceRecordCount"]),
            ("used frame selectors", len(s["frameUseCounts"])),
            ("root groups", len(s["rootUseCounts"])),
        ]
    )
    non_claims = "".join(f"<li>{esc(item)}</li>" for item in payload["nonClaims"])
    return f"""<!doctype html>
<meta charset="utf-8">
<title>Active Object cara_etc Review</title>
<style>
:root{{color-scheme:dark;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:#101214;color:#e5e7eb}}
body{{margin:24px;background:#101214;color:#e5e7eb;line-height:1.45}}
a{{color:#93c5fd}} code{{color:#bfdbfe}}
.metrics{{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:14px 0}}
.metric{{border:1px solid #2f3640;background:#181c20;border-radius:6px;padding:10px}}
.metric span{{display:block;color:#9ca3af;font-size:12px}} .metric strong{{font-size:24px}}
.cards{{display:grid;gap:14px;margin-top:18px}}
.card{{display:grid;grid-template-columns:minmax(280px,430px) minmax(360px,1fr);gap:12px;border:1px solid #374151;background:#15191e;border-radius:8px;padding:12px}}
.card h2{{margin:0 0 8px;font-size:15px}}
.meta{{display:grid;gap:5px;color:#d1d5db;font-size:13px}}
.muted{{color:#9ca3af}} .warn{{color:#fbbf24}}
.source-panel{{display:grid;grid-template-columns:96px 1fr;gap:10px;align-items:start;margin-top:10px}}
.source-canvas,.map-canvas{{image-rendering:pixelated;background:#080808;border:1px solid #374151}}
.chips{{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0}}
.chip{{border:1px solid #4b5563;background:#1f2937;color:#e5e7eb;min-height:26px;padding:0 8px;cursor:pointer;border-radius:4px}}
.chip.active{{border-color:#22c55e;background:#16351f}}
.map-wrap{{overflow:auto;max-height:420px;border:1px solid #26303a;background:#080808}}
.map-tools{{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:8px}}
table{{border-collapse:collapse;width:100%;margin:14px 0}} td,th{{border:1px solid #374151;padding:7px 9px;vertical-align:top}} th{{background:#1f2937}}
@media (max-width:900px){{.card{{grid-template-columns:1fr}}}}
</style>
<h1>Active Object cara_etc Review</h1>
<p>{esc(s["evidence"])}</p>
<p>{esc(s["narrowingEvidence"])}</p>
<p class="warn">{esc(s["caution"])}</p>
<div class="metrics">{metric_html}</div>
<section>
  <h2>Non-claims</h2>
  <ul>{non_claims}</ul>
</section>
<section class="cards" id="cards"></section>
<script src="../web/engine/cns/renderer.js"></script>
<script src="maps.js"></script>
<script>
window.HWANSE_ACTIVE_OBJECT_CARA_ETC_REVIEW = {data};
const ROWS = {rows_json};
const MAPS = window.HWANSE_MAPS || {{}};
const cards = document.getElementById('cards');
const imageCache = new Map();
function loadImage(src) {{
  if (!imageCache.has(src)) {{
    imageCache.set(src, window.HWANSE_CNS_RENDERER.loadImageCanvas(src));
  }}
  return imageCache.get(src);
}}
function checker(ctx, w, h) {{
  ctx.fillStyle = '#0b0b0b';
  ctx.fillRect(0, 0, w, h);
  for (let y = 0; y < h; y += 8) for (let x = 0; x < w; x += 8) {{
    ctx.fillStyle = ((x + y) / 8) % 2 ? '#151515' : '#222';
    ctx.fillRect(x, y, 8, 8);
  }}
}}
async function drawSource(canvas, rect) {{
  const scale = 3;
  canvas.width = Math.max(32, rect?.w || 16) * scale;
  canvas.height = Math.max(32, rect?.h || 16) * scale;
  const ctx = canvas.getContext('2d');
  ctx.imageSmoothingEnabled = false;
  checker(ctx, canvas.width, canvas.height);
  if (!rect) return;
  const img = await loadImage('cara_etc');
  ctx.drawImage(img, rect.x, rect.y, rect.w, rect.h, 0, 0, rect.w * scale, rect.h * scale);
}}
function tileToSource(index, columns, tileSize) {{
  return {{ sx: (index % columns) * tileSize, sy: Math.floor(index / columns) * tileSize }};
}}
async function drawMap(canvas, row, mapName) {{
  const map = MAPS[mapName];
  const tileSize = map?.tileSize || 16;
  if (!map) {{
    canvas.width = 480; canvas.height = 80;
    const ctx = canvas.getContext('2d');
    ctx.fillStyle = '#111'; ctx.fillRect(0,0,canvas.width,canvas.height);
    ctx.fillStyle = '#fca5a5'; ctx.fillText(`missing map ${{mapName}}`, 12, 28);
    return;
  }}
  canvas.width = map.width * tileSize;
  canvas.height = map.height * tileSize;
  canvas.style.width = Math.min(canvas.width, 960) + 'px';
  canvas.style.height = 'auto';
  const ctx = canvas.getContext('2d');
  ctx.imageSmoothingEnabled = false;
  ctx.fillStyle = '#050505';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  const layer0 = map.layers?.[0] || [];
  const tileset = map.layerTilesets?.[0] || map.tileset;
  const tileImg = await loadImage(tileset);
  const columns = map.tilesetColumns || Math.floor(tileImg.width / tileSize) || 40;
  for (let y = 0; y < map.height; y++) {{
    for (let x = 0; x < map.width; x++) {{
      const index = layer0[y * map.width + x] ?? 0;
      const src = tileToSource(index, columns, tileSize);
      ctx.drawImage(tileImg, src.sx, src.sy, tileSize, tileSize, x * tileSize, y * tileSize, tileSize, tileSize);
    }}
  }}
  const bounds = row.tileBounds || {{}};
  const bx = Number(bounds.x), by = Number(bounds.y), bw = Number(bounds.w), bh = Number(bounds.h);
  if (Number.isFinite(bx) && Number.isFinite(by) && Number.isFinite(bw) && Number.isFinite(bh)) {{
    ctx.save();
    ctx.fillStyle = 'rgba(250, 204, 21, 0.22)';
    ctx.strokeStyle = '#facc15';
    ctx.lineWidth = 2;
    ctx.fillRect(bx * tileSize, by * tileSize, bw * tileSize, bh * tileSize);
    ctx.strokeRect(bx * tileSize + 1, by * tileSize + 1, bw * tileSize - 2, bh * tileSize - 2);
    ctx.fillStyle = '#111827';
    ctx.strokeStyle = '#facc15';
    ctx.lineWidth = 1;
    const label = `${{row.id}} ${{bx}},${{by}} ${{bw}}x${{bh}}`;
    ctx.font = '12px monospace';
    const tw = ctx.measureText(label).width + 8;
    ctx.fillRect(bx * tileSize, Math.max(0, by * tileSize - 18), tw, 16);
    ctx.strokeRect(bx * tileSize, Math.max(0, by * tileSize - 18), tw, 16);
    ctx.fillStyle = '#fde68a';
    ctx.fillText(label, bx * tileSize + 4, Math.max(12, by * tileSize - 6));
    ctx.restore();
  }}
}}
function mapLink(mapName) {{
  return `../web/map_review.html?map=${{encodeURIComponent(mapName)}}&grid=1&scale=1`;
}}
function renderCard(row) {{
  const card = document.createElement('article');
  card.className = 'card';
  const left = document.createElement('section');
  const maps = row.candidateMaps || [];
  const firstMap = maps[0]?.map || '';
  left.innerHTML = `
    <h2>${{row.id}} <span class="muted">${{row.field0x28Hex || ''}}</span></h2>
    <div class="meta">
      <div>init <code>${{row.initializerVaHex}}</code> · script <code>${{row.scriptVaHex}}</code></div>
      <div>slot <code>${{row.drawSlotHex}}</code> · frame <code>${{row.drawFrameIndexHex}}</code></div>
      <div>tile bounds <code>${{row.tileBounds.x}},${{row.tileBounds.y}} ${{row.tileBounds.w}}x${{row.tileBounds.h}}</code></div>
      <div>root <code>${{(row.rootVaHexes || []).join(', ') || '-'}}</code></div>
      <div>candidate maps <code>${{row.candidateMapCount}}</code></div>
      <div>narrowing <code>${{row.narrowing?.status || '-'}}</code>${{row.narrowing?.promotedMap ? ` · promoted <code>${{row.narrowing.promotedMap}}</code>` : ''}}</div>
      <div class="muted">${{row.narrowing?.reason || ''}}</div>
      ${{row.narrowing?.nearestMap ? `<div class="muted">nearest address hint: <code>${{row.narrowing.nearestMap}}</code> Δ=${{row.narrowing.nearestForwardDistance}} · gap=${{row.narrowing.nearestGapToNext ?? '-'}}</div>` : ''}}
    </div>
    <div class="source-panel">
      <canvas class="source-canvas"></canvas>
      <div class="meta">
        <div>source <code>cara_etc</code></div>
        <div>rect <code>${{row.frameRect ? `${{row.frameRect.label}} ${{row.frameRect.x}},${{row.frameRect.y}} ${{row.frameRect.w}}x${{row.frameRect.h}}` : '-'}}</code></div>
        <div class="muted">${{row.frameRect?.sourceLabel || ''}}</div>
      </div>
    </div>
  `;
  const right = document.createElement('section');
  right.innerHTML = `
    <div class="map-tools">
      <span class="muted">candidate map</span>
      <span class="chips"></span>
      <a class="open-map" href="${{firstMap ? mapLink(firstMap) : '#'}}">map_review 열기</a>
    </div>
    <p class="muted scene-note"></p>
    <div class="map-wrap"><canvas class="map-canvas"></canvas></div>
    <p class="muted">노란 사각형은 active object tile bounds/hotspot이다. final draw position으로 승격하지 않는다.</p>
  `;
  card.append(left, right);
  const sourceCanvas = left.querySelector('.source-canvas');
  drawSource(sourceCanvas, row.frameRect || null);
  const mapCanvas = right.querySelector('.map-canvas');
  const openMap = right.querySelector('.open-map');
  const chips = right.querySelector('.chips');
  const sceneNote = right.querySelector('.scene-note');
  let activeMap = firstMap;
  function setMap(name) {{
    activeMap = name;
    [...chips.querySelectorAll('button')].forEach((button) => button.classList.toggle('active', button.dataset.map === name));
    openMap.href = name ? mapLink(name) : '#';
    const item = maps.find((candidate) => candidate.map === name) || {{}};
    sceneNote.textContent = `scene ${{item.sceneRecordVaHex || '-'}} · sceneId ${{item.sceneIdHex || '-'}} · resourceIndex ${{item.resourceIndex ?? '-'}} · sprites ${{(item.sceneSprites || []).join(', ') || '-'}}`;
    drawMap(mapCanvas, row, name);
  }}
  maps.forEach((item) => {{
    const button = document.createElement('button');
    button.className = 'chip';
    button.type = 'button';
    button.dataset.map = item.map;
    button.textContent = `${{item.map}} ${{item.sceneRecordVaHex || item.refVaHex || ''}}`;
    button.title = `${{item.refVaHex || ''}} ${{(item.rootVaHexes || []).join(', ')}} · Δ=${{item.distanceFromInitializer ?? '-'}}`;
    button.addEventListener('click', () => setMap(item.map));
    chips.append(button);
  }});
  if (activeMap) setMap(activeMap);
  return card;
}}
ROWS.forEach((row) => cards.append(renderCard(row)));
</script>
"""


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


if __name__ == "__main__":
    main()
