#!/usr/bin/env python3
"""Scan decompressed CNS payloads for CP949 Korean text candidates."""
from __future__ import annotations

import argparse
import html
import json
import re
from collections import Counter
from pathlib import Path

from decode_cns import decompress_cns, parse_image
from summarize_korean_text_candidates import clean_text, classify_text, is_hangul


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
PRINTABLE_RE = re.compile(r"^[\u3131-\u318e\uac00-\ud7a3A-Za-z0-9 !?.,:;_+\-/()[\]{}'\"~%&*<>|=]+$")


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


def classify_decoded_payload(decoded: bytes) -> dict:
    if len(decoded) >= 4:
        width = int.from_bytes(decoded[0:2], "little")
        height = int.from_bytes(decoded[2:4], "little")
        expected_map = 4 + width * height * 4
        if 0 < width <= 256 and 0 < height <= 256 and len(decoded) == expected_map:
            return {"kind": "tilemap", "width": width, "height": height, "textScanStart": len(decoded)}
    if len(decoded) >= 2:
        width = decoded[0]
        height = decoded[1]
        expected_map = 2 + width * height * 4
        if 0 < width <= 256 and 0 < height <= 256 and len(decoded) == expected_map:
            return {
                "kind": "tilemap",
                "width": width,
                "height": height,
                "headerBytes": 2,
                "textScanStart": len(decoded),
            }
    try:
        width, height, palette, pixels, bpp = parse_image(decoded)
        stride = ((width * bpp + 31) // 32) * 4
        expected_pixels = stride * height
        if 0 < width <= 2048 and 0 < height <= 2048 and len(pixels) >= expected_pixels:
            palette_end = 8 + len(palette) * 4
            return {
                "kind": "image",
                "width": width,
                "height": height,
                "bpp": bpp,
                "paletteColors": len(palette),
                "textScanStart": min(len(decoded), palette_end + expected_pixels),
            }
    except Exception:
        pass
    return {"kind": "unknown", "textScanStart": 0}


def valid_cp949_char(data: bytes, index: int) -> int:
    byte = data[index]
    if byte in (0x09, 0x0A, 0x0D) or 0x20 <= byte <= 0x7E:
        return 1
    if byte >= 0x81 and index + 1 < len(data) and data[index + 1] != 0:
        pair = data[index:index + 2]
        try:
            char = pair.decode("cp949")
        except UnicodeDecodeError:
            return 0
        if is_hangul(char) or PRINTABLE_RE.match(char):
            return 2
    return 0


def scan_text_runs(data: bytes, base_offset: int = 0) -> list[dict]:
    rows = []
    index = 0
    while index < len(data):
        if data[index] == 0:
            index += 1
            continue
        start = index
        while index < len(data):
            step = valid_cp949_char(data, index)
            if not step:
                break
            index += step
        if index - start >= 4:
            blob = data[start:index]
            try:
                decoded = blob.decode("cp949")
            except UnicodeDecodeError:
                decoded = ""
            text = clean_text(decoded)
            hangul_count = sum(1 for char in text if is_hangul(char))
            if (
                hangul_count >= 2
                and 2 <= len(text) <= 180
                and PRINTABLE_RE.match(text)
            ):
                rows.append({
                    "decodedOffset": base_offset + start,
                    "decodedOffsetHex": hex32(base_offset + start),
                    "byteLength": len(blob),
                    "text": text,
                    "hangulCount": hangul_count,
                    "classification": classify_text(text),
                })
        index = max(index + 1, start + 1)
    return rows


def scan_file(path: Path) -> tuple[dict, list[dict]]:
    row = {
        "name": path.name,
        "decodedSize": None,
        "payloadKind": "unknown",
        "candidateCount": 0,
    }
    try:
        decoded = decompress_cns(path.read_bytes())
    except Exception as exc:
        row["error"] = str(exc)
        return row, []
    payload = classify_decoded_payload(decoded)
    row.update({
        "decodedSize": len(decoded),
        "payloadKind": payload["kind"],
        "width": payload.get("width"),
        "height": payload.get("height"),
    })
    scan_start = payload.get("textScanStart", 0)
    scan_data = decoded[scan_start:]
    row["textScanStart"] = scan_start
    row["textScanBytes"] = len(scan_data)
    candidates = scan_text_runs(scan_data, scan_start)
    for candidate in candidates:
        candidate["file"] = path.name
        candidate["payloadKind"] = payload["kind"]
        candidate["decodedSize"] = len(decoded)
    row["candidateCount"] = len(candidates)
    return row, candidates


def build_summary(src_dir: Path) -> dict:
    files = []
    candidates = []
    for path in sorted(src_dir.glob("*.cns")):
        file_row, file_candidates = scan_file(path)
        files.append(file_row)
        candidates.extend(file_candidates)
    candidates.sort(key=lambda row: (
        row["classification"],
        row["file"],
        row["decodedOffset"],
    ))
    payload_counts = Counter(row["payloadKind"] for row in files)
    class_counts = Counter(row["classification"] for row in candidates)
    return {
        "scope": "CP949 Korean string scan over decompressed CNS payloads",
        "source": str(src_dir),
        "scannedFileCount": len(files),
        "decodedFileCount": sum(1 for row in files if row.get("decodedSize") is not None),
        "candidateFileCount": sum(1 for row in files if row.get("candidateCount", 0) > 0),
        "candidateCount": len(candidates),
        "payloadKindCounts": dict(sorted(payload_counts.items())),
        "classificationCounts": dict(sorted(class_counts.items())),
        "conclusion": (
            "This scan checks text-bearing regions of decompressed CNS payloads for plain CP949 text. Known image "
            "pixels and exact tilemap bodies are skipped to avoid pixel-noise false positives. A zero or very small "
            "result means story dialogue is probably not stored as simple null-delimited CP949 strings in these "
            "payloads; it may be encoded in another script format or generated through executable-side tables."
        ),
        "filesWithCandidates": [row for row in files if row.get("candidateCount", 0) > 0],
        "candidates": candidates[:400],
    }


def html_page(summary: dict) -> str:
    file_rows = "".join(
        "<tr>"
        f"<td>{html.escape(row['name'])}</td>"
        f"<td>{html.escape(row['payloadKind'])}</td>"
        f"<td>{row.get('decodedSize') or '-'}</td>"
        f"<td>{row.get('candidateCount', 0)}</td>"
        "</tr>"
        for row in summary.get("filesWithCandidates") or []
    ) or '<tr><td colspan="4">No CNS files contained CP949 candidates.</td></tr>'
    candidate_rows = "".join(
        "<tr>"
        f"<td>{html.escape(row['classification'])}</td>"
        f"<td>{html.escape(row['file'])}</td>"
        f"<td><code>{html.escape(row['decodedOffsetHex'])}</code></td>"
        f"<td>{html.escape(row['text'])}</td>"
        "</tr>"
        for row in summary.get("candidates") or []
    ) or '<tr><td colspan="4">No text candidates.</td></tr>'
    return "\n".join([
        "<!doctype html>",
        '<html lang="en">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>CNS Text Candidates</title>",
        "  <style>body{font-family:system-ui,sans-serif;background:#101010;color:#eee;margin:24px}table{border-collapse:collapse;width:100%}td,th{border:1px solid #333;padding:6px 8px;vertical-align:top}th{background:#1d1d1d}code{color:#f5d76e}</style>",
        "</head>",
        "<body>",
        "  <h1>CNS Text Candidates</h1>",
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        f"  <p>scanned: {summary['scannedFileCount']}; decoded: {summary['decodedFileCount']}; candidates: {summary['candidateCount']}</p>",
        "  <h2>Files With Candidates</h2>",
        "  <table><thead><tr><th>file</th><th>kind</th><th>decoded bytes</th><th>candidates</th></tr></thead>",
        f"  <tbody>{file_rows}</tbody></table>",
        "  <h2>Candidates</h2>",
        "  <table><thead><tr><th>class</th><th>file</th><th>offset</th><th>text</th></tr></thead>",
        f"  <tbody>{candidate_rows}</tbody></table>",
        "</body>",
        "</html>",
        "",
    ])


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


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--src-dir", type=Path, default=ROOT / "extract_fld")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(args.src_dir)
    write_outputs(summary, args.out_dir)
    print(
        "wrote CNS text candidates -> "
        f"{args.out_dir / 'cns_text_candidates.html'} "
        f"({summary['candidateCount']} candidates)"
    )


if __name__ == "__main__":
    main()
