#!/usr/bin/env python3
"""Probe EXE-side patterns for monster sprite frame counts.

This scan is deliberately conservative. User-confirmed annotations are used
first, and remaining monsters fall back to alpha connected-component counts
from extracted PNGs. The result is only a pattern-finding report, not proof of
an original frame table.
"""
from __future__ import annotations

import argparse
import html
import json
import re
import struct
import zlib
from collections import Counter
from pathlib import Path
from typing import Any

from probe_exe_scene_tables import read_sections, va_to_offset, offset_to_va


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

MIN_COMPONENT_AREA = 100
LOCAL_WINDOW_BYTES = 0x300
SEQUENCE_MIN_LEN = 4


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:
    if not isinstance(value, int):
        return "-"
    return f"0x{value:08x}"


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


def parse_frame_annotations(path: Path) -> dict[str, dict]:
    text = path.read_text(encoding="utf-8")
    entries: dict[str, dict] = {}
    for match in re.finditer(r"\{\s*asset:\s*\"([^\"]+)\"(?P<body>.*?)(?=\n    \{|\n  \]\n\};)", text, re.S):
        key = match.group(1)
        body = match.group("body")
        total = re.search(r"totalFrames:\s*(\d+)", body)
        if not total:
            continue
        matte_colors = [
            {"hex": color.group(1).lower(), "label": color.group(2)}
            for color in re.finditer(r"\{\s*hex:\s*\"(#[0-9a-fA-F]{6})\",\s*label:\s*\"([^\"]+)\"", body)
        ]
        entries[key] = {
            "asset": key,
            "totalFrames": int(total.group(1)),
            "autoComponentCountConfirmed": "autoComponentCountConfirmed: true" in body,
            "autoComponentOverSegmented": "autoComponentOverSegmented: true" in body,
            "autoComponentCount": int(re.search(r"autoComponentCount:\s*(\d+)", body).group(1))
            if re.search(r"autoComponentCount:\s*(\d+)", body)
            else None,
            "detachedEffectComponentCount": int(re.search(r"detachedEffectComponentCount:\s*(\d+)", body).group(1))
            if re.search(r"detachedEffectComponentCount:\s*(\d+)", body)
            else 0,
            "matteColors": matte_colors,
            "matteTolerance": int(re.search(r"matteTolerance:\s*(\d+)", body).group(1))
            if re.search(r"matteTolerance:\s*(\d+)", body)
            else 0,
        }
    return entries


def read_png_rgba(path: Path) -> tuple[int, int, list[bytes]]:
    data = path.read_bytes()
    if data[:8] != b"\x89PNG\r\n\x1a\n":
        raise ValueError(f"not png: {path}")
    pos = 8
    width = height = bit_depth = color_type = 0
    idat: list[bytes] = []
    while pos < len(data):
        size = struct.unpack_from(">I", data, pos)[0]
        pos += 4
        kind = data[pos : pos + 4]
        pos += 4
        chunk = data[pos : pos + size]
        pos += size + 4
        if kind == b"IHDR":
            width, height, bit_depth, color_type, _comp, _filter, _interlace = struct.unpack(">IIBBBBB", chunk)
        elif kind == b"IDAT":
            idat.append(chunk)
        elif kind == b"IEND":
            break
    if bit_depth != 8 or color_type not in {2, 6}:
        raise ValueError(f"unsupported png format: {path} bit={bit_depth} color={color_type}")

    channels = 4 if color_type == 6 else 3
    stride = width * channels
    raw = zlib.decompress(b"".join(idat))
    rows: list[bytes] = []
    prev = bytearray(stride)
    index = 0
    for _y in range(height):
        filter_type = raw[index]
        index += 1
        scan = raw[index : index + stride]
        index += stride
        out = bytearray(stride)
        for x in range(stride):
            a = out[x - channels] if x >= channels else 0
            b = prev[x]
            c = prev[x - channels] if x >= channels else 0
            value = scan[x]
            if filter_type == 0:
                out[x] = value
            elif filter_type == 1:
                out[x] = (value + a) & 0xFF
            elif filter_type == 2:
                out[x] = (value + b) & 0xFF
            elif filter_type == 3:
                out[x] = (value + ((a + b) // 2)) & 0xFF
            elif filter_type == 4:
                p = a + b - c
                pa = abs(p - a)
                pb = abs(p - b)
                pc = abs(p - c)
                pr = a if pa <= pb and pa <= pc else (b if pb <= pc else c)
                out[x] = (value + pr) & 0xFF
            else:
                raise ValueError(f"unsupported png filter: {filter_type}")
        if channels == 3:
            rgba = bytearray(width * 4)
            for x in range(width):
                rgba[x * 4 : x * 4 + 3] = out[x * 3 : x * 3 + 3]
                rgba[x * 4 + 3] = 255
            rows.append(bytes(rgba))
        else:
            rows.append(bytes(out))
        prev = out
    return width, height, rows


def parse_color(hex_text: str) -> tuple[int, int, int]:
    text = hex_text.strip().lstrip("#")
    return int(text[:2], 16), int(text[2:4], 16), int(text[4:6], 16)


def is_visible_pixel(row: bytes, x: int, matte_colors: list[tuple[int, int, int]], tolerance: int) -> bool:
    idx = x * 4
    alpha = row[idx + 3]
    if alpha == 0:
        return False
    r, g, b = row[idx], row[idx + 1], row[idx + 2]
    for mr, mg, mb in matte_colors:
        if abs(r - mr) <= tolerance and abs(g - mg) <= tolerance and abs(b - mb) <= tolerance:
            return False
    return True


def connected_components(path: Path, annotation: dict | None) -> tuple[int, int, list[dict]]:
    width, height, rows = read_png_rgba(path)
    matte_colors = [parse_color(item["hex"]) for item in (annotation or {}).get("matteColors", [])]
    tolerance = int((annotation or {}).get("matteTolerance") or 0)
    visited = bytearray(width * height)
    components: list[dict] = []

    def opaque_at(x: int, y: int) -> bool:
        return is_visible_pixel(rows[y], x, matte_colors, tolerance)

    for y in range(height):
        for x in range(width):
            start = y * width + x
            if visited[start] or not opaque_at(x, y):
                continue
            stack = [(x, y)]
            visited[start] = 1
            min_x = max_x = x
            min_y = max_y = y
            area = 0
            while stack:
                cx, cy = stack.pop()
                area += 1
                min_x = min(min_x, cx)
                max_x = max(max_x, cx)
                min_y = min(min_y, cy)
                max_y = max(max_y, cy)
                for dy in (-1, 0, 1):
                    for dx in (-1, 0, 1):
                        if dx == 0 and dy == 0:
                            continue
                        nx = cx + dx
                        ny = cy + dy
                        if nx < 0 or ny < 0 or nx >= width or ny >= height:
                            continue
                        ni = ny * width + nx
                        if visited[ni] or not opaque_at(nx, ny):
                            continue
                        visited[ni] = 1
                        stack.append((nx, ny))
            if area >= MIN_COMPONENT_AREA:
                components.append({
                    "x": min_x,
                    "y": min_y,
                    "w": max_x - min_x + 1,
                    "h": max_y - min_y + 1,
                    "area": area,
                })
    components.sort(key=lambda item: (item["y"], item["x"], -item["area"]))
    for index, component in enumerate(components):
        component["index"] = index
    return width, height, components


def build_frame_count_rows(
    battle_enemy_candidates: dict,
    annotations: dict[str, dict],
) -> list[dict]:
    rows = []
    for asset in sorted(battle_enemy_candidates.get("spriteAssets") or [], key=lambda row: row.get("enemyAssetKey") or ""):
        key = asset.get("enemyAssetKey") or asset_key(asset.get("enemyCns") or "")
        annotation = annotations.get(key)
        png = ROOT / "out" / f"{key}.png"
        width, height, components = connected_components(png, annotation if annotation else None)
        auto_count = len(components)
        target_count = int(annotation["totalFrames"]) if annotation else auto_count
        source = "manual-annotation" if annotation else "auto-component-fallback"
        if annotation and annotation.get("autoComponentOverSegmented"):
            source = "manual-overrides-auto-oversegmented"
        elif annotation and annotation.get("autoComponentCountConfirmed"):
            source = "manual-confirms-auto-component"
        elif annotation and annotation.get("matteColors"):
            source = "manual-with-matte-mask"
        rows.append({
            "asset": key,
            "cns": asset.get("enemyCns") or f"{key}.cns",
            "refVa": asset.get("enemyRefVa"),
            "refVaHex": asset.get("enemyRefVaHex"),
            "stringVa": None,
            "targetFrameCount": target_count,
            "autoComponentCount": auto_count,
            "frameCountSource": source,
            "manualAnnotated": annotation is not None,
            "autoMatchesTarget": auto_count == target_count,
            "autoComponentOverSegmented": bool(annotation and annotation.get("autoComponentOverSegmented")),
            "autoComponentCountConfirmed": bool(annotation and annotation.get("autoComponentCountConfirmed")),
            "matteMask": annotation.get("matteColors", []) if annotation else [],
            "width": width,
            "height": height,
            "componentSample": components[:5],
        })
    return rows


def descriptor_index(resource_descriptors: dict) -> dict[str, dict]:
    out: dict[str, dict] = {}
    for row in resource_descriptors.get("resourceDescriptorRows") or []:
        if row.get("class") != "enemy-object-sprite-image":
            continue
        key = asset_key(str(row.get("name") or ""))
        out.setdefault(key, row)
    return out


def dword_refs(exe: bytes, sections: list[dict], value: int) -> list[int]:
    needle = struct.pack("<I", value)
    offsets = []
    search = 0
    while True:
        hit = exe.find(needle, search)
        if hit < 0:
            break
        if offset_to_va(sections, hit) is not None:
            offsets.append(hit)
        search = hit + 1
    return offsets


def local_count_hits(exe: bytes, sections: list[dict], row: dict, ref_offset: int) -> list[dict]:
    count = int(row["targetFrameCount"])
    start = max(0, ref_offset - LOCAL_WINDOW_BYTES)
    end = min(len(exe), ref_offset + LOCAL_WINDOW_BYTES)
    hits = []
    encodings = [
        ("u8", bytes([count])),
        ("u16le", struct.pack("<H", count)),
        ("u32le", struct.pack("<I", count)),
    ]
    for kind, needle in encodings:
        pos = start
        while True:
            hit = exe.find(needle, pos, end)
            if hit < 0:
                break
            hits.append({
                "encoding": kind,
                "fileOffset": hit,
                "fileOffsetHex": f"0x{hit:06x}",
                "relToDescriptorBytes": hit - ref_offset,
                "vaHex": hex32(offset_to_va(sections, hit)),
            })
            pos = hit + 1
    hits.sort(key=lambda item: (abs(item["relToDescriptorBytes"]), item["encoding"], item["fileOffset"]))
    return hits[:24]


def sequence_order(rows: list[dict], mode: str) -> list[dict]:
    if mode == "asset":
        return sorted(rows, key=lambda row: row["asset"])
    if mode == "refVa":
        return sorted(rows, key=lambda row: int(row.get("refVa") or 0))
    if mode == "stringVa":
        return sorted(rows, key=lambda row: int(row.get("stringVa") or 0))
    raise ValueError(mode)


def find_sequence_hits(exe: bytes, sections: list[dict], rows: list[dict], mode: str) -> list[dict]:
    ordered = sequence_order(rows, mode)
    counts = [int(row["targetFrameCount"]) for row in ordered]
    hits: list[dict] = []
    for encoding, packer, step in [
        ("u8", lambda values: bytes(values), 1),
        ("u16le", lambda values: b"".join(struct.pack("<H", value) for value in values), 2),
        ("u32le", lambda values: b"".join(struct.pack("<I", value) for value in values), 4),
    ]:
        for length in range(min(12, len(counts)), SEQUENCE_MIN_LEN - 1, -1):
            for start_index in range(0, len(counts) - length + 1):
                values = counts[start_index : start_index + length]
                if len(set(values)) <= 1:
                    continue
                needle = packer(values)
                search = 0
                while True:
                    hit = exe.find(needle, search)
                    if hit < 0:
                        break
                    va = offset_to_va(sections, hit)
                    if va is not None:
                        hits.append({
                            "order": mode,
                            "encoding": encoding,
                            "length": length,
                            "startIndex": start_index,
                            "fileOffset": hit,
                            "fileOffsetHex": f"0x{hit:06x}",
                            "vaHex": hex32(va),
                            "values": values,
                            "assets": [row["asset"] for row in ordered[start_index : start_index + length]],
                            "byteStride": step,
                        })
                    search = hit + 1
            if hits:
                break
    hits.sort(key=lambda item: (-item["length"], item["order"], item["encoding"], item["fileOffset"]))
    return hits[:80]


def run_scan(exe_path: Path, out_dir: Path) -> dict:
    exe = exe_path.read_bytes()
    sections = read_sections(exe)
    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)

    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_by_asset = {row["asset"]: row for row in rows}
    local_rows = []
    for row in rows:
        ref_offset = row.get("descriptorFileOffset")
        if not isinstance(ref_offset, int):
            continue
        hits = local_count_hits(exe, sections, row, ref_offset)
        local_rows.append({
            "asset": row["asset"],
            "cns": row["cns"],
            "targetFrameCount": row["targetFrameCount"],
            "frameCountSource": row["frameCountSource"],
            "descriptorRefVaHex": row.get("descriptorRefVaHex"),
            "descriptorFileOffsetHex": row.get("descriptorFileOffsetHex"),
            "localHitCount": len(hits),
            "nearestLocalHits": hits[:8],
        })

    sequence_hits: list[dict] = []
    for mode in ["asset", "refVa", "stringVa"]:
        sequence_hits.extend(find_sequence_hits(exe, sections, rows, mode))

    source_counts = Counter(row["frameCountSource"] for row in rows)
    frame_counts = Counter(int(row["targetFrameCount"]) for row in rows)
    auto_match_count = sum(1 for row in rows if row["autoMatchesTarget"])
    manual_count = sum(1 for row in rows if row["manualAnnotated"])
    local_with_hits = sum(1 for row in local_rows if row["localHitCount"])
    exact_descriptor_count_fields = [
        row for row in local_rows
        if any(abs(hit["relToDescriptorBytes"]) <= 64 and hit["encoding"] in {"u16le", "u32le"} for hit in row["nearestLocalHits"])
    ]

    return {
        "scope": "Monster frame count 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": "pattern-scan-not-original-frame-table-proof",
        "rowCount": len(rows),
        "manualAnnotatedCount": manual_count,
        "autoFallbackCount": len(rows) - manual_count,
        "autoMatchesTargetCount": auto_match_count,
        "sourceCounts": dict(sorted(source_counts.items())),
        "frameCountDistribution": {str(key): value for key, value in sorted(frame_counts.items())},
        "sequenceHitCount": len(sequence_hits),
        "sequenceHits": sequence_hits,
        "localDescriptorRowsWithCountHits": local_with_hits,
        "localDescriptorRowsWithNearWordOrDwordHits": len(exact_descriptor_count_fields),
        "localDescriptorCountRows": local_rows,
        "rows": rows,
        "checks": {
            "frameCountsPrepared": len(rows) >= 60,
            "manualOverridesApplied": manual_count > 0,
            "autoFallbackApplied": len(rows) > manual_count,
            "sequencePatternFound": len(sequence_hits) > 0,
            "descriptorLocalCountPatternPromoted": False,
            "originalFrameTableFound": False,
        },
        "conclusion": (
            "Frame counts are prepared for all extracted enemy/object sprites using manual annotations first and auto "
            "component counts otherwise. The scan looks for local count values near EXE resource descriptors and for "
            "ordered frame-count sequences in asset/ref/string order. Any hits remain non-promoting until they also "
            "carry a row stride, frame rect/offset fields, animation use site, or runtime execution edge."
        ),
    }


def markdown(summary: dict) -> str:
    lines = [
        "# Monster Frame Count EXE Pattern Scan",
        "",
        summary["conclusion"],
        "",
        f"- status: `{summary['status']}`",
        f"- rows: {summary['rowCount']}",
        f"- manual annotated: {summary['manualAnnotatedCount']}",
        f"- auto fallback: {summary['autoFallbackCount']}",
        f"- auto matches target: {summary['autoMatchesTargetCount']}",
        f"- sequence hits: {summary['sequenceHitCount']}",
        f"- descriptor rows with local count hits: {summary['localDescriptorRowsWithCountHits']}",
        "",
        "## Source Counts",
        "",
    ]
    for key, value in summary["sourceCounts"].items():
        lines.append(f"- `{key}`: {value}")
    lines.extend(["", "## Sequence Hits", ""])
    if summary["sequenceHits"]:
        lines.extend([
            "| order | enc | len | va | values | assets |",
            "| --- | --- | ---: | --- | --- | --- |",
        ])
        for hit in summary["sequenceHits"][:30]:
            lines.append(
                f"| `{hit['order']}` | `{hit['encoding']}` | {hit['length']} | `{hit['vaHex']}` | "
                f"`{','.join(map(str, hit['values']))}` | `{', '.join(hit['assets'])}` |"
            )
    else:
        lines.append("No ordered frame-count byte/word/dword sequence hit was found.")
    lines.extend(["", "## Local Descriptor Count Hits", "", "| asset | count | source | hits | nearest |", "| --- | ---: | --- | ---: | --- |"])
    for row in summary["localDescriptorCountRows"]:
        nearest = ", ".join(
            f"{hit['encoding']}@{hit['relToDescriptorBytes']:+d}"
            for hit in row["nearestLocalHits"][:5]
        )
        lines.append(
            f"| `{row['asset']}` | {row['targetFrameCount']} | `{row['frameCountSource']}` | "
            f"{row['localHitCount']} | {nearest or '-'} |"
        )
    return "\n".join(lines) + "\n"


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

    rows = []
    for row in summary["rows"]:
        rows.append(
            "<tr>"
            f"<td><code>{esc(row['asset'])}</code></td>"
            f"<td>{esc(row['targetFrameCount'])}</td>"
            f"<td>{esc(row['autoComponentCount'])}</td>"
            f"<td>{esc(row['frameCountSource'])}</td>"
            f"<td>{esc(row.get('descriptorRefVaHex') or '-')}</td>"
            "</tr>"
        )
    hits = []
    for hit in summary["sequenceHits"][:80]:
        hits.append(
            "<tr>"
            f"<td>{esc(hit['order'])}</td><td>{esc(hit['encoding'])}</td><td>{esc(hit['length'])}</td>"
            f"<td><code>{esc(hit['vaHex'])}</code></td>"
            f"<td><code>{esc(','.join(map(str, hit['values'])))}</code></td>"
            f"<td><code>{esc(', '.join(hit['assets']))}</code></td>"
            "</tr>"
        )
    return f"""<!doctype html>
<html lang=\"ko\">
<head>
  <meta charset=\"utf-8\">
  <title>Monster Frame Count EXE Pattern Scan</title>
  <style>
    body {{ margin: 24px; background: #101214; color: #e8eef2; font: 14px system-ui, sans-serif; }}
    table {{ border-collapse: collapse; width: 100%; margin: 16px 0 28px; }}
    th, td {{ border: 1px solid #303940; padding: 6px 8px; text-align: left; vertical-align: top; }}
    th {{ background: #182028; }}
    code {{ color: #f4d675; }}
  </style>
</head>
<body>
  <h1>Monster Frame Count EXE Pattern Scan</h1>
  <p>{esc(summary['conclusion'])}</p>
  <ul>
    <li>status: <code>{esc(summary['status'])}</code></li>
    <li>rows: {esc(summary['rowCount'])}</li>
    <li>manual annotated: {esc(summary['manualAnnotatedCount'])}</li>
    <li>auto fallback: {esc(summary['autoFallbackCount'])}</li>
    <li>sequence hits: {esc(summary['sequenceHitCount'])}</li>
  </ul>
  <h2>Sequence Hits</h2>
  <table><thead><tr><th>order</th><th>enc</th><th>len</th><th>VA</th><th>values</th><th>assets</th></tr></thead><tbody>{''.join(hits) or '<tr><td colspan=\"6\">No hits</td></tr>'}</tbody></table>
  <h2>Frame Count Rows</h2>
  <table><thead><tr><th>asset</th><th>target</th><th>auto</th><th>source</th><th>descriptor ref</th></tr></thead><tbody>{''.join(rows)}</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_count_exe_pattern_scan.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "monster_frame_count_exe_pattern_scan.md").write_text(markdown(summary), encoding="utf-8")
    (out_dir / "monster_frame_count_exe_pattern_scan.html").write_text(html_page(summary), 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_count_exe_pattern_scan.md'}")
    print(
        f"rows={summary['rowCount']} manual={summary['manualAnnotatedCount']} "
        f"autoFallback={summary['autoFallbackCount']} sequenceHits={summary['sequenceHitCount']}"
    )
    return 0


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