#!/usr/bin/env python3
"""Summarize EXE-side CNS pre-cut/catalog access modes.

This review separates three concepts that are easy to conflate:

* descriptor +4 source-rect tables, used by most battle/character/monster CNS
  assets as a pre-cut frame catalog;
* window.cns template/region catalogs, where 16x16 cells are assembled into
  reusable window templates and placed by region ids;
* fixed-grid sheets such as item/icon/face/num, where current evidence is a
  stable grid rather than an EXE-bound source-rect table.
"""
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"

FIXED_GRID_ASSETS = {
    "face_01": "80x80 portrait grid; dialogue face selector uses portrait ids/slots, not a descriptor +4 rect table.",
    "icon": "32x32 command/menu icon grid.",
    "icon_s": "32x32 small icon grid candidate/review sheet.",
    "item": "32x32 item/equipment icon grid.",
    "num": "16x16 digit glyph grid, rows are white/green/yellow/red 0-9.",
    "01234567": "review-only small digit/text strip; no promoted EXE rect table.",
}

SUPPRESSED_UNBOUND_CANDIDATES = {
    "aaa": "Full-screen 640x480 splash. The nearby unbound rect candidate is the logo_00 two-part title rect table, not an aaa.cns descriptor binding.",
    "compile": "Full-screen 640x480 splash. The nearby unbound rect candidate is the logo_00 two-part title rect table, not a compile.cns descriptor binding.",
}

CATEGORY_LABELS = {
    "battle": "전투/효과",
    "character": "필드 캐릭터",
    "enemy-object": "몬스터/오브젝트",
    "face": "초상화",
    "ui-misc": "UI/문구",
}


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


def h(value: Any) -> str:
    return html.escape(str(value), quote=True)


def table_brief(table: dict[str, Any]) -> dict[str, Any]:
    return {
        "tableStartVaHex": table.get("tableStartVaHex"),
        "frameCount": table.get("frameCount"),
        "schema": table.get("schema"),
        "stride": table.get("stride"),
        "fieldLabel": table.get("fieldLabel"),
        "fieldPlus4Bound": table.get("fieldPlus4Bound") is True,
        "boundaryKind": table.get("boundaryKind"),
        "hasOffset2": table.get("hasOffset2") is True,
        "rectSample": table.get("rectSample") or (table.get("rects") or [])[:4],
    }


def collect_window_catalog() -> dict[str, Any]:
    hud = load_json(OUT / "hud_menu_preview.json", {})
    menu = load_json(OUT / "menu_descriptor_stack_review.json", {})
    dialogue = load_json(OUT / "dialogue_template_text_face_review.json", {})

    templates_by_index: dict[int, dict[str, Any]] = {}
    for source_name, data in (("hud", hud), ("dialogue", dialogue)):
        for template in data.get("templates") or []:
            index = int(template.get("index", -1))
            if index < 0:
                continue
            templates_by_index.setdefault(
                index,
                {
                    "index": index,
                    "widthTiles": template.get("widthTiles", template.get("width_tiles")),
                    "heightTiles": template.get("heightTiles", template.get("height_tiles")),
                    "templateVaHex": template.get("templateVaHex")
                    or (f"0x{int(template['template_va']):08x}" if template.get("template_va") is not None else None),
                    "sources": [],
                },
            )["sources"].append(source_name)

    region_sources = []
    for source_name, data, keys in (
        ("hud", hud, ("regions",)),
        ("dialogue", dialogue, ("dialogueRegions", "relatedRegions")),
    ):
        for key in keys:
            for region in data.get(key) or []:
                item = {
                    "source": source_name,
                    "index": region.get("index"),
                    "rect": region.get("rect"),
                    "resourceIdHex": region.get("resourceIdHex")
                    or region.get("resource_id_hex"),
                    "templateIndex": region.get("templateIndex")
                    or (region.get("template") or {}).get("index"),
                    "role": region.get("role"),
                }
                region_sources.append(item)

    return {
        "mode": "window-template-region-catalog",
        "status": "grounded",
        "asset": "window",
        "cns": "window.cns",
        "description": "window.cns는 EXE-bound source rect table이 아니라 16x16 cell ids를 조합한 template과 screen region id로 재사용된다.",
        "templateCount": len(templates_by_index),
        "regionCount": len(region_sources),
        "templates": sorted(templates_by_index.values(), key=lambda item: item["index"]),
        "regions": region_sources,
        "menuDescriptorEvidence": {
            "status": menu.get("status"),
            "regionDrawCallSites": menu.get("regionDrawCallSites") or [],
            "contextRegionInitializerCount": len(menu.get("contextRegionInitializers") or []),
            "summary": menu.get("summary") or {},
        },
    }


def classify_asset(row: dict[str, Any]) -> dict[str, Any]:
    asset = row.get("asset")
    tables = [table for table in (row.get("tables") or []) if table.get("fieldPlus4Bound") is True]
    best = row.get("bestTable") or {}
    candidate_tables = row.get("candidateTables") or []
    category = row.get("category") or ""

    if asset == "window":
        return {
            "asset": asset,
            "cns": row.get("cns"),
            "category": category,
            "categoryLabel": CATEGORY_LABELS.get(category, category),
            "imageSize": row.get("imageSize"),
            "mode": "window-template-region-catalog",
            "status": "grounded",
            "tableCount": 0,
            "frameCount": None,
            "evidence": "window templates are grounded separately through resource group/template/region records.",
            "tables": [],
            "candidateTableCount": len(candidate_tables),
            "candidateNote": "Nearby unbound rect-like tables are not promoted for window.cns.",
        }

    if tables:
        table_count = len(tables)
        frame_count = sum(int(table.get("frameCount") or 0) for table in tables)
        schema_counts = Counter(str(table.get("schema")) for table in tables)
        return {
            "asset": asset,
            "cns": row.get("cns"),
            "category": category,
            "categoryLabel": CATEGORY_LABELS.get(category, category),
            "imageSize": row.get("imageSize"),
            "mode": "descriptor-plus4-source-rect-catalog",
            "status": "grounded",
            "tableCount": table_count,
            "frameCount": frame_count,
            "bestTableStartVaHex": best.get("tableStartVaHex"),
            "bestFrameCount": best.get("frameCount"),
            "schemaCounts": dict(schema_counts),
            "hasOffset2": any(table.get("hasOffset2") for table in tables),
            "evidence": "CNS descriptor field +4 points to one or more source-rect tables; later scripts can consume frame ids instead of raw x/y/w/h.",
            "tables": [table_brief(table) for table in tables],
            "candidateTableCount": len(candidate_tables),
        }

    if asset in FIXED_GRID_ASSETS:
        return {
            "asset": asset,
            "cns": row.get("cns"),
            "category": category,
            "categoryLabel": CATEGORY_LABELS.get(category, category),
            "imageSize": row.get("imageSize"),
            "mode": "fixed-grid-sheet",
            "status": "confirmed-grid" if asset in {"face_01", "icon", "item", "num"} else "review-grid",
            "tableCount": 0,
            "frameCount": None,
            "evidence": FIXED_GRID_ASSETS[asset],
            "tables": [],
            "candidateTableCount": len(candidate_tables),
        }

    if asset in SUPPRESSED_UNBOUND_CANDIDATES:
        return {
            "asset": asset,
            "cns": row.get("cns"),
            "category": category,
            "categoryLabel": CATEGORY_LABELS.get(category, category),
            "imageSize": row.get("imageSize"),
            "mode": "no-precut-evidence",
            "status": "none",
            "tableCount": 0,
            "frameCount": None,
            "evidence": SUPPRESSED_UNBOUND_CANDIDATES[asset],
            "tables": [],
            "candidateTableCount": 0,
            "suppressedCandidateTableCount": len(candidate_tables),
        }

    if candidate_tables:
        return {
            "asset": asset,
            "cns": row.get("cns"),
            "category": category,
            "categoryLabel": CATEGORY_LABELS.get(category, category),
            "imageSize": row.get("imageSize"),
            "mode": "unbound-rect-candidate",
            "status": "candidate",
            "tableCount": 0,
            "frameCount": None,
            "evidence": "Rect-like tables exist near this CNS reference, but descriptor +4 binding was not proven.",
            "tables": [],
            "candidateTableCount": len(candidate_tables),
            "candidateTables": [table_brief(table) for table in candidate_tables[:4]],
        }

    return {
        "asset": asset,
        "cns": row.get("cns"),
        "category": category,
        "categoryLabel": CATEGORY_LABELS.get(category, category),
        "imageSize": row.get("imageSize"),
        "mode": "no-precut-evidence",
        "status": "none",
        "tableCount": 0,
        "frameCount": None,
        "evidence": "No descriptor-bound source-rect table, fixed grid, or window-template evidence currently promoted.",
        "tables": [],
        "candidateTableCount": len(candidate_tables),
    }


def build() -> dict[str, Any]:
    scan = load_json(OUT / "cns_frame_rect_exe_scan.json", {})
    rows = [classify_asset(row) for row in scan.get("rows") or []]
    rows_by_mode = Counter(row["mode"] for row in rows)
    rows_by_status = Counter(row["status"] for row in rows)
    descriptor_rows = [row for row in rows if row["mode"] == "descriptor-plus4-source-rect-catalog"]
    descriptor_table_count = sum(int(row.get("tableCount") or 0) for row in descriptor_rows)
    descriptor_frame_count = sum(int(row.get("frameCount") or 0) for row in descriptor_rows)
    category_descriptor_counts = Counter(row["category"] for row in descriptor_rows)
    window_catalog = collect_window_catalog()

    conclusions = [
        "Most battle/player/monster/field-character CNS assets are not raw x,y,w,h consumers at every draw site: the EXE has descriptor +4 source-rect tables that serve as frame catalogs.",
        "window.cns is a separate reusable-template system: 16x16 cell ids are assembled into templates and then placed by region/resource ids.",
        "face_01/icon/item/num currently remain grid-sheet consumers rather than descriptor +4 rect catalogs.",
        "Unbound candidate tables should not be promoted unless descriptor binding or a consumer is found; window.cns/aaa/compile near-rect candidates are known false-positive shapes.",
    ]

    return {
        "version": 1,
        "kind": "cns-precut-catalog-review",
        "status": "partial-with-grounded-catalogs",
        "sourceArtifacts": [
            "out/cns_frame_rect_exe_scan.json",
            "out/hud_menu_preview.json",
            "out/menu_descriptor_stack_review.json",
            "out/dialogue_template_text_face_review.json",
        ],
        "summary": {
            "assetRows": len(rows),
            "descriptorPlus4Assets": len(descriptor_rows),
            "descriptorPlus4Tables": descriptor_table_count,
            "descriptorPlus4Frames": descriptor_frame_count,
            "windowTemplateCount": window_catalog["templateCount"],
            "windowRegionCount": window_catalog["regionCount"],
            "fixedGridAssets": sum(1 for row in rows if row["mode"] == "fixed-grid-sheet"),
            "unboundCandidateAssets": sum(1 for row in rows if row["mode"] == "unbound-rect-candidate"),
            "modeCounts": dict(rows_by_mode),
            "statusCounts": dict(rows_by_status),
            "descriptorCategoryCounts": dict(category_descriptor_counts),
        },
        "windowCatalog": window_catalog,
        "rows": sorted(rows, key=lambda row: (row["mode"], row["category"], row["asset"] or "")),
        "conclusions": conclusions,
    }


def render_html(data: dict[str, Any]) -> str:
    summary = data["summary"]
    rows = data["rows"]
    cards = "".join(
        f"<div class='card'><b>{h(key)}</b><span>{h(value)}</span></div>"
        for key, value in summary.items()
        if not isinstance(value, dict)
    )
    mode_rows = "".join(
        f"<tr><td>{h(key)}</td><td>{h(value)}</td></tr>"
        for key, value in summary["modeCounts"].items()
    )
    table_rows = []
    for row in rows:
        tables = row.get("tables") or []
        first_tables = "<br>".join(
            f"{h(table.get('tableStartVaHex'))} · {h(table.get('frameCount'))} frames · {h(table.get('schema'))}"
            for table in tables[:3]
        )
        if len(tables) > 3:
            first_tables += f"<br>... +{len(tables) - 3}"
        if not first_tables and row.get("candidateTables"):
            first_tables = "candidate " + h(row.get("candidateTableCount"))
        table_rows.append(
            "<tr>"
            f"<td><b>{h(row.get('asset'))}</b><br><small>{h(row.get('cns'))} · {h(row.get('imageSize'))}</small></td>"
            f"<td>{h(row.get('categoryLabel'))}</td>"
            f"<td><code>{h(row.get('mode'))}</code><br><small>{h(row.get('status'))}</small></td>"
            f"<td>{h(row.get('tableCount'))}</td>"
            f"<td>{h(row.get('frameCount') or '')}</td>"
            f"<td>{first_tables}</td>"
            f"<td>{h(row.get('evidence'))}</td>"
            "</tr>"
        )
    conclusion_items = "".join(f"<li>{h(item)}</li>" for item in data["conclusions"])
    window = data["windowCatalog"]
    window_templates = "".join(
        "<tr>"
        f"<td>{h(item.get('index'))}</td>"
        f"<td>{h(item.get('widthTiles'))}x{h(item.get('heightTiles'))}</td>"
        f"<td>{h(item.get('templateVaHex'))}</td>"
        f"<td>{h(', '.join(item.get('sources') or []))}</td>"
        "</tr>"
        for item in window.get("templates") or []
    )
    return f"""<!doctype html>
<meta charset="utf-8">
<title>CNS Pre-cut Catalog Review</title>
<style>
  body {{ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 24px; background: #f7f7f4; color: #20201d; }}
  h1 {{ margin-bottom: 6px; }}
  .muted {{ color: #666; }}
  .cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin: 16px 0; }}
  .card {{ background: #fff; border: 1px solid #d8d5c9; border-radius: 6px; padding: 10px; }}
  .card b {{ display: block; font-size: 12px; color: #666; }}
  .card span {{ font-size: 20px; }}
  table {{ border-collapse: collapse; width: 100%; background: #fff; margin: 14px 0 26px; }}
  th, td {{ border: 1px solid #d8d5c9; padding: 7px 8px; vertical-align: top; }}
  th {{ background: #ece8dd; text-align: left; position: sticky; top: 0; }}
  code {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
  small {{ color: #666; }}
  .scroll {{ overflow: auto; border: 1px solid #d8d5c9; }}
</style>
<h1>CNS Pre-cut Catalog Review</h1>
<p class="muted">EXE 정적 분석 기준으로 CNS가 raw x/y/w/h 직접 참조인지, 초기화된 source-rect/frame catalog인지, window template/region인지 분리한 리뷰.</p>
<div class="cards">{cards}</div>
<h2>결론</h2>
<ul>{conclusion_items}</ul>
<h2>Mode Counts</h2>
<table><tbody>{mode_rows}</tbody></table>
<h2>window.cns Template Catalog</h2>
<p>{h(window.get('description'))}</p>
<table>
  <thead><tr><th>template</th><th>tiles</th><th>VA</th><th>source</th></tr></thead>
  <tbody>{window_templates}</tbody>
</table>
<h2>All CNS Access Modes</h2>
<div class="scroll">
<table>
  <thead><tr><th>asset</th><th>category</th><th>mode</th><th>tables</th><th>frames</th><th>evidence tables</th><th>note</th></tr></thead>
  <tbody>{''.join(table_rows)}</tbody>
</table>
</div>
"""


def main() -> None:
    OUT.mkdir(exist_ok=True)
    data = build()
    (OUT / "cns_precut_catalog_review.json").write_text(
        json.dumps(data, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    (OUT / "cns_precut_catalog_review.html").write_text(render_html(data), encoding="utf-8")
    print(
        "wrote out/cns_precut_catalog_review.json and .html "
        f"({data['summary']['descriptorPlus4Assets']} descriptor assets)"
    )


if __name__ == "__main__":
    main()
