#!/usr/bin/env python3
"""Summarize CP949 Korean text candidates embedded in Hwanse2.exe."""
from __future__ import annotations

import argparse
import html
import json
import re
import struct
import sys
from collections import Counter, defaultdict
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from probe_exe_scene_tables import offset_to_va, read_sections, va_to_offset
from exe_call_scanner import direct_text_calls


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


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


def is_hangul(char: str) -> bool:
    return "\u3131" <= char <= "\u318e" or "\uac00" <= char <= "\ud7a3"


def clean_text(text: str) -> str:
    return " ".join(text.replace("\r", " ").replace("\n", " ").split())


def section_for_va(sections: list[dict], va: int) -> dict | None:
    for section in sections:
        if section["va"] <= va < section["va"] + section["raw_size"]:
            return section
    return None


def classify_text(text: str) -> str:
    menu_words = ["기본기", "공격기", "도구", "무기", "방어구", "장비", "저장", "레벨", "경험치"]
    system_words = ["에러", "파일", "읽기", "저장", "메모리", "데이터"]
    if any(word in text for word in menu_words):
        return "menu-ui"
    if any(word in text for word in system_words):
        return "system-ui"
    if len(text) >= 18 or any(char in text for char in "!?.,"):
        return "dialogue-like"
    return "label"


def iter_candidate_strings(exe: bytes, sections: list[dict]) -> list[dict]:
    rows = []
    for section in sections:
        if section["name"] not in TEXT_SECTIONS:
            continue
        raw_start = section["raw"]
        raw = exe[raw_start: raw_start + section["raw_size"]]
        start = None
        for index, byte in enumerate(raw + b"\0"):
            if byte == 0:
                if start is not None and index - start >= 4:
                    blob = raw[start:index]
                    try:
                        decoded = blob.decode("cp949")
                    except UnicodeDecodeError:
                        decoded = blob.decode("cp949", "ignore")
                    text = clean_text(decoded)
                    hangul_count = sum(1 for char in text if is_hangul(char))
                    if (
                        hangul_count >= 2
                        and len(text) >= 2
                        and len(text) <= 160
                        and PRINTABLE_RE.match(text)
                    ):
                        va = offset_to_va(sections, raw_start + start)
                        if va is not None:
                            rows.append({
                                "va": va,
                                "vaHex": hex32(va),
                                "section": section["name"],
                                "byteLength": len(blob),
                                "text": text,
                                "hangulCount": hangul_count,
                                "classification": classify_text(text),
                            })
                start = None
                continue
            if start is None:
                start = index
    return rows


def find_value_refs(exe: bytes, sections: list[dict], values: set[int]) -> dict[int, list[dict]]:
    refs: dict[int, list[dict]] = defaultdict(list)
    if not values:
        return refs
    needles = {struct.pack("<I", value): value for value in values}
    for section in sections:
        if section["name"] not in REF_SECTIONS:
            continue
        raw_start = section["raw"]
        raw = exe[raw_start: raw_start + section["raw_size"]]
        for needle, value in needles.items():
            index = raw.find(needle)
            while index >= 0:
                ref_va = offset_to_va(sections, raw_start + index)
                if ref_va is not None:
                    refs[value].append({
                        "section": section["name"],
                        "refVa": ref_va,
                        "refVaHex": hex32(ref_va),
                        "aligned": index % 4 == 0,
                    })
                index = raw.find(needle, index + 1)
    return refs


def ref_clusters(candidates: list[dict], exe: bytes, sections: list[dict], text_calls: list[dict]) -> list[dict]:
    ref_rows = []
    for row in candidates:
        for ref in row.get("refs") or []:
            if ref.get("section") != ".data":
                continue
            ref_rows.append({
                "refVa": ref["refVa"],
                "refVaHex": ref["refVaHex"],
                "textVa": row["va"],
                "textVaHex": row["vaHex"],
                "text": row["text"],
                "classification": row["classification"],
            })
    ref_rows.sort(key=lambda row: row["refVa"])
    clusters = []
    current = []
    previous_va = None
    for row in ref_rows:
        if previous_va is None or row["refVa"] - previous_va <= 0x20:
            current.append(row)
        else:
            clusters.append(current)
            current = [row]
        previous_va = row["refVa"]
    if current:
        clusters.append(current)

    rows = []
    for cluster in clusters:
        start = cluster[0]["refVa"]
        end = cluster[-1]["refVa"]
        classifications = Counter(row["classification"] for row in cluster)
        inbound_refs = immediate_range_refs(exe, sections, text_calls, max(0, start - 0x40), end + 0x40, start, end)
        rows.append({
            "startVa": start,
            "startVaHex": hex32(start),
            "endVa": end,
            "endVaHex": hex32(end),
            "entryCount": len(cluster),
            "classificationCounts": dict(sorted(classifications.items())),
            "sampleTexts": [row["text"] for row in cluster[:8]],
            "entries": cluster[:24],
            "inboundRefCount": len(inbound_refs),
            "inboundRefs": inbound_refs[:12],
        })
    rows.sort(key=lambda row: (-row["entryCount"], row["startVa"]))
    return rows


def immediate_range_refs(
    exe: bytes,
    sections: list[dict],
    text_calls: list[dict],
    value_start: int,
    value_end: int,
    cluster_start: int,
    cluster_end: int,
) -> list[dict]:
    refs = []
    for section in sections:
        if section["name"] not in REF_SECTIONS:
            continue
        raw_start = section["raw"]
        raw = exe[raw_start: raw_start + section["raw_size"]]
        for index in range(0, max(0, len(raw) - 3)):
            value = struct.unpack_from("<I", raw, index)[0]
            if not (value_start <= value <= value_end):
                continue
            ref_va = offset_to_va(sections, raw_start + index)
            if ref_va is None or cluster_start <= ref_va <= cluster_end:
                continue
            refs.append({
                "section": section["name"],
                "refVa": ref_va,
                "refVaHex": hex32(ref_va),
                "valueHex": hex32(value),
                "aligned": index % 4 == 0,
                **(code_ref_context(exe, sections, text_calls, ref_va) if section["name"] == ".text" else {}),
            })
    refs.sort(key=lambda row: (row["section"] != ".text", row["refVa"]))
    return refs[:40]


def code_ref_context(
    exe: bytes,
    sections: list[dict],
    text_calls: list[dict],
    ref_va: int,
    before: int = 10,
    after: int = 18,
) -> dict:
    start_va = max(0, ref_va - before)
    end_va = ref_va + after
    start_off = va_to_offset(sections, start_va)
    end_off = va_to_offset(sections, end_va)
    if start_off is None or end_off is None:
        return {}
    data = exe[start_off:end_off]
    scan_start_va = max(0, ref_va - 48)
    scan_end_va = ref_va + 48
    calls = [
        {
            "callVaHex": call["callVaHex"],
            "targetVaHex": call["targetVaHex"],
            "deltaFromRef": call["callVa"] - ref_va,
            "scanMethod": call.get("scanMethod"),
        }
        for call in text_calls
        if scan_start_va <= call["callVa"] <= scan_end_va
    ]
    return {
        "codeContextVaHex": hex32(start_va),
        "codeContextBytes": data.hex(" "),
        "nearbyCalls": calls[:8],
    }


def build_summary(exe: bytes, limit_per_class: int = 80, exe_path: Path | None = None) -> dict:
    sections = read_sections(exe)
    text_calls, call_scan_method = direct_text_calls(exe, sections, exe_path)
    candidates = iter_candidate_strings(exe, sections)
    refs = find_value_refs(exe, sections, {row["va"] for row in candidates})
    by_class: dict[str, list[dict]] = defaultdict(list)
    for row in candidates:
        row_refs = refs.get(row["va"], [])
        row["refCount"] = len(row_refs)
        row["refs"] = row_refs[:12]
        by_class[row["classification"]].append(row)
    clusters = ref_clusters(candidates, exe, sections, text_calls)
    for rows in by_class.values():
        rows.sort(key=lambda row: (-row["refCount"], -row["hangulCount"], row["va"]))
    selected = []
    for name in sorted(by_class):
        selected.extend(by_class[name][:limit_per_class])
    selected.sort(key=lambda row: (row["classification"], -row["refCount"], row["va"]))
    counts = Counter(row["classification"] for row in candidates)
    referenced_counts = Counter(row["classification"] for row in candidates if row.get("refCount", 0) > 0)
    routine_candidates = text_routine_candidates(clusters, exe, sections)
    return {
        "scope": "CP949 Korean strings in Hwanse2.exe .rdata/.data sections",
        "candidateCount": len(candidates),
        "referencedCandidateCount": sum(1 for row in candidates if row.get("refCount", 0) > 0),
        "classificationCounts": dict(sorted(counts.items())),
        "referencedClassificationCounts": dict(sorted(referenced_counts.items())),
        "refClusterCount": len(clusters),
        "directTextCallScanMethod": call_scan_method,
        "directTextCallCount": len(text_calls),
        "refClusters": clusters[:32],
        "textRoutineCandidates": routine_candidates,
        "conclusion": (
            "This is a text-location index, not a decoded dialogue system. It identifies Korean strings and code/data "
            "references so the next pass can connect text display handlers and event script opcodes."
        ),
        "candidates": selected,
    }


def function_context(exe: bytes, sections: list[dict], va_hex: str, length: int = 48) -> dict:
    va = int(va_hex, 16)
    off = va_to_offset(sections, va)
    if off is None:
        return {}
    data = exe[off:off + length]
    return {
        "targetContextVaHex": va_hex,
        "targetContextBytes": data.hex(" "),
    }


def text_routine_candidates(clusters: list[dict], exe: bytes, sections: list[dict]) -> list[dict]:
    grouped: dict[str, dict] = {}
    for cluster in clusters:
        for ref in cluster.get("inboundRefs") or []:
            if ref.get("section") != ".text":
                continue
            for call in ref.get("nearbyCalls") or []:
                target = call["targetVaHex"]
                row = grouped.setdefault(target, {
                    "targetVaHex": target,
                    "callCount": 0,
                    "clusterCount": 0,
                    "clusters": set(),
                    "examples": [],
                })
                row["callCount"] += 1
                row["clusters"].add(cluster["startVaHex"])
                if len(row["examples"]) < 8:
                    row["examples"].append({
                        "clusterStartVaHex": cluster["startVaHex"],
                        "clusterSamples": cluster.get("sampleTexts", [])[:3],
                        "refVaHex": ref["refVaHex"],
                        "callVaHex": call["callVaHex"],
                    })
    rows = []
    for row in grouped.values():
        rows.append({
            **{key: value for key, value in row.items() if key != "clusters"},
            "clusterCount": len(row["clusters"]),
            "clusterStartVaHexes": sorted(row["clusters"]),
            **function_context(exe, sections, row["targetVaHex"]),
        })
    rows.sort(key=lambda row: (-row["callCount"], -row["clusterCount"], row["targetVaHex"]))
    return rows[:24]


def markdown(summary: dict) -> str:
    lines = [
        "# Korean Text Candidates",
        "",
        summary["conclusion"],
        "",
        f"- candidates: {summary['candidateCount']}",
        f"- referenced candidates: {summary['referencedCandidateCount']}",
        f"- classification counts: {json.dumps(summary['classificationCounts'], ensure_ascii=False)}",
        f"- referenced classification counts: {json.dumps(summary['referencedClassificationCounts'], ensure_ascii=False)}",
        f"- pointer table clusters: {summary.get('refClusterCount', 0)}",
        f"- direct call scan: {summary.get('directTextCallScanMethod', '-')} ({summary.get('directTextCallCount', 0)} calls)",
        "",
        "## Pointer Table Clusters",
        "",
        "| range | entries | classes | samples | inbound refs | first inbound refs |",
        "| --- | ---: | --- | --- | ---: | --- |",
    ]
    for row in summary.get("refClusters") or []:
        samples = ", ".join(row.get("sampleTexts") or []).replace("|", "\\|")
        inbound = ", ".join(
            f"{ref['refVaHex']}->{ref['valueHex']}"
            for ref in row.get("inboundRefs", [])[:4]
        ) or "-"
        lines.append(
            f"| `{row['startVaHex']}..{row['endVaHex']}` | {row['entryCount']} | "
            f"{json.dumps(row.get('classificationCounts') or {}, ensure_ascii=False)} | "
            f"{samples or '-'} | {row.get('inboundRefCount', 0)} | {inbound} |"
        )
    lines.extend([
        "",
        "## Text Code References",
        "",
        "| cluster | ref | value | nearby calls | code bytes |",
        "| --- | --- | --- | --- | --- |",
    ])
    code_rows = []
    for cluster in summary.get("refClusters") or []:
        for ref in cluster.get("inboundRefs") or []:
            if ref.get("section") != ".text" or not ref.get("codeContextBytes"):
                continue
            code_rows.append((cluster, ref))
    for cluster, ref in code_rows[:40]:
        calls = ", ".join(
            f"{call['callVaHex']}->{call['targetVaHex']}"
            for call in ref.get("nearbyCalls", [])[:3]
        ) or "-"
        lines.append(
            f"| `{cluster['startVaHex']}..{cluster['endVaHex']}` | `{ref['refVaHex']}` | "
            f"`{ref['valueHex']}` | {calls} | `{ref['codeContextBytes']}` |"
        )
    if not code_rows:
        lines.append("| - | - | - | - | - |")
    lines.extend([
        "",
        "## Text Routine Candidates",
        "",
        "| target | calls | clusters | target bytes | examples |",
        "| --- | ---: | ---: | --- | --- |",
    ])
    for row in summary.get("textRoutineCandidates") or []:
        examples = ", ".join(
            f"{example['callVaHex']} near {example['refVaHex']} ({'/'.join(example['clusterSamples'])})"
            for example in row.get("examples", [])[:4]
        )
        lines.append(
            f"| `{row['targetVaHex']}` | {row['callCount']} | {row['clusterCount']} | "
            f"`{row.get('targetContextBytes') or '-'}` | {examples or '-'} |"
        )
    if not summary.get("textRoutineCandidates"):
        lines.append("| - | - | - | - | - |")
    lines.extend([
        "",
        "## Strings",
        "",
        "| class | VA | refs | text | first refs |",
        "| --- | --- | ---: | --- | --- |",
    ])
    for row in summary["candidates"]:
        refs = ", ".join(ref["refVaHex"] for ref in row.get("refs", [])[:4]) or "-"
        text = row["text"].replace("|", "\\|")
        lines.append(
            f"| {row['classification']} | `{row['vaHex']}` | {row['refCount']} | {text} | {refs} |"
        )
    return "\n".join(lines) + "\n"


def html_page(summary: dict) -> str:
    rows = []
    for row in summary["candidates"]:
        refs = ", ".join(ref["refVaHex"] for ref in row.get("refs", [])[:6]) or "-"
        rows.append(
            "<tr>"
            f"<td>{html.escape(row['classification'])}</td>"
            f"<td><code>{html.escape(row['vaHex'])}</code></td>"
            f"<td>{row['refCount']}</td>"
            f"<td>{html.escape(row['text'])}</td>"
            f"<td>{html.escape(refs)}</td>"
            "</tr>"
        )
    code_rows = []
    for cluster in summary.get("refClusters") or []:
        for ref in cluster.get("inboundRefs") or []:
            if ref.get("section") != ".text" or not ref.get("codeContextBytes"):
                continue
            code_rows.append((cluster, ref))
    return "\n".join([
        "<!doctype html>",
        '<html lang="en">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>Korean 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>Korean Text Candidates</h1>",
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        f"  <p>candidates: {summary['candidateCount']}; referenced: {summary['referencedCandidateCount']}; "
        f"direct call scan: {html.escape(summary.get('directTextCallScanMethod', '-'))} "
        f"({summary.get('directTextCallCount', 0)} calls)</p>",
        "  <h2>Pointer Table Clusters</h2>",
        "  <table><thead><tr><th>range</th><th>entries</th><th>classes</th><th>samples</th><th>inbound refs</th><th>first inbound refs</th></tr></thead>",
        "  <tbody>"
        + "".join(
            "<tr>"
            f"<td><code>{html.escape(row['startVaHex'])}..{html.escape(row['endVaHex'])}</code></td>"
            f"<td>{row['entryCount']}</td>"
            f"<td>{html.escape(json.dumps(row.get('classificationCounts') or {}, ensure_ascii=False))}</td>"
            f"<td>{html.escape(', '.join(row.get('sampleTexts') or []))}</td>"
            f"<td>{row.get('inboundRefCount', 0)}</td>"
            f"<td>{html.escape(', '.join(f'{ref['refVaHex']}->{ref['valueHex']}' for ref in row.get('inboundRefs', [])[:4]) or '-')}</td>"
            "</tr>"
            for row in summary.get("refClusters") or []
        )
        + "</tbody></table>",
        "  <h2>Text Code References</h2>",
        "  <table><thead><tr><th>cluster</th><th>ref</th><th>value</th><th>nearby calls</th><th>code bytes</th></tr></thead>",
        "  <tbody>"
        + (
            "".join(
                "<tr>"
                f"<td><code>{html.escape(cluster['startVaHex'])}..{html.escape(cluster['endVaHex'])}</code></td>"
                f"<td><code>{html.escape(ref['refVaHex'])}</code></td>"
                f"<td><code>{html.escape(ref['valueHex'])}</code></td>"
                f"<td>{html.escape(', '.join(f'{call['callVaHex']}->{call['targetVaHex']}' for call in ref.get('nearbyCalls', [])[:3]) or '-')}</td>"
                f"<td><code>{html.escape(ref.get('codeContextBytes') or '-')}</code></td>"
                "</tr>"
                for cluster, ref in code_rows[:40]
            )
            or '<tr><td colspan="5">No text refs.</td></tr>'
        )
        + "</tbody></table>",
        "  <h2>Text Routine Candidates</h2>",
        "  <table><thead><tr><th>target</th><th>calls</th><th>clusters</th><th>target bytes</th><th>examples</th></tr></thead>",
        "  <tbody>"
        + (
            "".join(
                "<tr>"
                f"<td><code>{html.escape(row['targetVaHex'])}</code></td>"
                f"<td>{row['callCount']}</td>"
                f"<td>{row['clusterCount']}</td>"
                f"<td><code>{html.escape(row.get('targetContextBytes') or '-')}</code></td>"
                f"<td>{html.escape(', '.join(f'{example['callVaHex']} near {example['refVaHex']} ({'/'.join(example['clusterSamples'])})' for example in row.get('examples', [])[:4]) or '-')}</td>"
                "</tr>"
                for row in summary.get("textRoutineCandidates") or []
            )
            or '<tr><td colspan="5">No routine candidates.</td></tr>'
        )
        + "</tbody></table>",
        "  <h2>Strings</h2>",
        "  <table><thead><tr><th>class</th><th>VA</th><th>refs</th><th>text</th><th>first refs</th></tr></thead>",
        f"  <tbody>{''.join(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 / "korean_text_candidates.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--limit-per-class", type=int, default=80)
    args = parser.parse_args()
    summary = build_summary(args.exe.read_bytes(), args.limit_per_class, args.exe)
    write_outputs(summary, args.out_dir)


if __name__ == "__main__":
    main()
