#!/usr/bin/env python3
"""Scan EXE-side frame rectangle tables for non-map CNS image assets.

Monster sprites already proved a common `u32 x1,y1,x2,y2` table shape.  This
scanner applies that same shape to every non-map CNS image and follows the
descriptor-side pointer field rather than assuming the table always belongs to
the filename immediately before it.  Character tables are often chained, so a
table usually ends at the next referenced table start.
"""
from __future__ import annotations

import argparse
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 find_cns_strings, offset_to_va, read_sections, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
FIELD_RELS = tuple(range(-32, 68, 4))
MAX_RECTS = 512
SCHEMA_SPECS = (
    {"schema": "u32-xyxy-excl", "stride": 16, "hasOffset2": False},
    {"schema": "u32-xyxy-excl-offset2", "stride": 24, "hasOffset2": True},
)


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


def hex32(value: int | None) -> str | None:
    if value is None:
        return None
    return f"0x{value:08x}"


def asset_key(name: str) -> str:
    return Path(name).stem


def category_label(category: str) -> str:
    return {
        "battle": "전투/효과",
        "character": "캐릭터",
        "enemy-object": "몬스터/오브젝트",
        "face": "얼굴",
        "ui-misc": "아이템/UI/문구",
    }.get(category, category)


def review_href_for_asset(asset: str, category: str | None = None) -> str:
    encoded = html.escape(asset, quote=True)
    if category == "enemy-object":
        return f"../web/monster_review.html?asset={encoded}"
    if category == "character":
        return f"../web/field_character_review.html?asset={encoded}"
    if category in {"ui-misc", "face"}:
        return f"../web/ui_window_review.html?asset={encoded}"
    if category == "battle" and asset == "btl_etc":
        return f"../web/ui_window_review.html?asset={encoded}"
    if category == "battle":
        return f"../web/battle_effect_visual_review.html?asset={encoded}"
    return f"../web/cns_rect_review.html?asset={encoded}"


def dword_at_offset(data: bytes, offset: int) -> int | None:
    if offset < 0 or offset + 4 > len(data):
        return None
    return struct.unpack_from("<I", data, offset)[0]


def find_value_refs(data: bytes, sections: list[dict], value: int) -> list[dict]:
    needle = struct.pack("<I", value)
    refs = []
    search = 0
    while True:
        hit = data.find(needle, search)
        if hit < 0:
            break
        search = hit + 1
        ref_va = offset_to_va(sections, hit)
        if ref_va is None:
            continue
        refs.append({"fileOffset": hit, "refVa": ref_va})
    return refs


def source_rect_from_values(values: tuple[int, int, int, int]) -> dict | None:
    x1, y1, x2, y2 = [int(value) for value in values]
    w = x2 - x1
    h = y2 - y1
    if w <= 0 or h <= 0:
        return None
    return {"x": x1, "y": y1, "w": w, "h": h}


def valid_source_rect(rect: dict, width: int, height: int) -> bool:
    x = int(rect["x"])
    y = int(rect["y"])
    w = int(rect["w"])
    h = int(rect["h"])
    if x < 0 or y < 0 or w <= 0 or h <= 0:
        return False
    if x >= width or y >= height:
        return False
    if w > width or h > height:
        return False
    return x + w <= width and y + h <= height


def decode_rect_at_schema(
    data: bytes,
    offset: int,
    width: int,
    height: int,
    spec: dict,
) -> dict | None:
    stride = int(spec["stride"])
    if offset < 0 or offset + stride > len(data):
        return None
    rect = source_rect_from_values(struct.unpack_from("<IIII", data, offset))
    if rect is None or not valid_source_rect(rect, width, height):
        return None
    if spec.get("hasOffset2"):
        offset_x, offset_y = struct.unpack_from("<ii", data, offset + 16)
        rect["offsetX"] = offset_x
        rect["offsetY"] = offset_y
    return rect


def decode_rect_at(data: bytes, offset: int, width: int, height: int) -> dict | None:
    return decode_rect_at_schema(data, offset, width, height, SCHEMA_SPECS[0])


def decode_until_invalid_schema(
    data: bytes,
    sections: list[dict],
    table_start_va: int,
    width: int,
    height: int,
    spec: dict,
    *,
    max_rects: int = MAX_RECTS,
) -> list[dict]:
    start = va_to_offset(sections, table_start_va)
    if start is None:
        return []
    stride = int(spec["stride"])
    rects = []
    for index in range(max_rects):
        rect = decode_rect_at_schema(data, start + index * stride, width, height, spec)
        if rect is None:
            break
        rects.append(rect)
    return rects


def decode_until_invalid(
    data: bytes,
    sections: list[dict],
    table_start_va: int,
    width: int,
    height: int,
    *,
    max_rects: int = MAX_RECTS,
) -> list[dict]:
    return decode_until_invalid_schema(
        data,
        sections,
        table_start_va,
        width,
        height,
        SCHEMA_SPECS[0],
        max_rects=max_rects,
    )


def decode_bounded_schema(
    data: bytes,
    sections: list[dict],
    table_start_va: int,
    boundary_va: int | None,
    width: int,
    height: int,
    spec: dict,
) -> list[dict] | None:
    if boundary_va is None or boundary_va <= table_start_va:
        return None
    start = va_to_offset(sections, table_start_va)
    boundary = va_to_offset(sections, boundary_va)
    if start is None or boundary is None or boundary <= start:
        return None
    byte_len = boundary - start
    stride = int(spec["stride"])
    if byte_len % stride:
        return None
    count = byte_len // stride
    if count <= 0 or count > MAX_RECTS:
        return None
    rects = []
    for index in range(count):
        rect = decode_rect_at_schema(data, start + index * stride, width, height, spec)
        if rect is None:
            return None
        rects.append(rect)
    return rects


def decode_bounded(
    data: bytes,
    sections: list[dict],
    table_start_va: int,
    boundary_va: int | None,
    width: int,
    height: int,
) -> list[dict] | None:
    return decode_bounded_schema(data, sections, table_start_va, boundary_va, width, height, SCHEMA_SPECS[0])


def rect_sample(rects: list[dict], limit: int = 6) -> list[dict]:
    return [dict(rect) for rect in rects[:limit]]


def frame_count_hints(asset_rows: list[dict]) -> dict[str, dict]:
    out = {}
    for row in asset_rows:
        asset = row.get("assetKey") or asset_key(str(row.get("cns") or ""))
        if not asset:
            continue
        counts = set()
        counts_by_mode = {}
        frame_hint = row.get("frameHint") or {}
        hint_count = frame_hint.get("frameCount")
        if isinstance(hint_count, int) and hint_count > 0:
            counts.add(hint_count)
            counts_by_mode["frameHint"] = [hint_count]
        confirmed = row.get("confirmedFrames") or {}
        confirmed_rects = confirmed.get("rects") if isinstance(confirmed, dict) else None
        known_count = len(confirmed_rects) if isinstance(confirmed_rects, list) and confirmed_rects else None
        if isinstance(known_count, int):
            counts.add(known_count)
        out[asset] = {
            "knownFrameCount": known_count if isinstance(known_count, int) else None,
            "knownFrameCountSource": confirmed.get("source") if isinstance(confirmed, dict) else None,
            "autoCountSet": sorted(counts),
            "autoCountsByMode": counts_by_mode,
        }
    return out


def rows_from_assets(asset_summary: dict, payloads: list[dict]) -> list[dict]:
    rows = asset_summary.get("rows") or []
    if rows:
        return rows
    fallback = []
    for row in payloads:
        if row.get("kind") != "image":
            continue
        name = str(row.get("name") or "")
        stem = asset_key(name)
        if not name or stem.startswith("map_"):
            continue
        fallback.append(
            {
                "assetKey": stem,
                "cns": name,
                "category": "enemy-object" if stem.startswith(("boss", "z")) else "ui-misc",
                "categoryLabel": "",
                "width": row.get("width"),
                "height": row.get("height"),
            }
        )
    return sorted(fallback, key=lambda item: item["assetKey"])


def collect_candidate_seeds(
    data: bytes,
    sections: list[dict],
    strings: dict[int, str],
    rows: list[dict],
) -> list[dict]:
    name_to_vas: dict[str, list[int]] = defaultdict(list)
    for va, name in strings.items():
        name_to_vas[name].append(va)

    seeds = []
    seen = set()
    for row in rows:
        cns = str(row.get("cns") or "")
        asset = str(row.get("assetKey") or asset_key(cns))
        width = int(row.get("width") or 0)
        height = int(row.get("height") or 0)
        if not cns or width <= 0 or height <= 0:
            continue
        for string_va in sorted(name_to_vas.get(cns, [])):
            for ref in find_value_refs(data, sections, string_va):
                ref_offset = int(ref["fileOffset"])
                ref_va = int(ref["refVa"])
                for rel in FIELD_RELS:
                    field_offset = ref_offset + rel
                    value = dword_at_offset(data, field_offset)
                    if value is None or va_to_offset(sections, value) is None:
                        continue
                    for spec in SCHEMA_SPECS:
                        initial_rects = decode_until_invalid_schema(
                            data,
                            sections,
                            value,
                            width,
                            height,
                            spec,
                            max_rects=8,
                        )
                        if len(initial_rects) < 2:
                            continue
                        key = (asset, string_va, ref_va, rel, value, spec["schema"], spec["stride"])
                        if key in seen:
                            continue
                        seen.add(key)
                        seeds.append(
                            {
                                "asset": asset,
                                "cns": cns,
                                "category": row.get("category") or "",
                                "categoryLabel": row.get("categoryLabel") or category_label(str(row.get("category") or "")),
                                "width": width,
                                "height": height,
                                "stringVa": string_va,
                                "stringVaHex": hex32(string_va),
                                "refVa": ref_va,
                                "refVaHex": hex32(ref_va),
                                "refFileOffset": ref_offset,
                                "refFileOffsetHex": f"0x{ref_offset:06x}",
                                "fieldRel": rel,
                                "fieldLabel": f"{rel:+d}",
                                "fieldVa": ref_va + rel,
                                "fieldVaHex": hex32(ref_va + rel),
                                "tableStartVa": value,
                                "tableStartVaHex": hex32(value),
                                "initialValidRectCount": len(initial_rects),
                                "schema": spec["schema"],
                                "stride": spec["stride"],
                                "hasOffset2": spec.get("hasOffset2", False),
                            }
                        )
    return seeds


def next_table_start(table_start_va: int, table_starts: list[int]) -> int | None:
    for candidate in table_starts:
        if candidate > table_start_va:
            return candidate
    return None


def build_candidate(seed: dict, data: bytes, sections: list[dict], table_starts: list[int], auto_meta: dict) -> dict | None:
    width = int(seed["width"])
    height = int(seed["height"])
    start_va = int(seed["tableStartVa"])
    spec = {
        "schema": seed.get("schema") or "u32-xyxy-excl",
        "stride": int(seed.get("stride") or 16),
        "hasOffset2": bool(seed.get("hasOffset2")),
    }
    boundary_va = next_table_start(start_va, table_starts)
    bounded = decode_bounded_schema(data, sections, start_va, boundary_va, width, height, spec)
    if bounded:
        rects = bounded
        boundary_kind = "next-table-start"
        boundary_used = boundary_va
    else:
        rects = decode_until_invalid_schema(data, sections, start_va, width, height, spec)
        boundary_kind = "invalid-terminator"
        boundary_used = None
    if not rects:
        return None

    count = len(rects)
    auto_counts = set(auto_meta.get("autoCountSet") or [])
    known_count = auto_meta.get("knownFrameCount")
    start_offset = va_to_offset(sections, start_va)
    boundary_offset = va_to_offset(sections, boundary_used) if boundary_used is not None else None
    return {
        "schema": spec["schema"],
        "stride": spec["stride"],
        "hasOffset2": spec.get("hasOffset2", False),
        "frameCount": count,
        "imageSize": f"{width}x{height}",
        "tableStartVa": start_va,
        "tableStartVaHex": hex32(start_va),
        "tableStartFileOffset": start_offset,
        "tableStartFileOffsetHex": f"0x{start_offset:06x}" if isinstance(start_offset, int) else None,
        "boundaryKind": boundary_kind,
        "boundaryVa": boundary_used,
        "boundaryVaHex": hex32(boundary_used),
        "boundaryFileOffsetHex": f"0x{boundary_offset:06x}" if isinstance(boundary_offset, int) else None,
        "fieldRel": seed["fieldRel"],
        "fieldLabel": seed["fieldLabel"],
        "fieldVaHex": seed["fieldVaHex"],
        "stringVaHex": seed["stringVaHex"],
        "refVaHex": seed["refVaHex"],
        "refFileOffsetHex": seed["refFileOffsetHex"],
        "fieldPlus4Bound": int(seed["fieldRel"]) == 4,
        "matchesKnownFrameCount": isinstance(known_count, int) and count == known_count,
        "matchesAutoCountSet": count in auto_counts,
        "autoCountSet": sorted(auto_counts),
        "knownFrameCount": known_count,
        "knownFrameCountSource": auto_meta.get("knownFrameCountSource"),
        "rectSample": rect_sample(rects),
        "rects": rects,
    }


def table_sort_key(table: dict) -> tuple:
    return (
        int(table.get("fieldRel") != 4),
        int(table.get("boundaryKind") != "next-table-start"),
        int(not table.get("matchesAutoCountSet")),
        int(not table.get("matchesKnownFrameCount")),
        abs(int(table.get("fieldRel") or 0) - 4),
        -int(table.get("frameCount") or 0),
        str(table.get("tableStartVaHex") or ""),
    )


MANUAL_RECT_CORRECTIONS = {
    "btl_etc": [
        {
            "action": "annotate",
            "index": 81,
            "expected": {"x": 272, "y": 80, "w": 32, "h": 16},
            "label": "MISS",
            "reason": "EXE rect #81 is the bottom-row MISS text.",
        },
        {
            "action": "insert_after",
            "afterIndex": 81,
            "expectedPrevious": {"x": 272, "y": 80, "w": 32, "h": 16},
            "rect": {"x": 304, "y": 80, "w": 24, "h": 16, "label": "HIT"},
            "reason": "Bottom-row HIT text follows MISS in btl_etc.cns but is not present as a separate EXE rect before the next table boundary.",
        }
    ]
}


def apply_manual_rect_corrections(row: dict) -> None:
    corrections = MANUAL_RECT_CORRECTIONS.get(row.get("asset"))
    if not corrections:
        return
    touched_tables = []
    for table in [row.get("bestTable"), *(row.get("tables") or []), *(row.get("candidateTables") or [])]:
        if table and table not in touched_tables:
            touched_tables.append(table)
    applied = []
    for table in touched_tables:
        rects = table.get("rects") or []
        original_frame_count = len(rects)
        for correction in corrections:
            action = correction.get("action") or "replace"
            if action == "annotate":
                index = int(correction["index"])
                if index < 0 or index >= len(rects):
                    continue
                current = rects[index]
                expected = correction["expected"]
                if {key: current.get(key) for key in ("x", "y", "w", "h")} != expected:
                    continue
                rects[index] = {
                    **current,
                    "label": correction.get("label") or current.get("label") or "",
                    "manualAnnotation": correction["reason"],
                }
                applied.append({"index": index, "tableStartVaHex": table.get("tableStartVaHex"), **correction})
            elif action == "insert_after":
                after_index = int(correction["afterIndex"])
                if after_index < 0 or after_index >= len(rects):
                    continue
                previous = rects[after_index]
                expected_previous = correction["expectedPrevious"]
                if {key: previous.get(key) for key in ("x", "y", "w", "h")} != expected_previous:
                    continue
                insert_index = after_index + 1
                rects.insert(
                    insert_index,
                    {
                        **correction["rect"],
                        "manualInsertion": correction["reason"],
                        "insertedAfterOriginalIndex": after_index,
                    },
                )
                applied.append(
                    {
                        "index": insert_index,
                        "tableStartVaHex": table.get("tableStartVaHex"),
                        **correction,
                    }
                )
        table["rectSample"] = rect_sample(rects)
        if len(rects) != original_frame_count:
            table["originalFrameCount"] = original_frame_count
            table["frameCount"] = len(rects)
    if applied:
        row["manualRectCorrections"] = applied


def build_rows(
    data: bytes,
    sections: list[dict],
    asset_rows: list[dict],
    auto_meta_by_asset: dict[str, dict],
) -> list[dict]:
    strings = find_cns_strings(data, sections)
    seeds = collect_candidate_seeds(data, sections, strings, asset_rows)
    plus4_starts = sorted({int(seed["tableStartVa"]) for seed in seeds if int(seed["fieldRel"]) == 4})
    rows_by_asset: dict[str, dict] = {}
    for row in asset_rows:
        cns = str(row.get("cns") or "")
        asset = str(row.get("assetKey") or asset_key(cns))
        rows_by_asset[asset] = {
            "asset": asset,
            "cns": cns,
            "category": row.get("category") or "",
            "categoryLabel": row.get("categoryLabel") or category_label(str(row.get("category") or "")),
            "imageSize": f"{row.get('width')}x{row.get('height')}",
            "width": row.get("width"),
            "height": row.get("height"),
            "tableCount": 0,
            "bestTable": None,
            "tables": [],
        }

    candidates_by_asset: dict[str, list[dict]] = defaultdict(list)
    seen_candidates = set()
    for seed in seeds:
        meta = auto_meta_by_asset.get(seed["asset"], {})
        table = build_candidate(seed, data, sections, plus4_starts, meta)
        if table is None:
            continue
        key = (seed["asset"], table["tableStartVa"], table["fieldRel"], table["frameCount"])
        if key in seen_candidates:
            continue
        seen_candidates.add(key)
        candidates_by_asset[seed["asset"]].append(table)

    for asset, tables in candidates_by_asset.items():
        tables.sort(key=table_sort_key)
        promoted_tables = [table for table in tables if table.get("fieldPlus4Bound")]
        row = rows_by_asset.get(asset)
        if not row:
            continue
        row["candidateTableCount"] = len(tables)
        row["tableCount"] = len(promoted_tables)
        row["bestTable"] = promoted_tables[0] if promoted_tables else None
        row["tables"] = promoted_tables
        row["candidateTables"] = tables
        apply_manual_rect_corrections(row)

    return sorted(rows_by_asset.values(), key=lambda item: (item.get("category") or "", item["asset"]))


def build_summary(exe_path: Path, asset_rows: list[dict], rows: list[dict]) -> dict:
    rows_with_tables = [row for row in rows if row.get("bestTable")]
    plus4_bound = [row for row in rows_with_tables if row["bestTable"].get("fieldPlus4Bound")]
    next_bound = [row for row in rows_with_tables if row["bestTable"].get("boundaryKind") == "next-table-start"]
    auto_matches = [row for row in rows_with_tables if row["bestTable"].get("matchesAutoCountSet")]
    category_counts = Counter(row.get("category") or "unknown" for row in rows)
    table_category_counts = Counter(row.get("category") or "unknown" for row in rows_with_tables)
    boundary_counts = Counter(row["bestTable"].get("boundaryKind") for row in rows_with_tables)
    examples = []
    for name in [
        "cara_at1",
        "cara_at2",
        "cara_at3",
        "cara_rs1",
        "cara_sm1",
        "btl_at",
        "zk_big",
        "item",
    ]:
        row = next((item for item in rows if item["asset"] == name), None)
        if row:
            examples.append(
                {
                    "asset": row["asset"],
                    "cns": row["cns"],
                    "category": row["category"],
                    "imageSize": row["imageSize"],
                    "bestFrameCount": (row.get("bestTable") or {}).get("frameCount"),
                    "bestTableStartVaHex": (row.get("bestTable") or {}).get("tableStartVaHex"),
                    "boundaryKind": (row.get("bestTable") or {}).get("boundaryKind"),
                    "tableCount": row.get("tableCount", 0),
                    "candidateTableCount": row.get("candidateTableCount", 0),
                }
            )

    return {
        "scope": "EXE-side u32 x1,y1,x2,y2 frame rectangle table scan for non-map CNS images.",
        "source": [str(exe_path), "out/cns_frame_assets.json"],
        "status": "cns-frame-rect-static-scan",
        "assetCount": len(asset_rows),
        "rowCount": len(rows),
        "assetsWithExeRectTables": len(rows_with_tables),
        "assetsWithPlus4BoundBestTable": len(plus4_bound),
        "assetsWithNextTableBoundary": len(next_bound),
        "assetsMatchingAutoCountSet": len(auto_matches),
        "categoryCounts": dict(sorted(category_counts.items())),
        "tableCategoryCounts": dict(sorted(table_category_counts.items())),
        "boundaryKindCounts": dict(sorted(boundary_counts.items())),
        "examples": examples,
        "rows": rows,
        "conclusion": (
            "Non-map CNS images can reuse the monster-proven u32 x1,y1,x2,y2 source-rectangle shape when an EXE "
            "descriptor field points to the table.  Character resources commonly chain several tables together, so "
            "the nearest next table start is used as the strongest boundary.  Assets without an EXE table remain "
            "grid/manual-review candidates."
        ),
    }


def html_page(summary: dict) -> str:
    def esc(value: Any) -> str:
        return html.escape(str(value))

    example_rows = []
    for row in summary["examples"]:
        href = review_href_for_asset(row["asset"], row.get("category"))
        example_rows.append(
            "<tr>"
            f"<td><a href=\"{href}\"><code>{esc(row['asset'])}</code></a></td>"
            f"<td><code>{esc(row['cns'])}</code></td>"
            f"<td>{esc(row['imageSize'])}</td>"
            f"<td>{esc(row.get('bestFrameCount') or '-')}</td>"
            f"<td><code>{esc(row.get('bestTableStartVaHex') or '-')}</code></td>"
            f"<td><code>{esc(row.get('boundaryKind') or '-')}</code></td>"
            f"<td>{esc(row.get('tableCount', 0))}</td>"
            f"<td>{esc(row.get('candidateTableCount', 0))}</td>"
            "</tr>"
        )

    asset_rows = []
    for row in summary["rows"]:
        table = row.get("bestTable") or {}
        href = review_href_for_asset(row["asset"], row.get("category"))
        sample = "; ".join(
            f"{rect['x']},{rect['y']} {rect['w']}x{rect['h']}" for rect in (table.get("rectSample") or [])[:4]
        )
        asset_rows.append(
            "<tr>"
            f"<td><a href=\"{href}\"><code>{esc(row['asset'])}</code></a></td>"
            f"<td>{esc(row.get('categoryLabel') or row.get('category') or '-')}</td>"
            f"<td>{esc(row.get('imageSize'))}</td>"
            f"<td>{esc(table.get('frameCount') or '-')}</td>"
            f"<td><code>{esc(table.get('schema') or '-')}</code></td>"
            f"<td>{esc(table.get('fieldLabel') or '-')}</td>"
            f"<td><code>{esc(table.get('tableStartVaHex') or '-')}</code></td>"
            f"<td><code>{esc(table.get('boundaryKind') or '-')}</code></td>"
            f"<td>{esc(table.get('matchesAutoCountSet'))}</td>"
            f"<td><code>{esc(table.get('autoCountSet') or [])}</code></td>"
            f"<td><code>{esc(sample or '-')}</code></td>"
            "</tr>"
        )

    cards = "".join(
        f"<div><strong>{esc(value)}</strong><span>{esc(key)}</span></div>"
        for key, value in [
            ("assets", summary["assetCount"]),
            ("EXE rect tables", summary["assetsWithExeRectTables"]),
            ("plus4 best", summary["assetsWithPlus4BoundBestTable"]),
            ("next-bound", summary["assetsWithNextTableBoundary"]),
            ("auto matches", summary["assetsMatchingAutoCountSet"]),
        ]
    )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>CNS Frame Rect EXE Scan</title>
  <style>
    body {{ margin: 24px; background: #101214; color: #edf2f4; font: 14px system-ui, sans-serif; line-height: 1.45; }}
    a {{ color: #9bd4ff; }}
    code {{ color: #f5eec8; white-space: pre-wrap; word-break: break-word; }}
    .cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; max-width: 980px; margin: 16px 0; }}
    .cards div {{ border: 1px solid #303940; background: #151a1f; padding: 10px; }}
    .cards strong {{ display: block; font-size: 22px; }}
    .cards span {{ color: #aeb8bd; }}
    table {{ border-collapse: collapse; width: 100%; margin: 12px 0 30px; }}
    th, td {{ border: 1px solid #303940; padding: 6px 8px; text-align: left; vertical-align: top; }}
    th {{ background: #182028; position: sticky; top: 0; }}
  </style>
</head>
<body>
  <h1>CNS Frame Rect EXE Scan</h1>
  <p>{esc(summary['conclusion'])}</p>
  <div class="cards">{cards}</div>
  <h2>Examples</h2>
  <table><thead><tr><th>asset</th><th>cns</th><th>size</th><th>frames</th><th>table</th><th>boundary</th><th>promoted</th><th>candidates</th></tr></thead><tbody>{''.join(example_rows)}</tbody></table>
  <h2>All Assets</h2>
  <table><thead><tr><th>asset</th><th>category</th><th>size</th><th>frames</th><th>schema</th><th>field</th><th>table</th><th>boundary</th><th>auto match</th><th>auto counts</th><th>sample</th></tr></thead><tbody>{''.join(asset_rows)}</tbody></table>
</body>
</html>
"""


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


def run_scan(exe_path: Path, out_dir: Path) -> dict:
    data = exe_path.read_bytes()
    sections = read_sections(data)
    assets = load_json(out_dir / "cns_frame_assets.json", {})
    payloads = load_json(out_dir / "cns_payloads.json", [])
    asset_rows = rows_from_assets(assets, payloads)
    rows = build_rows(data, sections, asset_rows, frame_count_hints(asset_rows))
    return build_summary(exe_path, asset_rows, rows)


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = run_scan(args.exe, args.out_dir)
    write_outputs(summary, args.out_dir)
    print(f"wrote {args.out_dir / 'cns_frame_rect_exe_scan.json'}")
    print(
        f"assets={summary['assetCount']} exeTables={summary['assetsWithExeRectTables']} "
        f"plus4={summary['assetsWithPlus4BoundBestTable']} nextBound={summary['assetsWithNextTableBoundary']}"
    )
    return 0


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