#!/usr/bin/env python3
"""Build a canonical CNS source-rect review bundle.

The web page should not re-assemble scattered review JSON files at runtime.
This script normalizes the current promoted/selected rect data into one bundle
and keeps unpromoted candidates explicitly separate.
"""
from __future__ import annotations

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


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


GROUP_LABELS = {
    "ui": "UI",
    "monster": "몬스터",
    "character": "캐릭터",
    "battle": "배틀",
}

STATUS_LABELS = {
    "good": "확정/적용",
    "warn": "검토/후보",
    "bad": "미확정",
}

PROMOTED_SINGLE_RECTS = {
    "cara_etc": [
        {
            "x": 416,
            "y": 112,
            "w": 48,
            "h": 48,
            "sourceLabel": "0x00485874 #16",
            "sourceTableVaHex": "0x00485874",
            "sourceRectIndex": 16,
            "confirmation": "manual-tile-26-7-3x3-exe-candidate",
        }
    ],
}


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


def clean_rect(rect: dict[str, Any], index: int | None = None, label: str | None = None) -> dict[str, Any]:
    out = {
        "x": int(rect.get("x", rect.get("sourceX", 0)) or 0),
        "y": int(rect.get("y", rect.get("sourceY", 0)) or 0),
        "w": int(rect.get("w", rect.get("width", 0)) or 0),
        "h": int(rect.get("h", rect.get("height", 0)) or 0),
    }
    if index is not None:
        out["index"] = index
    rect_label = label or rect.get("label") or rect.get("overlayLabel")
    if rect_label is not None:
        out["label"] = str(rect_label)
    for key in ("sourceLabel", "column", "row", "nontransparentPixels", "empty", "offsetX", "offsetY"):
        if key in rect:
            out[key] = rect[key]
    return out


def clean_rects(rects: list[dict[str, Any]] | None, label_prefix: str = "") -> list[dict[str, Any]]:
    result = []
    for index, rect in enumerate(rects or []):
        label = rect.get("label") or rect.get("overlayLabel") or (f"{label_prefix}{index}" if label_prefix else None)
        result.append(clean_rect(rect, index=index, label=label))
    return result


def rect_key(rect: dict[str, Any]) -> tuple[int, int, int, int, Any, Any]:
    return (
        int(rect.get("x", rect.get("sourceX", 0)) or 0),
        int(rect.get("y", rect.get("sourceY", 0)) or 0),
        int(rect.get("w", rect.get("width", 0)) or 0),
        int(rect.get("h", rect.get("height", 0)) or 0),
        rect.get("offsetX"),
        rect.get("offsetY"),
    )


def descriptor_bound_rect_tables(row: dict[str, Any]) -> list[dict[str, Any]]:
    tables = [table for table in row.get("tables") or [] if table.get("fieldPlus4Bound") and table.get("rects")]
    if tables:
        return tables
    table = row.get("bestTable") or {}
    if table.get("fieldPlus4Bound") and table.get("rects"):
        return [table]
    return []


def merged_descriptor_rects(row: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]], int]:
    """Return unique source rects across all descriptor +4 tables for one CNS.

    Some `cara_*.cns` files hold multiple actors or action groups.  The EXE has
    several descriptor-bound source-rect tables for the same CNS, so a single
    best table leaves valid sprites without overlays in the unified review.
    """
    tables = descriptor_bound_rect_tables(row)
    seen: set[tuple[int, int, int, int, Any, Any]] = set()
    merged = []
    raw_entry_count = 0
    for table_index, table in enumerate(tables):
        table_label = table.get("tableStartVaHex") or f"table-{table_index}"
        for frame_index, rect in enumerate(table.get("rects") or []):
            raw_entry_count += 1
            key = rect_key(rect)
            if key in seen:
                continue
            seen.add(key)
            item = clean_rect(rect, index=len(merged), label=f"T{table_index}#{frame_index}")
            item["sourceLabel"] = f"{table_label} #{frame_index}"
            merged.append(item)
    for rect in PROMOTED_SINGLE_RECTS.get(str(row.get("asset") or ""), []):
        key = rect_key(rect)
        if key in seen:
            continue
        seen.add(key)
        item = clean_rect(rect, index=len(merged), label=f"manual#{len(merged)}")
        item["sourceLabel"] = rect.get("sourceLabel")
        item["sourceTableVaHex"] = rect.get("sourceTableVaHex")
        item["sourceRectIndex"] = rect.get("sourceRectIndex")
        item["confirmation"] = rect.get("confirmation")
        merged.append(item)
    return merged, tables, raw_entry_count


def candidate_tables(row: dict[str, Any]) -> list[dict[str, Any]]:
    tables = []
    for index, table in enumerate(row.get("candidateTables") or []):
        rects = clean_rects(table.get("rects") or [], label_prefix=f"C{index}:")
        if not rects:
            continue
        tables.append(
            {
                "index": index,
                "label": table.get("tableStartVaHex") or f"candidate-{index}",
                "schema": table.get("schema"),
                "stride": table.get("stride"),
                "frameCount": table.get("frameCount", len(rects)),
                "tableStartVaHex": table.get("tableStartVaHex"),
                "fieldLabel": table.get("fieldLabel"),
                "fieldPlus4Bound": table.get("fieldPlus4Bound"),
                "boundaryKind": table.get("boundaryKind"),
                "rects": rects,
            }
        )
    return tables


def flattened_candidate_rects(tables: list[dict[str, Any]]) -> list[dict[str, Any]]:
    out = []
    for table in tables:
        for rect in table.get("rects") or []:
            item = dict(rect)
            item["candidateTable"] = table.get("label")
            out.append(item)
    return out


def selected_ui_rects(row: dict[str, Any]) -> tuple[list[dict[str, Any]], str]:
    if isinstance(row.get("glyphMap"), list):
        return clean_rects(row["glyphMap"]), "glyphMap"
    if isinstance(row.get("cellMap"), list):
        return clean_rects(row["cellMap"]), "cellMap"
    rect_table = row.get("rectTable") or {}
    if rect_table.get("rects"):
        return clean_rects(rect_table["rects"]), "rectTable"
    if row.get("fullFrame"):
        return [clean_rect(row["fullFrame"], index=0, label=row["fullFrame"].get("label") or "full")], "fullFrame"
    return [], "none"


def source_status(selected: str) -> str:
    selected = selected or ""
    if selected.startswith("confirmed") or selected == "best-exe-rect":
        return "good"
    if selected == "review-full-image" or "candidate" in selected:
        return "warn"
    return "warn"


def frame_asset_map(frame_assets: dict[str, Any]) -> dict[str, dict[str, Any]]:
    return {row["assetKey"]: row for row in frame_assets.get("rows") or []}


def image_path(asset: str, frame_assets: dict[str, dict[str, Any]]) -> str:
    return frame_assets.get(asset, {}).get("path") or f"../extract_fld/{asset}.cns"


def review_href(group: str, asset: str) -> str:
    if group == "ui":
        return f"ui_window_review.html?asset={asset}"
    if group == "monster":
        return f"monster_review.html?asset={asset}"
    if group == "character":
        return f"field_character_review.html?asset={asset}"
    if group == "battle":
        return f"battle_effect_visual_review.html?asset={asset}"
    return f"cns_rect_review.html?asset={asset}"


def table_summary(table: dict[str, Any] | None) -> dict[str, Any] | None:
    if not table:
        return None
    return {
        "schema": table.get("schema"),
        "stride": table.get("stride"),
        "hasOffset2": table.get("hasOffset2"),
        "frameCount": table.get("frameCount", len(table.get("rects") or [])),
        "tableStartVaHex": table.get("tableStartVaHex"),
        "tableStartFileOffsetHex": table.get("tableStartFileOffsetHex"),
        "fieldLabel": table.get("fieldLabel"),
        "fieldPlus4Bound": table.get("fieldPlus4Bound"),
        "descriptorPlus4PointsToTableStart": table.get("descriptorPlus4PointsToTableStart"),
        "boundaryKind": table.get("boundaryKind"),
        "score": table.get("score"),
    }


def base_row(
    *,
    group: str,
    asset: str,
    cns: str,
    path: str,
    width: int | None,
    height: int | None,
    status: str,
    selected_rect_source: str,
    rects: list[dict[str, Any]],
    candidate_tables_value: list[dict[str, Any]] | None = None,
    source: dict[str, Any] | None = None,
    note: str = "",
) -> dict[str, Any]:
    candidate_tables_value = candidate_tables_value or []
    candidates = flattened_candidate_rects(candidate_tables_value)
    return {
        "group": group,
        "groupLabel": GROUP_LABELS[group],
        "asset": asset,
        "cns": cns,
        "path": path,
        "width": width,
        "height": height,
        "size": f"{width or '-'}x{height or '-'}",
        "status": status,
        "statusLabel": STATUS_LABELS[status],
        "selectedRectSource": selected_rect_source,
        "rects": rects,
        "rectCount": len(rects),
        "candidateTables": candidate_tables_value,
        "candidateRects": candidates,
        "candidateCount": len(candidates),
        "source": source or {},
        "reviewHref": review_href(group, asset),
        "note": note,
    }


def build_ui_rows(ui_window: dict[str, Any]) -> list[dict[str, Any]]:
    rows = []
    for row in ui_window.get("assets") or []:
        asset = row.get("asset")
        if not asset or str(asset).startswith("btl_"):
            continue
        rects, rect_key = selected_ui_rects(row)
        selected = row.get("selectedRectSource") or rect_key
        c_tables = candidate_tables(row)
        status = source_status(selected)
        if status == "good":
            c_tables = []
        source = {
            "kind": "ui-window-canonical",
            "selectedKey": rect_key,
            "rectTable": table_summary(row.get("rectTable") or row.get("bestTable")),
            "confidence": "confirmed" if status == "good" else "review",
        }
        rows.append(
            base_row(
                group="ui",
                asset=asset,
                cns=row.get("cns") or f"{asset}.cns",
                path=row.get("path") or f"../extract_fld/{asset}.cns",
                width=row.get("width"),
                height=row.get("height"),
                status=status,
                selected_rect_source=selected,
                rects=rects,
                candidate_tables_value=c_tables,
                source=source,
                note=row.get("frameHint", {}).get("note") or row.get("note") or "",
            )
        )
    return rows


def build_monster_rows(monster_scan: dict[str, Any], frame_assets: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
    rows = []
    for row in monster_scan.get("localRows") or []:
        asset = row.get("asset")
        table = row.get("bestAdjacentTable") or {}
        rects = clean_rects(table.get("rects") or [])
        target_count = row.get("targetFrameCount")
        table_count = table.get("frameCount", len(rects))
        descriptor_bound = bool(table.get("descriptorPlus4PointsToTableStart"))
        matches = target_count is not None and int(table_count or 0) == int(target_count or 0)
        if rects and descriptor_bound and matches:
            status = "good"
            confidence = "descriptor-bound-target-count-match"
        elif rects:
            status = "warn"
            confidence = "rect-table-present-review"
        else:
            status = "bad"
            confidence = "missing-rect-table"
        source = {
            "kind": "monster-descriptor-plus4-adjacent-table",
            "confidence": confidence,
            "targetFrameCount": target_count,
            "autoComponentCount": row.get("autoComponentCount"),
            "frameCountSource": row.get("frameCountSource"),
            "rectSetSource": row.get("rectSetSource"),
            "descriptorRefVaHex": row.get("descriptorRefVaHex"),
            "table": table_summary(table),
        }
        rows.append(
            base_row(
                group="monster",
                asset=asset,
                cns=row.get("cns") or f"{asset}.cns",
                path=image_path(asset, frame_assets),
                width=int(str(row.get("imageSize") or "0x0").split("x")[0] or 0),
                height=int(str(row.get("imageSize") or "0x0").split("x")[1] or 0),
                status=status,
                selected_rect_source="monster-exe-adjacent-source-rect-table" if rects else "missing",
                rects=rects,
                source=source,
                note=f"target {target_count or '-'} / table {table_count or '-'} / auto {row.get('autoComponentCount') or '-'}",
            )
        )
    return rows


def playable_rects(row: dict[str, Any]) -> list[dict[str, Any]]:
    rects: list[dict[str, Any]] = []
    for direction in row.get("directions") or []:
        for frame in direction.get("frames") or []:
            rects.append(
                clean_rect(
                    {
                        "x": frame.get("sourceX"),
                        "y": frame.get("sourceY"),
                        "w": frame.get("width"),
                        "h": frame.get("height"),
                        "sourceLabel": f"{direction.get('name')} walk",
                    },
                    index=frame.get("index"),
                    label=f"{direction.get('name')}#{frame.get('index')}",
                )
            )
    for index, frame in enumerate(row.get("specialFrames") or []):
        rects.append(
            clean_rect(
                {
                    "x": frame.get("sourceX"),
                    "y": frame.get("sourceY"),
                    "w": row.get("frameWidth"),
                    "h": row.get("frameHeight"),
                    "sourceLabel": "special",
                },
                index=index,
                label=f"special#{index}",
            )
        )
    return rects


def build_character_rows(
    character_frames: list[dict[str, Any]],
    rect_scan: dict[str, Any],
    frame_assets: dict[str, dict[str, Any]],
) -> list[dict[str, Any]]:
    rows = []
    confirmed = set()
    rect_scan_by_asset = {row.get("asset"): row for row in rect_scan.get("rows") or []}
    for row in character_frames:
        asset = str(row.get("file") or "").replace(".cns", "")
        confirmed.add(asset)
        exe_row = rect_scan_by_asset.get(asset) or {}
        exe_rects, exe_tables, raw_entry_count = merged_descriptor_rects(exe_row)
        if exe_rects and exe_tables:
            has_alternate_tables = any(not table.get("fieldPlus4Bound") for table in exe_tables)
            promoted_single_rects = PROMOTED_SINGLE_RECTS.get(asset, [])
            rects = exe_rects
            selected_source = "exe-source-rect-table-union" if len(exe_tables) > 1 else "exe-source-rect-table"
            source = {
                "kind": "field-character-exe-source-rect-table-union" if len(exe_tables) > 1 else "field-character-exe-source-rect-table",
                "confidence": "field-plus4-or-promoted-single-rect-bound" if promoted_single_rects else ("field-plus4-or-promoted-alternate-bound" if has_alternate_tables else "field-plus4-bound"),
                "table": table_summary(exe_tables[0]),
                "tables": [table_summary(table) for table in exe_tables],
                "promotedAlternateTables": [
                    table_summary(table) for table in exe_tables if not table.get("fieldPlus4Bound")
                ],
                "promotedSingleRects": promoted_single_rects,
                "sourceTableCount": len(exe_tables),
                "rawRectEntryCount": raw_entry_count,
                "uniqueRectCount": len(rects),
                "reviewGrid": {
                    "source": "out/character_sprite_frames.json",
                    "frameWidth": row.get("frameWidth"),
                    "frameHeight": row.get("frameHeight"),
                    "walkFramesPerDirection": row.get("walkFramesPerDirection"),
                    "idleFrame": row.get("idleFrame"),
                    "footprint": "bottom-3x1-horizontal-footprint",
                },
            }
            note = (
                f"exe {len(exe_tables)} table(s), {len(rects)} unique rects"
                f"{' · includes promoted single rect' if promoted_single_rects else ''}"
                f"{' · includes promoted alternate layout' if has_alternate_tables else ''} · "
                f"walk-grid {row.get('walkFramesPerDirection')}/dir · idle #{row.get('idleFrame')}"
            )
        else:
            rects = playable_rects(row)
            selected_source = "confirmed-48x64-field-character-grid"
            source = {
                "kind": "confirmed-field-character-grid",
                "confidence": "manual-confirmed",
                "frameWidth": row.get("frameWidth"),
                "frameHeight": row.get("frameHeight"),
                "walkFramesPerDirection": row.get("walkFramesPerDirection"),
                "footprint": "bottom-3x1-horizontal-footprint",
            }
            note = f"walk {row.get('walkFramesPerDirection')}/dir · idle #{row.get('idleFrame')}"
        rows.append(
            base_row(
                group="character",
                asset=asset,
                cns=row.get("file"),
                path=image_path(asset, frame_assets),
                width=row.get("width"),
                height=row.get("height"),
                status="good",
                selected_rect_source=selected_source,
                rects=rects,
                source=source,
                note=note,
            )
        )
    for row in rect_scan.get("rows") or []:
        if row.get("category") != "character" or row.get("asset") in confirmed:
            continue
        asset = row.get("asset")
        table = row.get("bestTable") or {}
        rects, exe_tables, raw_entry_count = merged_descriptor_rects(row)
        if not rects:
            rects = clean_rects(table.get("rects") or [])
        is_descriptor_bound = bool(rects and exe_tables)
        has_alternate_tables = any(not table.get("fieldPlus4Bound") for table in exe_tables)
        promoted_single_rects = PROMOTED_SINGLE_RECTS.get(asset, [])
        status = "good" if is_descriptor_bound else ("warn" if rects else "bad")
        c_tables: list[dict[str, Any]] = []
        source = {
            "kind": "character-exe-source-rect-table-union" if is_descriptor_bound and len(exe_tables) > 1 else ("character-exe-source-rect-table" if is_descriptor_bound else "character-exe-source-rect-candidate"),
            "confidence": ("field-plus4-or-promoted-single-rect-bound" if promoted_single_rects else ("field-plus4-or-promoted-alternate-bound" if has_alternate_tables else "field-plus4-bound")) if is_descriptor_bound else "review",
            "category": row.get("category"),
            "table": table_summary(exe_tables[0] if exe_tables else table),
            "tables": [table_summary(table_item) for table_item in exe_tables],
            "promotedAlternateTables": [
                table_summary(table_item) for table_item in exe_tables if not table_item.get("fieldPlus4Bound")
            ],
            "promotedSingleRects": promoted_single_rects,
            "sourceTableCount": len(exe_tables),
            "rawRectEntryCount": raw_entry_count,
            "uniqueRectCount": len(rects),
        }
        note = (
            f"descriptor +4/alternate source rect union: {len(exe_tables)} table(s), {len(rects)} unique rects"
            if has_alternate_tables
            else f"descriptor +4 source rect union: {len(exe_tables)} table(s), {len(rects)} unique rects"
            if is_descriptor_bound
            else f"{table.get('fieldLabel') or '-'} · {table.get('boundaryKind') or '-'}"
        )
        if promoted_single_rects:
            note = f"{note} · promoted single rect {promoted_single_rects[0]['sourceLabel']}"
        rows.append(
            base_row(
                group="character",
                asset=asset,
                cns=row.get("cns") or f"{asset}.cns",
                path=image_path(asset, frame_assets),
                width=row.get("width"),
                height=row.get("height"),
                status=status,
                selected_rect_source=("exe-source-rect-table-union" if len(exe_tables) > 1 else "exe-source-rect-table") if is_descriptor_bound else ("exe-source-rect-candidate" if rects else "missing"),
                rects=rects,
                candidate_tables_value=c_tables,
                source=source,
                note=note,
            )
        )
    return rows


def build_battle_rows(rect_scan: dict[str, Any], frame_assets: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
    rows = []
    for row in rect_scan.get("rows") or []:
        if row.get("category") != "battle":
            continue
        asset = row.get("asset")
        table = row.get("bestTable") or {}
        rects = clean_rects(table.get("rects") or [])
        status = "good" if rects and table.get("fieldPlus4Bound") else ("warn" if rects else "bad")
        c_tables: list[dict[str, Any]] = []
        source = {
            "kind": "battle-exe-source-rect-table",
            "confidence": "field-plus4-bound" if table.get("fieldPlus4Bound") else "review",
            "category": row.get("category"),
            "table": table_summary(table),
        }
        rows.append(
            base_row(
                group="battle",
                asset=asset,
                cns=row.get("cns") or f"{asset}.cns",
                path=image_path(asset, frame_assets),
                width=row.get("width"),
                height=row.get("height"),
                status=status,
                selected_rect_source="exe-source-rect-table" if rects else "missing",
                rects=rects,
                candidate_tables_value=c_tables,
                source=source,
                note=f"{table.get('frameCount', len(rects)) or 0} frames · {table.get('fieldLabel') or '-'}",
            )
        )
    return rows


def build_summary(out_dir: Path = OUT) -> dict[str, Any]:
    frame_assets_data = load_json(out_dir / "cns_frame_assets.json")
    rect_scan = load_json(out_dir / "cns_frame_rect_exe_scan.json")
    monster_scan = load_json(out_dir / "monster_frame_rect_exe_pattern_scan.json")
    character_frames = load_json(out_dir / "character_sprite_frames.json")
    ui_window = load_json(out_dir / "ui_window_review_data.json")
    frame_assets = frame_asset_map(frame_assets_data)
    rows = [
        *build_ui_rows(ui_window),
        *build_monster_rows(monster_scan, frame_assets),
        *build_character_rows(character_frames, rect_scan, frame_assets),
        *build_battle_rows(rect_scan, frame_assets),
    ]
    rows.sort(key=lambda row: (row["group"], row["asset"]))
    group_counts = {group: sum(1 for row in rows if row["group"] == group) for group in GROUP_LABELS}
    status_counts = {status: sum(1 for row in rows if row["status"] == status) for status in STATUS_LABELS}
    return {
        "status": "canonical-cns-rect-review-data",
        "scope": "Canonical selected CNS frame/source rect data grouped for review.",
        "source": [
            "out/ui_window_review_data.json",
            "out/monster_frame_rect_exe_pattern_scan.json",
            "out/character_sprite_frames.json",
            "out/cns_frame_rect_exe_scan.json",
            "out/cns_frame_assets.json",
        ],
        "rowCount": len(rows),
        "groupCounts": group_counts,
        "statusCounts": status_counts,
        "selectedRectCount": sum(row["rectCount"] for row in rows),
        "candidateRectCount": sum(row["candidateCount"] for row in rows),
        "rows": rows,
        "notes": [
            "The browser page consumes this one canonical bundle instead of assembling scattered source files at runtime.",
            "Selected rects are the latest applied/review units. Unpromoted candidates are retained separately and hidden by default.",
            "status.cns and 01234567.cns remain whole-image review rows; their grid/table candidates are not promoted.",
            "face_01.cns is a confirmed 80x80 portrait grid.",
            "Monster rows use descriptor +4 adjacent source-rect tables when available; mismatches remain review rows.",
        ],
    }


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


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(args.out_dir)
    write_outputs(summary, args.out_dir)
    print(
        "wrote "
        f"{summary['rowCount']} rows, "
        f"{summary['selectedRectCount']} selected rects -> {args.out_dir / 'cns_rect_review_data.json'}"
    )


if __name__ == "__main__":
    main()
