#!/usr/bin/env python3
"""Build compact data for the UI window/frame CNS assembly review."""
from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Any

from decode_cns import decompress_cns, parse_image


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

PREFERRED_TARGET_ASSETS = [
    "window",
    "frame",
    "status",
    "num",
    "icon",
    "icon_s",
    "item",
    "btl_etc",
    "face_01",
    "cara_fnt",
    "logo_00",
    "title",
    "compile",
    "aaa",
]

ALWAYS_INCLUDE_ASSETS = {
    "btl_etc",
    "face_01",
    "cara_fnt",
}

RECT_REVIEW_ASSETS = {
    "window",
    "status",
    "btl_etc",
    "face_01",
    "cara_fnt",
    "logo_00",
}

FULL_SCREEN_ASSETS = {
    "aaa",
    "compile",
    "title",
}

GRID_CANDIDATES = {
    "window": [
        {"label": "16x16 확정 셀", "cellWidth": 16, "cellHeight": 16, "status": "confirmed-grid"},
    ],
    "frame": [{"label": "640x352 전체 프레임", "cellWidth": 0, "cellHeight": 0, "status": "confirmed-full-frame"}],
    "status": [
        {"label": "EXE rects", "cellWidth": 0, "cellHeight": 0, "status": "exe-rect"},
        {"label": "16x16", "cellWidth": 16, "cellHeight": 16, "status": "review"},
        {"label": "32x16", "cellWidth": 32, "cellHeight": 16, "status": "review"},
    ],
    "num": [
        {"label": "16x16 확정 0-9 x 4색", "cellWidth": 16, "cellHeight": 16, "status": "confirmed-glyph-grid"},
    ],
    "icon": [{"label": "32x32", "cellWidth": 32, "cellHeight": 32, "status": "confirmed-grid"}],
    "icon_s": [{"label": "32x32", "cellWidth": 32, "cellHeight": 32, "status": "review"}],
    "item": [{"label": "32x32", "cellWidth": 32, "cellHeight": 32, "status": "confirmed-grid"}],
    "btl_etc": [
        {"label": "EXE rects", "cellWidth": 0, "cellHeight": 0, "status": "exe-rect"},
        {"label": "16x16", "cellWidth": 16, "cellHeight": 16, "status": "review"},
    ],
    "face_01": [
        {"label": "80x80 초상화 grid", "cellWidth": 80, "cellHeight": 80, "status": "confirmed-grid"},
        {"label": "48x64 EXE 후보", "cellWidth": 48, "cellHeight": 64, "status": "candidate"},
    ],
    "01234567": [
        {"label": "11x16 후보", "cellWidth": 11, "cellHeight": 16, "status": "candidate"},
        {"label": "8x16 후보", "cellWidth": 8, "cellHeight": 16, "status": "candidate"},
    ],
    "cara_fnt": [
        {"label": "144x48 EXE", "cellWidth": 144, "cellHeight": 48, "status": "exe-rect"},
        {"label": "64x48", "cellWidth": 64, "cellHeight": 48, "status": "review"},
    ],
    "logo_00": [{"label": "EXE rects", "cellWidth": 0, "cellHeight": 0, "status": "exe-rect"}],
    "title": [{"label": "640x480 전체 화면", "cellWidth": 0, "cellHeight": 0, "status": "confirmed-full-screen"}],
    "compile": [{"label": "640x480 전체 화면", "cellWidth": 0, "cellHeight": 0, "status": "confirmed-full-screen"}],
    "aaa": [{"label": "640x480 전체 화면", "cellWidth": 0, "cellHeight": 0, "status": "confirmed-full-screen"}],
}

NINE_SLICE_CANDIDATES = {
    "window": [
        {
            "id": "window_0_0_48_border16",
            "label": "window 0,0 48x48 / border 16",
            "source": {"x": 0, "y": 0, "w": 48, "h": 48},
            "border": {"left": 16, "top": 16, "right": 16, "bottom": 16},
            "status": "candidate",
        },
        {
            "id": "window_0_0_64_border16",
            "label": "window 0,0 64x64 / border 16",
            "source": {"x": 0, "y": 0, "w": 64, "h": 64},
            "border": {"left": 16, "top": 16, "right": 16, "bottom": 16},
            "status": "candidate",
        },
        {
            "id": "window_96_0_64_48_border16",
            "label": "window 96,0 64x48 / border 16",
            "source": {"x": 96, "y": 0, "w": 64, "h": 48},
            "border": {"left": 16, "top": 16, "right": 16, "bottom": 16},
            "status": "candidate",
        },
        {
            "id": "window_160_0_96_32_border16",
            "label": "window 160,0 96x32 / border 16",
            "source": {"x": 160, "y": 0, "w": 96, "h": 32},
            "border": {"left": 16, "top": 16, "right": 16, "bottom": 16},
            "status": "candidate",
        },
    ],
}

ASSET_NOTES = {
    "window": "창/대화상자 조립용 16x16 셀 sheet. 전체 18열x4행 셀 번호를 조합해 프레임을 만든다.",
    "frame": "게임 화면을 둘러싸는 640x352 단일 프레임 오버레이. 테두리를 제외한 내부는 투명이며 나누거나 9-slice로 조립하지 않는다.",
    "status": "0x0047e3e0의 23개 EXE rect 후보가 잡히지만 실제 status.cns 소비처로 확정하지 않은 UI 조각 sheet.",
    "num": "숫자 glyph sheet. 16x16 셀 10열x4행이며 각 행은 흰색/초록색/노란색/빨간색 0-9로 확정한다.",
    "icon": "item/skill command 쪽 32x32 아이콘 grid는 기존 ui_grid_review에서 확정된 영역.",
    "icon_s": "작은 아이콘 sheet. 32x32 단위로 우선 확인한다.",
    "item": "장비/아이템 32x32 grid는 기존 ui_grid_review에서 확정된 영역.",
    "btl_etc": "전투 숫자/MISS/HIT/표시 조각 sheet. EXE rect table을 우선 적용한다.",
    "face_01": "얼굴 초상화 sheet. 80x80 grid 16칸을 기본으로 보고 EXE 후보 rect는 근거로만 남긴다.",
    "cara_fnt": "캐릭터 이름/표기용 후보. EXE rect table은 144x48 중심으로 잡힌다.",
    "logo_00": "오프닝 로고 조각. EXE rect 2개(E0/E1)를 우선 적용한다.",
    "title": "타이틀 전체 화면 이미지. 640x480 단일 화면으로 표시한다.",
    "compile": "COMPILE 로고 전체 화면 이미지. 640x480 단일 화면으로 표시한다.",
    "aaa": "AAA 로고 전체 화면 이미지. 640x480 단일 화면으로 표시한다.",
    "01234567": "숫자/문구 계열 작은 UI sheet. EXE rect 근거가 없어 전체 이미지로 보고 11x16/8x16은 후보로만 검토한다.",
    "ds14_a": "엔딩/타이틀 계열 전체 화면 UI 이미지 후보.",
    "ds14_b": "엔딩/타이틀 계열 전체 화면 UI 이미지 후보.",
    "ds14_ed": "엔딩/타이틀 계열 전체 화면 UI 이미지 후보.",
    "ed": "엔딩 전체 화면 UI 이미지 후보.",
    "ed_compi": "엔딩 크레딧/문구 계열 작은 UI strip.",
    "p_sp": "16x16 UI 조각 후보.",
    "p_wb": "16x16 UI 조각 후보.",
    "spot": "spot 표시/문구 계열 UI 이미지 후보.",
    "sun": "전체 화면 UI/연출 이미지 후보.",
}

NUM_GLYPH_ROWS = [
    ("white", "흰색"),
    ("green", "초록색"),
    ("yellow", "노란색"),
    ("red", "빨간색"),
]


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


def target_assets(frame_assets: dict) -> list[str]:
    targets = list(PREFERRED_TARGET_ASSETS)
    seen = set(targets)
    for row in frame_assets.get("rows") or []:
        asset = row.get("assetKey")
        if not asset or asset in seen:
            continue
        if row.get("category") == "ui-misc" or asset in ALWAYS_INCLUDE_ASSETS:
            targets.append(asset)
            seen.add(asset)
    for asset in sorted(ALWAYS_INCLUDE_ASSETS):
        if asset not in seen:
            targets.append(asset)
            seen.add(asset)
    return targets


def compact_table(table: dict | None) -> dict | None:
    if not table:
        return None
    rects = table.get("rects") or []
    return {
        "schema": table.get("schema"),
        "stride": table.get("stride"),
        "frameCount": table.get("frameCount"),
        "tableStartVaHex": table.get("tableStartVaHex"),
        "fieldLabel": table.get("fieldLabel"),
        "fieldPlus4Bound": table.get("fieldPlus4Bound"),
        "boundaryKind": table.get("boundaryKind"),
        "matchesAutoCountSet": table.get("matchesAutoCountSet"),
        "rects": [
            {"x": int(rect["x"]), "y": int(rect["y"]), "w": int(rect["w"]), "h": int(rect["h"])}
            for rect in rects
            if all(key in rect for key in ("x", "y", "w", "h"))
        ],
    }


def palette0_rgb(asset: str) -> list[int] | None:
    path = EXTRACT_FLD / f"{asset}.cns"
    if not path.exists():
        return None
    try:
        _width, _height, palette, _pixels, _bpp = parse_image(decompress_cns(path.read_bytes()))
    except Exception:
        return None
    if not palette:
        return None
    return [int(value) for value in palette[0]]


def num_glyph_map() -> list[dict[str, Any]]:
    glyphs = []
    for row_index, (color_key, color_name) in enumerate(NUM_GLYPH_ROWS):
        for digit in range(10):
            glyphs.append(
                {
                    "index": row_index * 10 + digit,
                    "digit": digit,
                    "colorKey": color_key,
                    "colorName": color_name,
                    "label": f"{color_name} {digit}",
                    "overlayLabel": str(digit),
                    "sourceLabel": "confirmed 16x16 glyph",
                    "x": digit * 16,
                    "y": row_index * 16,
                    "w": 16,
                    "h": 16,
                }
            )
    return glyphs


def nontransparent_cell_map(asset: str, cell_width: int = 16, cell_height: int = 16) -> list[dict[str, Any]]:
    path = EXTRACT_FLD / f"{asset}.cns"
    if not path.exists():
        return []
    try:
        width, height, _palette, pixels, _bpp = parse_image(decompress_cns(path.read_bytes()))
    except Exception:
        return []
    cells = []
    columns = width // cell_width
    rows = height // cell_height
    for row_index in range(rows):
        for column_index in range(columns):
            x0 = column_index * cell_width
            y0 = row_index * cell_height
            nontransparent = 0
            for y in range(y0, min(y0 + cell_height, height)):
                start = y * width + x0
                end = start + min(cell_width, width - x0)
                nontransparent += sum(1 for value in pixels[start:end] if value != 0)
            if not nontransparent:
                continue
            index = row_index * columns + column_index
            cells.append(
                {
                    "index": index,
                    "column": column_index,
                    "row": row_index,
                    "label": f"r{row_index} c{column_index}",
                    "overlayLabel": str(index),
                    "sourceLabel": "review 16x16 cell",
                    "x": x0,
                    "y": y0,
                    "w": cell_width,
                    "h": cell_height,
                    "nontransparentPixels": nontransparent,
                }
            )
    return cells


def full_cell_map(asset: str, cell_width: int = 16, cell_height: int = 16) -> list[dict[str, Any]]:
    path = EXTRACT_FLD / f"{asset}.cns"
    if not path.exists():
        return []
    try:
        width, height, _palette, pixels, _bpp = parse_image(decompress_cns(path.read_bytes()))
    except Exception:
        return []
    cells = []
    columns = width // cell_width
    rows = height // cell_height
    for row_index in range(rows):
        for column_index in range(columns):
            x0 = column_index * cell_width
            y0 = row_index * cell_height
            nontransparent = 0
            for y in range(y0, min(y0 + cell_height, height)):
                start = y * width + x0
                end = start + min(cell_width, width - x0)
                nontransparent += sum(1 for value in pixels[start:end] if value != 0)
            index = row_index * columns + column_index
            cells.append(
                {
                    "index": index,
                    "column": column_index,
                    "row": row_index,
                    "label": f"#{index}",
                    "overlayLabel": f"#{index}",
                    "sourceLabel": "confirmed window 16x16 cell",
                    "x": x0,
                    "y": y0,
                    "w": cell_width,
                    "h": cell_height,
                    "nontransparentPixels": nontransparent,
                    "empty": nontransparent == 0,
                }
            )
    return cells


def sheet_cell_map(
    asset: str,
    cell_width: int,
    cell_height: int,
    *,
    include_empty: bool,
    source_label: str,
) -> list[dict[str, Any]]:
    path = EXTRACT_FLD / f"{asset}.cns"
    if not path.exists():
        return []
    try:
        width, height, _palette, pixels, _bpp = parse_image(decompress_cns(path.read_bytes()))
    except Exception:
        return []
    cells = []
    columns = width // cell_width
    rows = height // cell_height
    for row_index in range(rows):
        for column_index in range(columns):
            x0 = column_index * cell_width
            y0 = row_index * cell_height
            nontransparent = 0
            for y in range(y0, min(y0 + cell_height, height)):
                start = y * width + x0
                end = start + min(cell_width, width - x0)
                nontransparent += sum(1 for value in pixels[start:end] if value != 0)
            if not include_empty and not nontransparent:
                continue
            index = row_index * columns + column_index
            cells.append(
                {
                    "index": index,
                    "column": column_index,
                    "row": row_index,
                    "label": f"#{index}",
                    "overlayLabel": f"#{index}",
                    "sourceLabel": source_label,
                    "x": x0,
                    "y": y0,
                    "w": cell_width,
                    "h": cell_height,
                    "nontransparentPixels": nontransparent,
                    "empty": nontransparent == 0,
                }
            )
    return cells


def full_frame_meta(asset: str) -> dict[str, Any] | None:
    path = EXTRACT_FLD / f"{asset}.cns"
    if not path.exists():
        return None
    try:
        width, height, _palette, pixels, _bpp = parse_image(decompress_cns(path.read_bytes()))
    except Exception:
        return None
    nontransparent = sum(1 for value in pixels if value != 0)
    total = width * height
    return {
        "label": "전체 화면 프레임",
        "overlayLabel": "full",
        "sourceLabel": "confirmed full-frame overlay",
        "x": 0,
        "y": 0,
        "w": width,
        "h": height,
        "nontransparentPixels": nontransparent,
        "totalPixels": total,
        "coveragePercent": round((nontransparent / total) * 100, 2) if total else 0,
    }


def relabel_full_image(meta: dict[str, Any] | None, *, label: str, source_label: str) -> dict[str, Any] | None:
    if not meta:
        return None
    meta = dict(meta)
    meta["label"] = label
    meta["sourceLabel"] = source_label
    return meta


def source_for_asset(asset: str, frame_assets: dict, rect_scan: dict) -> dict:
    frame_rows = {row.get("assetKey"): row for row in frame_assets.get("rows") or []}
    scan_rows = {row.get("asset"): row for row in rect_scan.get("rows") or []}
    frame_row = frame_rows.get(asset) or {}
    scan_row = scan_rows.get(asset) or {}
    best_table = compact_table(scan_row.get("bestTable")) if asset in RECT_REVIEW_ASSETS else None
    candidate_tables = [
        compact_table(row)
        for row in ((scan_row.get("candidateTables") or [])[:4] if asset in RECT_REVIEW_ASSETS else [])
    ]
    candidate_tables = [row for row in candidate_tables if row]
    selected = best_table or (candidate_tables[0] if candidate_tables else None)
    selected_status = "none"
    if best_table:
        selected_status = "best-exe-rect"
    elif candidate_tables:
        selected_status = "candidate-exe-rect"
    source = {
        "asset": asset,
        "cns": frame_row.get("cns") or f"{asset}.cns",
        "path": frame_row.get("path") or f"../extract_fld/{asset}.cns",
        "width": frame_row.get("width") or scan_row.get("width"),
        "height": frame_row.get("height") or scan_row.get("height"),
        "bpp": frame_row.get("bpp"),
        "paletteColors": frame_row.get("paletteColors"),
        "transparentRgb": palette0_rgb(asset),
        "transparentSource": "cns-palette-index-0",
        "note": ASSET_NOTES.get(asset, ""),
        "frameHint": frame_row.get("frameHint") or {},
        "gridCandidates": GRID_CANDIDATES.get(asset) or [],
        "bestTable": best_table,
        "candidateTables": candidate_tables,
        "selectedRectSource": selected_status,
        "rectTable": selected,
        "nineSliceCandidates": NINE_SLICE_CANDIDATES.get(asset) or [],
    }
    if asset == "num":
        source.update(
            {
                "selectedRectSource": "confirmed-glyph-grid",
                "bestTable": None,
                "candidateTables": [],
                "rectTable": None,
                "glyphMap": num_glyph_map(),
                "frameHint": {
                    "sourceX": 0,
                    "sourceY": 0,
                    "sourceWidth": 16,
                    "sourceHeight": 16,
                    "columns": 10,
                    "rows": 4,
                    "frameCount": 40,
                    "order": "rows white,green,yellow,red; columns digit 0-9",
                    "inference": "manual-confirmed-num-glyph-grid",
                    "confidence": "confirmed",
                },
            }
        )
    elif asset == "window":
        cell_map = full_cell_map(asset, 16, 16)
        source.update(
            {
                "selectedRectSource": "confirmed-16x16-cell-grid",
                "bestTable": None,
                "rectTable": None,
                "nineSliceCandidates": [],
                "cellMap": cell_map,
                "frameHint": {
                    "sourceX": 0,
                    "sourceY": 0,
                    "sourceWidth": 16,
                    "sourceHeight": 16,
                    "columns": int((source.get("width") or 0) // 16),
                    "rows": int((source.get("height") or 0) // 16),
                    "frameCount": len(cell_map),
                    "order": "row-major 0-based cell indices",
                    "inference": "manual-confirmed-window-cell-grid",
                    "confidence": "confirmed",
                    "note": "Every 16x16 cell is exposed, including transparent cells, so manual frame recipes can reference stable cell numbers.",
                },
            }
        )
    elif asset == "frame":
        full_frame = full_frame_meta(asset)
        source.update(
            {
                "selectedRectSource": "confirmed-full-frame",
                "bestTable": None,
                "candidateTables": [],
                "rectTable": None,
                "nineSliceCandidates": [],
                "fullFrame": full_frame,
                "frameHint": {
                    "sourceX": 0,
                    "sourceY": 0,
                    "sourceWidth": source.get("width"),
                    "sourceHeight": source.get("height"),
                    "columns": 1,
                    "rows": 1,
                    "frameCount": 1,
                    "inference": "manual-confirmed-full-frame-overlay",
                    "confidence": "confirmed",
                    "note": "A single transparent overlay that surrounds the game screen.",
                },
            }
        )
    elif asset == "status":
        full_frame = relabel_full_image(
            full_frame_meta(asset),
            label="전체 이미지",
            source_label="review whole-image UI",
        )
        source.update(
            {
                "selectedRectSource": "review-full-image",
                "rectTable": None,
                "nineSliceCandidates": [],
                "fullFrame": full_frame,
                "frameHint": {
                    "sourceX": 0,
                    "sourceY": 0,
                    "sourceWidth": source.get("width"),
                    "sourceHeight": source.get("height"),
                    "columns": 1,
                    "rows": 1,
                    "frameCount": 1,
                    "inference": "manual-review-whole-status-image",
                    "confidence": "review",
                    "note": "The 0x0047e3e0 EXE rect table is retained in candidateTables only; it is not promoted until a draw-call/consumer match is proven.",
                },
            }
        )
    elif asset == "face_01":
        cell_map = sheet_cell_map(
            asset,
            80,
            80,
            include_empty=True,
            source_label="confirmed 80x80 face cell",
        )
        source.update(
            {
                "selectedRectSource": "confirmed-80x80-face-grid",
                "rectTable": None,
                "cellMap": cell_map,
                "frameHint": {
                    "sourceX": 0,
                    "sourceY": 0,
                    "sourceWidth": 80,
                    "sourceHeight": 80,
                    "columns": int((source.get("width") or 0) // 80),
                    "rows": int((source.get("height") or 0) // 80),
                    "frameCount": len(cell_map),
                    "order": "row-major 0-based portrait cells",
                    "inference": "manual-confirmed-face-80x80-grid",
                    "confidence": "confirmed",
                    "note": "Prompt portraits are laid out as 80x80 cells; EXE 48x64 rect candidates are kept only as unpromoted evidence.",
                },
            }
        )
    elif asset == "01234567":
        full_frame = relabel_full_image(
            full_frame_meta(asset),
            label="전체 이미지",
            source_label="review whole-image UI",
        )
        source.update(
            {
                "selectedRectSource": "review-full-image",
                "bestTable": None,
                "candidateTables": [],
                "rectTable": None,
                "nineSliceCandidates": [],
                "fullFrame": full_frame,
                "frameHint": {
                    "sourceX": 0,
                    "sourceY": 0,
                    "sourceWidth": source.get("width"),
                    "sourceHeight": source.get("height"),
                    "columns": 1,
                    "rows": 1,
                    "frameCount": 1,
                    "inference": "manual-review-whole-01234567-image",
                    "confidence": "review",
                    "note": "No EXE rect table is promoted for this sheet. 11x16/8x16 remain visual grid candidates only.",
                },
            }
        )
    elif asset in {"icon", "item", "icon_s"}:
        status = "confirmed-32x32-cell-grid" if asset in {"icon", "item"} else "review-32x32-cell-grid"
        cell_map = sheet_cell_map(
            asset,
            32,
            32,
            include_empty=True,
            source_label=f"{status} cell",
        )
        source.update(
            {
                "selectedRectSource": status,
                "bestTable": None,
                "candidateTables": candidate_tables,
                "rectTable": None,
                "nineSliceCandidates": [],
                "cellMap": cell_map,
                "frameHint": {
                    "sourceX": 0,
                    "sourceY": 0,
                    "sourceWidth": 32,
                    "sourceHeight": 32,
                    "columns": int((source.get("width") or 0) // 32),
                    "rows": int((source.get("height") or 0) // 32),
                    "frameCount": len(cell_map),
                    "order": "row-major 0-based cell indices",
                    "inference": "manual-ui-32x32-cell-grid",
                    "confidence": "confirmed" if asset in {"icon", "item"} else "review",
                    "note": "Cells are exposed here for UI CNS inspection; item/action semantic mappings remain in ui_grid_review.html.",
                },
            }
        )
    elif asset in FULL_SCREEN_ASSETS:
        full_frame = relabel_full_image(
            full_frame_meta(asset),
            label="전체 화면 UI",
            source_label="confirmed full-screen UI image",
        )
        source.update(
            {
                "selectedRectSource": "confirmed-full-screen",
                "bestTable": None,
                "candidateTables": [],
                "rectTable": None,
                "nineSliceCandidates": [],
                "fullFrame": full_frame,
                "frameHint": {
                    "sourceX": 0,
                    "sourceY": 0,
                    "sourceWidth": source.get("width"),
                    "sourceHeight": source.get("height"),
                    "columns": 1,
                    "rows": 1,
                    "frameCount": 1,
                    "inference": "manual-confirmed-full-screen-ui",
                    "confidence": "confirmed",
                    "note": "A single full-screen UI/opening image.",
                },
            }
        )
    elif frame_row.get("category") == "ui-misc" and not selected:
        selected = "confirmed-full-screen" if source.get("width") == 640 and source.get("height") == 480 else "review-full-image"
        full_frame = relabel_full_image(
            full_frame_meta(asset),
            label="전체 화면 UI" if selected == "confirmed-full-screen" else "전체 이미지",
            source_label="confirmed full-screen UI image" if selected == "confirmed-full-screen" else "review whole-image UI",
        )
        source.update(
            {
                "selectedRectSource": selected,
                "bestTable": None,
                "candidateTables": candidate_tables,
                "rectTable": None,
                "nineSliceCandidates": [],
                "fullFrame": full_frame,
                "frameHint": {
                    "sourceX": 0,
                    "sourceY": 0,
                    "sourceWidth": source.get("width"),
                    "sourceHeight": source.get("height"),
                    "columns": 1,
                    "rows": 1,
                    "frameCount": 1,
                    "inference": "manual-review-whole-ui-image",
                    "confidence": "confirmed" if selected == "confirmed-full-screen" else "review",
                    "note": "Whole-image review fallback for UI/misc CNS without a promoted rect or cell map.",
                },
            }
        )
    return source


def build_summary(frame_assets_path: Path, rect_scan_path: Path) -> dict:
    frame_assets = load_json(frame_assets_path, {})
    rect_scan = load_json(rect_scan_path, {})
    assets = [source_for_asset(asset, frame_assets, rect_scan) for asset in target_assets(frame_assets)]
    rect_assets = [
        asset
        for asset in assets
        if asset.get("rectTable") or asset.get("glyphMap") or asset.get("cellMap") or asset.get("fullFrame")
    ]
    return {
        "status": "ui-window-cns-assembly-review",
        "scope": "All ui-misc CNS image sheets plus btl_etc/face_01/cara_fnt review units and EXE rect candidates.",
        "source": [
            str(frame_assets_path.relative_to(ROOT)),
            str(rect_scan_path.relative_to(ROOT)),
        ],
        "assetCount": len(assets),
        "rectAssetCount": len(rect_assets),
        "nineSliceAssetCount": sum(1 for asset in assets if asset.get("nineSliceCandidates")),
        "assets": assets,
        "notes": [
            "This is a review workbench, not a promotion of every UI assembly rule.",
            "item.cns/icon.cns 32x32 semantic mappings remain in ui_grid_review.html; this page also exposes the source cells for visual inspection.",
            "frame.cns is a confirmed full-screen frame overlay, not a sliced assembly sheet.",
            "num.cns is a confirmed 16x16 digit glyph sheet, not an EXE rect candidate table.",
            "status.cns is shown as a whole-image review by default; the 23-entry EXE rect table at 0x0047e3e0 is retained only as unpromoted candidate evidence.",
            "window.cns is now a confirmed 16x16 cell sheet; frame recipes still need manual/EXE draw-call evidence.",
            "face_01.cns defaults to 80x80 portrait cells; 01234567.cns remains whole-image review because no EXE rect consumer has been promoted.",
            "All ui-misc CNS rows from cns_frame_assets.json are included here, including 01234567, ds14_*, ed*, p_*, spot, and sun.",
            "aaa.cns, compile.cns, and title.cns are full-screen UI/opening images; logo_00.cns and btl_etc.cns keep EXE rect evidence.",
        ],
    }


def write_outputs(summary: dict, out_dir: Path) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "ui_window_review_data.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "ui_window_review_data.js").write_text(
        "window.HWANSE_UI_WINDOW_REVIEW_DATA = "
        + json.dumps(summary, ensure_ascii=False, separators=(",", ":"))
        + ";\n",
        encoding="utf-8",
    )


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--frame-assets", type=Path, default=OUT / "cns_frame_assets.json")
    parser.add_argument("--rect-scan", type=Path, default=OUT / "cns_frame_rect_exe_scan.json")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    summary = build_summary(args.frame_assets, args.rect_scan)
    write_outputs(summary, args.out_dir)
    print(
        f"wrote {summary['assetCount']} UI CNS review assets, "
        f"{summary['rectAssetCount']} with rect data -> {args.out_dir / 'ui_window_review_data.js'}"
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
