#!/usr/bin/env python3
"""Probe approximate EXE-side rectangle table patterns for monster frames.

This is a static pattern scan. It uses user-confirmed frame counts first and
alpha connected-component bounding boxes otherwise, then looks for nearby
little-endian rectangle-like records in Hwanse2.exe. Hits are evidence for
follow-up review only; they are not promoted as original frame tables without
a runtime consumer or a stronger table schema.
"""
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 offset_to_va, read_sections, va_to_offset
from probe_monster_frame_count_exe_patterns import (
    DATA,
    OUT,
    ROOT,
    build_frame_count_rows,
    connected_components,
    descriptor_index,
    hex32,
    load_json,
    parse_frame_annotations,
)


LOCAL_WINDOW_BYTES = 0x1800
MAX_LOCAL_HITS_PER_ASSET = 10
MAX_UNORDERED_RECTS = 14


U16_SPECS = [
    ("u16-xywh", "u16", "xywh", 8, 0),
    ("u16-xywh+2", "u16", "xywh", 10, 2),
    ("u16-xywh+4", "u16", "xywh", 12, 4),
    ("u16-xywh-stride12", "u16", "xywh", 12, 0),
    ("u16-xywh-stride16", "u16", "xywh", 16, 0),
    ("u16-xyxy-excl", "u16", "xyxy-excl", 8, 0),
    ("u16-xyxy-incl", "u16", "xyxy-incl", 8, 0),
    ("u16-whxy", "u16", "whxy", 8, 0),
]

U32_SPECS = [
    ("u32-xywh", "u32", "xywh", 16, 0),
    ("u32-xyxy-excl", "u32", "xyxy-excl", 16, 0),
    ("u32-xyxy-excl-offset1", "u32", "xyxy-excl", 20, 0),
    ("u32-xyxy-excl-offset2", "u32", "xyxy-excl", 24, 0),
]


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


def rect_end(rect: dict) -> tuple[int, int]:
    return int(rect["x"]) + int(rect["w"]), int(rect["y"]) + int(rect["h"])


def compact_rect(rect: dict) -> dict:
    return {
        "x": int(rect["x"]),
        "y": int(rect["y"]),
        "w": int(rect["w"]),
        "h": int(rect["h"]),
    }


def build_rect_rows(out_dir: Path) -> list[dict]:
    annotations = parse_frame_annotations(DATA / "monster_frame_annotations.js")
    battle_enemy_candidates = load_json(out_dir / "battle_enemy_candidates.json", {})
    resource_descriptors = load_json(out_dir / "original_battle_resource_descriptors.json", {})
    descriptor_by_asset = descriptor_index(resource_descriptors)
    rows = build_frame_count_rows(battle_enemy_candidates, annotations)

    enriched = []
    for row in rows:
        descriptor = descriptor_by_asset.get(row["asset"]) or {}
        if descriptor:
            row["stringVa"] = descriptor.get("stringVa")
            row["stringVaHex"] = descriptor.get("stringVaHex")
            row["descriptorRefVa"] = descriptor.get("refVa")
            row["descriptorRefVaHex"] = descriptor.get("refVaHex")
            row["descriptorFileOffset"] = descriptor.get("fileOffset")
            row["descriptorFileOffsetHex"] = descriptor.get("fileOffsetHex")
            row["descriptorValues"] = descriptor.get("values") or {}

        annotation = annotations.get(row["asset"])
        png = ROOT / "out" / f"{row['asset']}.png"
        width, height, components = connected_components(png, annotation if annotation else None)
        row["width"] = width
        row["height"] = height
        row["componentRects"] = [compact_rect(item) | {"area": int(item.get("area", 0))} for item in components]
        row["rectSetSource"] = "component-bbox"
        if row.get("autoComponentOverSegmented"):
            row["rectSetSource"] = "component-bbox-oversegmented-review-only"
        elif row.get("manualAnnotated") and row.get("autoMatchesTarget"):
            row["rectSetSource"] = "manual-count-component-bbox"
        elif row.get("manualAnnotated"):
            row["rectSetSource"] = "manual-count-component-bbox-mismatch"
        enriched.append(row)
    return enriched


def decode_values(exe: bytes, offset: int, scalar: str, count: int) -> tuple[int, ...] | None:
    size = 2 if scalar == "u16" else 4
    end = offset + size * count
    if offset < 0 or end > len(exe):
        return None
    fmt = "<" + ("H" if scalar == "u16" else "I") * count
    return struct.unpack_from(fmt, exe, offset)


def values_to_rect(values: tuple[int, int, int, int], layout: str) -> dict | None:
    a, b, c, d = [int(value) for value in values]
    if layout == "xywh":
        x, y, w, h = a, b, c, d
    elif layout == "whxy":
        w, h, x, y = a, b, c, d
    elif layout == "xyxy-excl":
        x, y = a, b
        w, h = c - a, d - b
    elif layout == "xyxy-incl":
        x, y = a, b
        w, h = c - a + 1, d - b + 1
    else:
        raise ValueError(layout)
    if w <= 0 or h <= 0:
        return None
    return {"x": x, "y": y, "w": w, "h": h}


def valid_candidate_rect(rect: dict, width: int, height: int) -> bool:
    x, y, w, h = rect["x"], rect["y"], rect["w"], 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 + 8 and y + h <= height + 8


def valid_source_rect(rect: dict, width: int, height: int) -> bool:
    x, y, w, h = rect["x"], rect["y"], rect["w"], 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 + 16 and y + h <= height + 16


def signed32(value: int) -> int:
    value &= 0xFFFFFFFF
    return value - 0x100000000 if value & 0x80000000 else value


def decode_rect_sequence(
    exe: bytes,
    base: int,
    spec: tuple[str, str, str, int, int],
    count: int,
    width: int,
    height: int,
) -> list[dict] | None:
    _name, scalar, layout, stride, field_offset = spec
    rects = []
    for index in range(count):
        values = decode_values(exe, base + index * stride + field_offset, scalar, 4)
        if values is None:
            return None
        rect = values_to_rect(values, layout)
        if rect is None or not valid_candidate_rect(rect, width, height):
            return None
        rects.append(rect)
    return rects


def single_rect_score(candidate: dict, expected: dict) -> tuple[float, bool, str]:
    cx2, cy2 = rect_end(candidate)
    ex2, ey2 = rect_end(expected)
    cw, ch = int(candidate["w"]), int(candidate["h"])
    ew, eh = int(expected["w"]), int(expected["h"])
    expected_area = max(1, ew * eh)
    candidate_area = max(1, cw * ch)

    contains = (
        int(candidate["x"]) <= int(expected["x"])
        and int(candidate["y"]) <= int(expected["y"])
        and cx2 >= ex2
        and cy2 >= ey2
    )
    if contains:
        margin = (
            int(expected["x"]) - int(candidate["x"])
            + int(expected["y"]) - int(candidate["y"])
            + cx2
            - ex2
            + cy2
            - ey2
        )
        area_ratio = candidate_area / expected_area
        score = margin + max(0.0, area_ratio - 1.0) * 12.0
        matched = margin <= max(80, int((ew + eh) * 1.4)) and area_ratio <= 8.0
        return score, matched, "contains"

    delta = (
        abs(int(candidate["x"]) - int(expected["x"]))
        + abs(int(candidate["y"]) - int(expected["y"]))
        + abs(cw - ew)
        + abs(ch - eh)
    )
    threshold = max(18, int((ew + eh) * 0.32))
    return float(delta), delta <= threshold, "near"


def ordered_match_score(candidates: list[dict], expected: list[dict]) -> dict:
    scores = []
    match_count = 0
    contains_count = 0
    for cand, exp in zip(candidates, expected):
        score, matched, mode = single_rect_score(cand, exp)
        scores.append(score)
        if matched:
            match_count += 1
        if mode == "contains":
            contains_count += 1
    avg = sum(scores) / max(1, len(scores))
    return {
        "mode": "ordered",
        "matched": match_count,
        "contains": contains_count,
        "avgScore": round(avg, 3),
        "totalScore": round(sum(scores), 3),
    }


def unordered_match_score(candidates: list[dict], expected: list[dict]) -> dict:
    remaining = set(range(len(expected)))
    scores = []
    match_count = 0
    contains_count = 0
    pairs = []
    for cand_index, cand in enumerate(candidates):
        best = None
        for exp_index in remaining:
            score, matched, mode = single_rect_score(cand, expected[exp_index])
            item = (score, matched, mode, exp_index)
            if best is None or item < best:
                best = item
        if best is None:
            continue
        score, matched, mode, exp_index = best
        remaining.remove(exp_index)
        scores.append(score)
        pairs.append([cand_index, exp_index])
        if matched:
            match_count += 1
        if mode == "contains":
            contains_count += 1
    avg = sum(scores) / max(1, len(scores))
    return {
        "mode": "unordered-greedy",
        "matched": match_count,
        "contains": contains_count,
        "avgScore": round(avg, 3),
        "totalScore": round(sum(scores), 3),
        "pairs": pairs[:16],
    }


def table_candidate_score(candidates: list[dict], expected: list[dict]) -> dict:
    ordered = ordered_match_score(candidates, expected)
    if len(expected) <= MAX_UNORDERED_RECTS:
        unordered = unordered_match_score(candidates, expected)
        best = unordered if unordered["matched"] > ordered["matched"] else ordered
        if unordered["matched"] == ordered["matched"] and unordered["avgScore"] < ordered["avgScore"]:
            best = unordered
    else:
        best = ordered
    best = dict(best)
    best["matchRatio"] = round(best["matched"] / max(1, len(expected)), 3)
    return best


def variable_count_table_score(candidates: list[dict], expected: list[dict]) -> dict:
    if not candidates or not expected:
        return {
            "mode": "none",
            "matched": 0,
            "contains": 0,
            "avgScore": 0,
            "totalScore": 0,
            "matchRatio": 0,
            "candidateMatchRatio": 0,
            "expectedMatchRatio": 0,
        }
    if len(candidates) == len(expected):
        score = table_candidate_score(candidates, expected)
        score["candidateMatchRatio"] = score["matchRatio"]
        score["expectedMatchRatio"] = score["matchRatio"]
        return score
    unordered = unordered_match_score(candidates, expected)
    matched = int(unordered["matched"])
    unordered["candidateMatchRatio"] = round(matched / max(1, len(candidates)), 3)
    unordered["expectedMatchRatio"] = round(matched / max(1, len(expected)), 3)
    unordered["matchRatio"] = min(unordered["candidateMatchRatio"], unordered["expectedMatchRatio"])
    return unordered


def padding_is_zero(exe: bytes, start: int, end: int) -> bool:
    return start <= end and all(value == 0 for value in exe[start:end])


def decode_adjacent_frame_table(exe: bytes, sections: list[dict], row: dict) -> list[dict]:
    descriptor_offset = row.get("descriptorFileOffset")
    string_va = row.get("stringVa")
    if not isinstance(descriptor_offset, int) or not isinstance(string_va, int):
        return []
    string_offset = va_to_offset(sections, string_va)
    if not isinstance(string_offset, int):
        return []
    cns = str(row["cns"])
    table_start = string_offset + len(cns.encode("ascii")) + 1
    descriptor_record_start = descriptor_offset - 16
    if descriptor_record_start <= table_start:
        return []

    width = int(row["width"])
    height = int(row["height"])
    expected = row.get("componentRects") or []
    table_start_va = offset_to_va(sections, table_start)
    descriptor_plus4 = (row.get("descriptorValues") or {}).get("plus4")
    candidates = []
    for stride, label, extra_count in [
        (24, "u32-xyxy-excl-offset2", 2),
        (20, "u32-xyxy-excl-offset1", 1),
        (16, "u32-xyxy-excl", 0),
    ]:
        byte_len = descriptor_record_start - table_start
        count = byte_len // stride
        trailing_len = byte_len - count * stride
        if count <= 0 or trailing_len > 7:
            continue
        trailing = exe[table_start + count * stride : descriptor_record_start]
        zero_padding = padding_is_zero(exe, table_start + count * stride, descriptor_record_start)
        if trailing_len <= 3 and not zero_padding:
            continue
        if trailing_len > 3 and trailing_len % 4 != 0:
            continue
        rects = []
        valid = True
        for index in range(count):
            values = decode_values(exe, table_start + index * stride, "u32", 4 + extra_count)
            if values is None:
                valid = False
                break
            x1, y1, x2, y2 = [int(value) for value in values[:4]]
            rect = {"x": x1, "y": y1, "w": x2 - x1, "h": y2 - y1}
            if not valid_source_rect(rect, width, height):
                valid = False
                break
            if extra_count:
                rect["offsets"] = [signed32(value) for value in values[4:]]
            rects.append(rect)
        if not valid:
            continue
        score = variable_count_table_score(rects, expected)
        candidates.append(
            {
                "schema": label,
                "stride": stride,
                "frameCount": count,
                "targetFrameCount": row.get("targetFrameCount"),
                "autoComponentCount": row.get("autoComponentCount"),
                "paddingBytes": trailing_len if zero_padding else 0,
                "trailingMetadataBytes": 0 if zero_padding else trailing_len,
                "trailingMetadataDwords": list(struct.unpack("<" + "I" * (trailing_len // 4), trailing))
                if trailing_len and not zero_padding
                else [],
                "tableStartFileOffset": table_start,
                "tableStartFileOffsetHex": f"0x{table_start:06x}",
                "tableStartVaHex": hex32(table_start_va),
                "descriptorRecordStartFileOffsetHex": f"0x{descriptor_record_start:06x}",
                "descriptorPlus4VaHex": hex32(descriptor_plus4),
                "descriptorPlus4PointsToTableStart": descriptor_plus4 == table_start_va,
                "score": score,
                "rectSample": rects[: min(6, len(rects))],
                "rects": rects,
            }
        )
    candidates.sort(
        key=lambda item: (
            item["frameCount"] != row.get("targetFrameCount"),
            item["frameCount"] != row.get("autoComponentCount"),
            -int(item["descriptorPlus4PointsToTableStart"]),
            -item["score"]["matched"],
            item["score"]["avgScore"],
            item["stride"],
        )
    )
    return candidates


def keep_candidate(score: dict, count: int) -> bool:
    matched = int(score["matched"])
    ratio = float(score["matchRatio"])
    avg = float(score["avgScore"])
    if count <= 4:
        return matched == count and avg <= 34
    if count <= 7:
        return matched >= count - 1 and ratio >= 0.78 and avg <= 54
    if count <= 14:
        return ratio >= 0.68 and matched >= 6 and avg <= 72
    return ratio >= 0.56 and matched >= 9 and avg <= 90


def scan_local_rect_tables(exe: bytes, sections: list[dict], row: dict) -> list[dict]:
    ref_offset = row.get("descriptorFileOffset")
    expected = row.get("componentRects") or []
    if not isinstance(ref_offset, int) or not expected:
        return []
    width = int(row["width"])
    height = int(row["height"])
    count = len(expected)
    specs = U16_SPECS + U32_SPECS
    start = max(0, ref_offset - LOCAL_WINDOW_BYTES)
    end = min(len(exe), ref_offset + LOCAL_WINDOW_BYTES)
    hits = []
    for spec in specs:
        name, scalar, _layout, stride, _field_offset = spec
        alignment = 2 if scalar == "u16" else 1
        table_size = (count - 1) * stride + (16 if scalar == "u32" else 8)
        local_end = end - table_size
        if local_end <= start:
            continue
        base = start - (start % alignment)
        while base <= local_end:
            rects = decode_rect_sequence(exe, base, spec, count, width, height)
            if rects is not None:
                score = table_candidate_score(rects, expected)
                if keep_candidate(score, count):
                    va = offset_to_va(sections, base)
                    hits.append(
                        {
                            "asset": row["asset"],
                            "spec": name,
                            "scalar": scalar,
                            "stride": stride,
                            "fileOffset": base,
                            "fileOffsetHex": f"0x{base:06x}",
                            "vaHex": hex32(va),
                            "relToDescriptorBytes": base - ref_offset,
                            "rectCount": count,
                            "score": score,
                            "decodedSample": rects[: min(5, len(rects))],
                            "expectedSample": expected[: min(5, len(expected))],
                        }
                    )
            base += alignment
    hits.sort(
        key=lambda hit: (
            -hit["score"]["matched"],
            hit["score"]["avgScore"],
            abs(hit["relToDescriptorBytes"]),
            hit["spec"],
        )
    )
    return hits[:MAX_LOCAL_HITS_PER_ASSET]


def exact_tuple_hits(exe: bytes, sections: list[dict], row: dict) -> list[dict]:
    expected = row.get("componentRects") or []
    if not expected:
        return []
    needles: dict[bytes, dict] = {}
    for index, rect in enumerate(expected):
        x, y, w, h = rect["x"], rect["y"], rect["w"], rect["h"]
        x2, y2 = x + w, y + h
        variants = {
            "u16-xywh": (x, y, w, h),
            "u16-xyxy-excl": (x, y, x2, y2),
            "u16-xyxy-incl": (x, y, x2 - 1, y2 - 1),
        }
        for label, values in variants.items():
            if all(0 <= value <= 0xFFFF for value in values):
                needles[b"".join(struct.pack("<H", value) for value in values)] = {
                    "frameIndex": index,
                    "variant": label,
                    "values": list(values),
                }
    hits = []
    for needle, meta in needles.items():
        pos = 0
        while True:
            hit = exe.find(needle, pos)
            if hit < 0:
                break
            va = offset_to_va(sections, hit)
            if va is not None:
                hits.append(
                    {
                        "frameIndex": meta["frameIndex"],
                        "variant": meta["variant"],
                        "values": meta["values"],
                        "fileOffset": hit,
                        "fileOffsetHex": f"0x{hit:06x}",
                        "vaHex": hex32(va),
                        "relToDescriptorBytes": hit - row["descriptorFileOffset"]
                        if isinstance(row.get("descriptorFileOffset"), int)
                        else None,
                    }
                )
            pos = hit + 1
            if len(hits) >= 24:
                return hits
    hits.sort(key=lambda item: abs(item["relToDescriptorBytes"]) if isinstance(item.get("relToDescriptorBytes"), int) else 10**9)
    return hits[:24]


def group_local_hits(local_rows: list[dict]) -> list[dict]:
    groups: dict[tuple, list[dict]] = defaultdict(list)
    for row in local_rows:
        for hit in row.get("hits") or []:
            rel = int(hit["relToDescriptorBytes"])
            rel_bucket = int(round(rel / 16) * 16)
            key = (hit["spec"], hit["stride"], rel_bucket)
            groups[key].append(hit | {"asset": row["asset"]})
    out = []
    for (spec, stride, rel_bucket), hits in groups.items():
        if len(hits) < 2:
            continue
        out.append(
            {
                "spec": spec,
                "stride": stride,
                "relBucket": rel_bucket,
                "assetCount": len({hit["asset"] for hit in hits}),
                "avgMatched": round(sum(hit["score"]["matched"] for hit in hits) / len(hits), 3),
                "avgScore": round(sum(hit["score"]["avgScore"] for hit in hits) / len(hits), 3),
                "assets": sorted({hit["asset"] for hit in hits})[:24],
            }
        )
    out.sort(key=lambda item: (-item["assetCount"], item["avgScore"], item["spec"], abs(item["relBucket"])))
    return out[:40]


def run_scan(exe_path: Path, out_dir: Path) -> dict:
    exe = exe_path.read_bytes()
    sections = read_sections(exe)
    rows = build_rect_rows(out_dir)

    local_rows = []
    exact_rows = []
    for row in rows:
        adjacent_tables = decode_adjacent_frame_table(exe, sections, row)
        best_adjacent = adjacent_tables[0] if adjacent_tables else None
        hits = scan_local_rect_tables(exe, sections, row)
        exact_hits = exact_tuple_hits(exe, sections, row)
        local_rows.append(
            {
                "asset": row["asset"],
                "cns": row["cns"],
                "targetFrameCount": row["targetFrameCount"],
                "autoComponentCount": row["autoComponentCount"],
                "componentRectCount": len(row.get("componentRects") or []),
                "frameCountSource": row["frameCountSource"],
                "rectSetSource": row["rectSetSource"],
                "descriptorFileOffsetHex": row.get("descriptorFileOffsetHex"),
                "descriptorRefVaHex": row.get("descriptorRefVaHex"),
                "imageSize": f"{row['width']}x{row['height']}",
                "adjacentTableCount": len(adjacent_tables),
                "bestAdjacentTable": best_adjacent,
                "adjacentTables": adjacent_tables,
                "hitCount": len(hits),
                "hits": hits,
            }
        )
        if exact_hits:
            exact_rows.append(
                {
                    "asset": row["asset"],
                    "cns": row["cns"],
                    "exactTupleHitCount": len(exact_hits),
                    "hits": exact_hits,
                }
            )

    local_groups = group_local_hits(local_rows)
    hit_assets = [row for row in local_rows if row["hitCount"]]
    high_ratio_assets = [
        row
        for row in local_rows
        if row["hits"] and row["hits"][0]["score"]["matchRatio"] >= 0.8
    ]
    adjacent_assets = [row for row in local_rows if row["bestAdjacentTable"]]
    adjacent_target_matches = [
        row
        for row in adjacent_assets
        if row["bestAdjacentTable"]["frameCount"] == row["targetFrameCount"]
    ]
    adjacent_auto_matches = [
        row
        for row in adjacent_assets
        if row["bestAdjacentTable"]["frameCount"] == row["autoComponentCount"]
    ]
    adjacent_descriptor_bound = [
        row
        for row in adjacent_assets
        if row["bestAdjacentTable"]["descriptorPlus4PointsToTableStart"]
    ]
    adjacent_stride_counts = Counter(row["bestAdjacentTable"]["stride"] for row in adjacent_assets)
    adjacent_schema_counts = Counter(row["bestAdjacentTable"]["schema"] for row in adjacent_assets)
    adjacent_mismatches = [
        {
            "asset": row["asset"],
            "targetFrameCount": row["targetFrameCount"],
            "autoComponentCount": row["autoComponentCount"],
            "adjacentFrameCount": row["bestAdjacentTable"]["frameCount"],
            "schema": row["bestAdjacentTable"]["schema"],
            "frameCountSource": row["frameCountSource"],
            "rectSetSource": row["rectSetSource"],
        }
        for row in adjacent_assets
        if row["bestAdjacentTable"]["frameCount"] != row["targetFrameCount"]
        or row["bestAdjacentTable"]["frameCount"] != row["autoComponentCount"]
    ]
    source_counts = Counter(row["frameCountSource"] for row in rows)
    rect_counts = Counter(len(row.get("componentRects") or []) for row in rows)

    promoted = (
        len(adjacent_assets) >= 60
        and len(adjacent_descriptor_bound) >= 60
        and len(adjacent_target_matches) >= 50
    )
    strongest_group = local_groups[0] if local_groups else None

    return {
        "scope": "Approximate monster frame rectangle pattern scan against Hwanse2.exe.",
        "source": [
            "Hwanse2.exe",
            "out/battle_enemy_candidates.json",
            "out/original_battle_resource_descriptors.json",
            "data/monster_frame_annotations.js",
            "extract_fld/*.cns",
        ],
        "status": "rect-pattern-scan-non-promoting" if not promoted else "rect-pattern-scan-promising-static-pattern",
        "rowCount": len(rows),
        "localWindowBytes": LOCAL_WINDOW_BYTES,
        "assetsWithAdjacentFrameTables": len(adjacent_assets),
        "adjacentTablesDescriptorBound": len(adjacent_descriptor_bound),
        "adjacentTablesTargetFrameCountMatches": len(adjacent_target_matches),
        "adjacentTablesAutoComponentCountMatches": len(adjacent_auto_matches),
        "adjacentStrideDistribution": {str(key): value for key, value in sorted(adjacent_stride_counts.items())},
        "adjacentSchemaDistribution": dict(sorted(adjacent_schema_counts.items())),
        "adjacentFrameCountMismatches": adjacent_mismatches,
        "assetsWithLocalRectHits": len(hit_assets),
        "assetsWithHighRatioLocalRectHits": len(high_ratio_assets),
        "exactTupleAssetCount": len(exact_rows),
        "localPatternGroupCount": len(local_groups),
        "strongestLocalPatternGroup": strongest_group,
        "sourceCounts": dict(sorted(source_counts.items())),
        "componentRectCountDistribution": {str(key): value for key, value in sorted(rect_counts.items())},
        "localPatternGroups": local_groups,
        "localRows": local_rows,
        "exactTupleRows": exact_rows,
        "checks": {
            "frameRectCandidatesPrepared": len(rows) >= 60,
            "manualFrameCountsApplied": sum(1 for row in rows if row.get("manualAnnotated")) > 0,
            "autoComponentFallbackApplied": sum(1 for row in rows if not row.get("manualAnnotated")) > 0,
            "adjacentFrameTablesFound": len(adjacent_assets) >= 60,
            "descriptorPlus4BindsAdjacentFrameTables": len(adjacent_descriptor_bound) >= 60,
            "descriptorLocalRectCandidatesFound": len(hit_assets) > 0,
            "multiAssetLocalPatternFound": bool(local_groups),
            "originalFrameRectTableFound": promoted,
            "requiresRuntimeConsumerBeforePromotion": True,
        },
        "conclusion": (
            "The scan compares EXE-side rectangle-like records with connected-component bounding boxes from monster "
            "sprite sheets. It also decodes the CNS-adjacent table that sits between each CNS filename string and its "
            "resource descriptor. Descriptor +4 points back to that adjacent table for promoted candidates, which is "
            "strong static binding. Runtime animation/AI consumers are still needed before interpreting frame order as "
            "attack sequencing."
        ),
    }


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

    group_rows = []
    for group in summary["localPatternGroups"][:40]:
        group_rows.append(
            "<tr>"
            f"<td><code>{esc(group['spec'])}</code></td>"
            f"<td>{esc(group['stride'])}</td>"
            f"<td>{esc(group['relBucket']):}</td>"
            f"<td>{esc(group['assetCount'])}</td>"
            f"<td>{esc(group['avgMatched'])}</td>"
            f"<td>{esc(group['avgScore'])}</td>"
            f"<td><code>{esc(', '.join(group['assets']))}</code></td>"
            "</tr>"
        )

    local_rows = []
    for row in summary["localRows"]:
        table = row.get("bestAdjacentTable")
        table_text = "-"
        table_sample = ""
        if table:
            score = table["score"]
            table_text = (
                f"{table['schema']} count={table['frameCount']} stride={table['stride']} "
                f"bound={table['descriptorPlus4PointsToTableStart']} "
                f"matched={score['matched']}/{row['componentRectCount']} avg={score['avgScore']}"
            )
            table_sample = json.dumps(table["rectSample"], ensure_ascii=False)
        best = "-"
        sample = ""
        if row["hits"]:
            hit = row["hits"][0]
            score = hit["score"]
            best = (
                f"{hit['spec']} rel={hit['relToDescriptorBytes']:+d} "
                f"matched={score['matched']}/{hit['rectCount']} ratio={score['matchRatio']} avg={score['avgScore']}"
            )
            sample = json.dumps(hit["decodedSample"], ensure_ascii=False)
        local_rows.append(
            "<tr>"
            f"<td><code>{esc(row['asset'])}</code></td>"
            f"<td>{esc(row['imageSize'])}</td>"
            f"<td>{esc(row['componentRectCount'])}</td>"
            f"<td>{esc(row['rectSetSource'])}</td>"
            f"<td><code>{esc(table_text)}</code></td>"
            f"<td><code>{esc(table_sample)}</code></td>"
            f"<td>{esc(row['hitCount'])}</td>"
            f"<td><code>{esc(best)}</code></td>"
            f"<td><code>{esc(sample)}</code></td>"
            "</tr>"
        )

    exact_rows = []
    for row in summary["exactTupleRows"][:80]:
        hit = row["hits"][0]
        exact_rows.append(
            "<tr>"
            f"<td><code>{esc(row['asset'])}</code></td>"
            f"<td>{esc(row['exactTupleHitCount'])}</td>"
            f"<td><code>{esc(hit['variant'])}</code></td>"
            f"<td><code>{esc(hit['vaHex'])}</code></td>"
            f"<td>{esc(hit.get('relToDescriptorBytes'))}</td>"
            f"<td><code>{esc(hit['values'])}</code></td>"
            "</tr>"
        )

    return f"""<!doctype html>
<html lang=\"ko\">
<head>
  <meta charset=\"utf-8\">
  <title>Monster Frame Rect EXE Pattern Scan</title>
  <style>
    body {{ margin: 24px; background: #101214; color: #e8eef2; font: 14px system-ui, sans-serif; }}
    h1, h2 {{ margin: 20px 0 10px; }}
    p {{ max-width: 1120px; line-height: 1.5; }}
    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; }}
    code {{ color: #f4d675; white-space: pre-wrap; word-break: break-word; }}
    .summary {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 8px; max-width: 1180px; }}
    .summary div {{ border: 1px solid #303940; background: #151a1f; padding: 10px; }}
  </style>
</head>
<body>
  <h1>Monster Frame Rect EXE Pattern Scan</h1>
  <p>{esc(summary['conclusion'])}</p>
  <div class=\"summary\">
    <div>status<br><code>{esc(summary['status'])}</code></div>
    <div>rows<br>{esc(summary['rowCount'])}</div>
    <div>adjacent tables<br>{esc(summary['assetsWithAdjacentFrameTables'])}</div>
    <div>descriptor-bound tables<br>{esc(summary['adjacentTablesDescriptorBound'])}</div>
    <div>target-count matches<br>{esc(summary['adjacentTablesTargetFrameCountMatches'])}</div>
    <div>auto-count matches<br>{esc(summary['adjacentTablesAutoComponentCountMatches'])}</div>
    <div>local hits<br>{esc(summary['assetsWithLocalRectHits'])}</div>
    <div>high-ratio hits<br>{esc(summary['assetsWithHighRatioLocalRectHits'])}</div>
    <div>pattern groups<br>{esc(summary['localPatternGroupCount'])}</div>
    <div>exact tuple assets<br>{esc(summary['exactTupleAssetCount'])}</div>
  </div>
  <h2>Adjacent Frame Tables</h2>
  <table><thead><tr><th>asset</th><th>image</th><th>rects</th><th>source</th><th>adjacent table</th><th>table sample</th><th>local hits</th><th>best local</th><th>decoded local sample</th></tr></thead><tbody>{''.join(local_rows)}</tbody></table>
  <h2>Repeated Local Pattern Groups</h2>
  <table><thead><tr><th>spec</th><th>stride</th><th>rel bucket</th><th>assets</th><th>avg matched</th><th>avg score</th><th>sample assets</th></tr></thead><tbody>{''.join(group_rows) or '<tr><td colspan=\"7\">No repeated group</td></tr>'}</tbody></table>
  <h2>Exact Tight Tuple Hits</h2>
  <table><thead><tr><th>asset</th><th>hits</th><th>variant</th><th>VA</th><th>rel</th><th>values</th></tr></thead><tbody>{''.join(exact_rows) or '<tr><td colspan=\"6\">No exact tuple hits</td></tr>'}</tbody></table>
</body>
</html>
"""


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


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 / 'monster_frame_rect_exe_pattern_scan.json'}")
    print(
        f"rows={summary['rowCount']} localHits={summary['assetsWithLocalRectHits']} "
        f"highRatio={summary['assetsWithHighRatioLocalRectHits']} groups={summary['localPatternGroupCount']}"
    )
    return 0


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