#!/usr/bin/env python3
"""Scan EXE numeric context near battle resource descriptors.

This report is intentionally non-promoting. It records local dword patterns
around battle background and enemy/object sprite resource descriptors so the
current enemy-data gap is backed by a repeatable static scan instead of only
by absence language.
"""
from __future__ import annotations

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

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

from probe_exe_scene_tables import read_sections, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
EXE = ROOT / "Hwanse2.exe"

CONTEXT_DWORDS_BEFORE = 10
CONTEXT_DWORDS_AFTER = 14
CLOSE_PAIR_DISTANCE = 1024
PROMOTION_BLOCKER = "descriptor-context-only-no-handler-row-stride-or-runtime-edge"

DESCRIPTOR_CONSTANTS = {
    0x00000000,
    0x00000001,
    0x00000010,
    0x0000003F,
    0x000B300C,
    0x00100010,
    0x0011600C,
    0x00116011,
    0x001AD00C,
    0x001AD011,
    0x001C900C,
    0x001C9011,
    0x001C9014,
}


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 section_name_for_va(sections: list[dict], va: int) -> str:
    for section in sections:
        start = int(section["va"])
        end = start + int(section["raw_size"])
        if start <= va < end:
            return str(section["name"])
    return ""


def dword_at_va(data: bytes, sections: list[dict], va: int) -> int | None:
    offset = va_to_offset(sections, va)
    if offset is None or offset + 4 > len(data):
        return None
    return struct.unpack_from("<I", data, offset)[0]


def descriptor_rows(resource_descriptors: dict) -> list[dict]:
    return [
        row for row in resource_descriptors.get("resourceDescriptorRows") or []
        if row.get("class") in {
            "battle-background-tilemap",
            "battle-sprite-image",
            "enemy-object-sprite-image",
        }
        and isinstance(row.get("refVa"), int)
    ]


def string_names_by_va(resource_descriptors: dict) -> dict[int, str]:
    names: dict[int, str] = {}
    for row in resource_descriptors.get("resourceDescriptorRows") or []:
        string_va = row.get("stringVa")
        name = row.get("name")
        if isinstance(string_va, int) and name:
            names[string_va] = str(name)
    return names


def classify_value(value: int, string_names: dict[int, str]) -> str:
    if value in string_names:
        return "cns-string-pointer"
    if 0x00400000 <= value <= 0x00600000:
        return "exe-pointer"
    if value in DESCRIPTOR_CONSTANTS:
        return "descriptor-constant"
    if 0 <= value <= 9999:
        return "plain-small-integer"
    return "large-or-encoded"


def context_items(
    data: bytes,
    sections: list[dict],
    ref_va: int,
    string_names: dict[int, str],
) -> list[dict]:
    items = []
    for index in range(-CONTEXT_DWORDS_BEFORE, CONTEXT_DWORDS_AFTER + 1):
        va = ref_va + index * 4
        value = dword_at_va(data, sections, va)
        if value is None:
            continue
        cls = classify_value(value, string_names)
        item = {
            "relDword": index,
            "va": va,
            "vaHex": hex32(va),
            "value": value,
            "valueHex": hex32(value),
            "class": cls,
        }
        if value in string_names:
            item["cns"] = string_names[value]
        items.append(item)
    return items


def find_plain_numeric_runs(items: list[dict]) -> list[dict]:
    runs: list[dict] = []
    current: list[dict] = []
    for item in items + [{"class": "sentinel"}]:
        if item.get("class") in {"plain-small-integer", "descriptor-constant"}:
            current.append(item)
            continue
        if len(current) >= 4:
            values = [int(row["value"]) for row in current]
            non_constant_values = [value for value in values if value not in DESCRIPTOR_CONSTANTS]
            if non_constant_values:
                runs.append({
                    "startRelDword": current[0]["relDword"],
                    "endRelDword": current[-1]["relDword"],
                    "startVaHex": current[0]["vaHex"],
                    "endVaHex": current[-1]["vaHex"],
                    "length": len(current),
                    "values": values,
                    "nonConstantValueCount": len(non_constant_values),
                    "maxValue": max(values),
                    "promotable": False,
                    "promotionBlocker": PROMOTION_BLOCKER,
                })
        current = []
    return runs


def summarize_context(
    data: bytes,
    sections: list[dict],
    row: dict,
    string_names: dict[int, str],
) -> dict:
    ref_va = int(row["refVa"])
    items = context_items(data, sections, ref_va, string_names)
    class_counts = Counter(item["class"] for item in items)
    plain_runs = find_plain_numeric_runs(items)
    return {
        "name": row.get("name") or "",
        "class": row.get("class") or "",
        "shape": row.get("shape") or "",
        "refVa": ref_va,
        "refVaHex": row.get("refVaHex") or hex32(ref_va),
        "section": section_name_for_va(sections, ref_va),
        "dwordCount": len(items),
        "classCounts": dict(sorted(class_counts.items())),
        "plainNumericRunCount": len(plain_runs),
        "plainNumericRuns": plain_runs[:4],
        "promotableEnemyRow": False,
        "promotionBlocker": PROMOTION_BLOCKER,
    }


def context_by_ref(contexts: list[dict]) -> dict[int, dict]:
    return {
        int(context["refVa"]): context
        for context in contexts
        if isinstance(context.get("refVa"), int)
    }


def build_candidate_contexts(contexts: list[dict], battle_enemy_candidates: dict) -> list[dict]:
    by_ref = context_by_ref(contexts)
    rows = []
    for candidate in battle_enemy_candidates.get("candidates") or []:
        bg_ref = candidate.get("battleBackgroundRefVa")
        enemy_ref = candidate.get("enemyRefVa")
        if not isinstance(bg_ref, int) or not isinstance(enemy_ref, int):
            continue
        distance = int(candidate.get("refDistance") or abs(enemy_ref - bg_ref))
        bg_context = by_ref.get(bg_ref, {})
        enemy_context = by_ref.get(enemy_ref, {})
        background_runs = bg_context.get("plainNumericRuns") or []
        enemy_runs = enemy_context.get("plainNumericRuns") or []
        local_run_count = int(bg_context.get("plainNumericRunCount") or 0) + int(
            enemy_context.get("plainNumericRunCount") or 0
        )
        examples = []
        if background_runs:
            examples.append({
                "source": "battle-background-descriptor",
                "descriptorName": bg_context.get("name") or candidate.get("battleBackground") or "",
                "descriptorRefVaHex": bg_context.get("refVaHex") or hex32(bg_ref),
                "run": run_summary(background_runs[0]),
            })
        if enemy_runs:
            examples.append({
                "source": "enemy-sprite-descriptor",
                "descriptorName": enemy_context.get("name") or candidate.get("enemyCns") or "",
                "descriptorRefVaHex": enemy_context.get("refVaHex") or hex32(enemy_ref),
                "run": run_summary(enemy_runs[0]),
            })
        rows.append({
            "candidateId": candidate.get("candidateId") or "",
            "battleBackground": candidate.get("battleBackground") or "",
            "enemyCns": candidate.get("enemyCns") or "",
            "battleBackgroundRefVaHex": candidate.get("battleBackgroundRefVaHex") or hex32(bg_ref),
            "enemyRefVaHex": candidate.get("enemyRefVaHex") or hex32(enemy_ref),
            "refDistance": distance,
            "closeResourcePair": distance <= CLOSE_PAIR_DISTANCE,
            "backgroundDescriptorClass": bg_context.get("class") or "",
            "enemyDescriptorClass": enemy_context.get("class") or "",
            "backgroundPlainNumericRunCount": bg_context.get("plainNumericRunCount", 0),
            "enemyPlainNumericRunCount": enemy_context.get("plainNumericRunCount", 0),
            "localPlainNumericRunCount": local_run_count,
            "backgroundFirstNumericRun": run_summary(background_runs[0] if background_runs else None),
            "enemyFirstNumericRun": run_summary(enemy_runs[0] if enemy_runs else None),
            "localNumericRunExampleCount": len(examples),
            "localNumericRunExamples": examples,
            "numericEvidenceClass": (
                "descriptor-local-run-without-row-binding"
                if local_run_count
                else "no-local-plain-numeric-run"
            ),
            "promotableEnemyRow": False,
            "promotionBlocker": PROMOTION_BLOCKER,
        })
    return rows


def build_summary(
    data: bytes,
    sections: list[dict],
    resource_descriptors: dict,
    battle_enemy_candidates: dict,
) -> dict:
    strings = string_names_by_va(resource_descriptors)
    contexts = [
        summarize_context(data, sections, row, strings)
        for row in descriptor_rows(resource_descriptors)
    ]
    candidate_contexts = build_candidate_contexts(contexts, battle_enemy_candidates)
    class_counts = Counter(context["class"] for context in contexts)
    numeric_run_count = sum(int(context.get("plainNumericRunCount") or 0) for context in contexts)
    close_pair_count = sum(1 for row in candidate_contexts if row["closeResourcePair"])
    local_run_pair_count = sum(1 for row in candidate_contexts if row["localPlainNumericRunCount"] > 0)
    close_local_run_pair_count = sum(
        1 for row in candidate_contexts
        if row["closeResourcePair"] and row["localPlainNumericRunCount"] > 0
    )
    background_local_run_pair_count = sum(
        1 for row in candidate_contexts
        if row["backgroundPlainNumericRunCount"] > 0
    )
    enemy_local_run_pair_count = sum(
        1 for row in candidate_contexts
        if row["enemyPlainNumericRunCount"] > 0
    )
    max_local_run_count = max(
        (row["localPlainNumericRunCount"] for row in candidate_contexts),
        default=0,
    )
    contexts_with_runs = sum(1 for context in contexts if int(context.get("plainNumericRunCount") or 0) > 0)
    checks = {
        "descriptorNumericContextsScanned": len(contexts) > 0,
        "candidatePairNumericContextsScanned": len(candidate_contexts) > 0,
        "numericRunsObserved": numeric_run_count > 0,
        "numericRunsPromotedToEnemyRows": False,
        "enemyStatTableIdentified": False,
        "formationTableIdentified": False,
        "encounterTableIdentified": False,
        "rewardTableIdentified": False,
        "combatFormulaIdentified": False,
        "battleEntryExecutionIdentified": False,
    }
    return {
        "scope": "Static numeric context near battle resource descriptors and EXE-nearest sprite candidates.",
        "source": [
            "Hwanse2.exe",
            "out/original_battle_resource_descriptors.json",
            "out/battle_enemy_candidates.json",
        ],
        "promotionStatus": "numeric-context-scanned-no-original-row-promotion",
        "contextDwordsBefore": CONTEXT_DWORDS_BEFORE,
        "contextDwordsAfter": CONTEXT_DWORDS_AFTER,
        "closePairDistance": CLOSE_PAIR_DISTANCE,
        "descriptorContextCount": len(contexts),
        "descriptorClassCounts": dict(sorted(class_counts.items())),
        "candidatePairContextCount": len(candidate_contexts),
        "closeCandidatePairContextCount": close_pair_count,
        "candidateContextsWithLocalPlainNumericRuns": local_run_pair_count,
        "closeCandidateContextsWithLocalPlainNumericRuns": close_local_run_pair_count,
        "candidateContextsWithBackgroundPlainNumericRuns": background_local_run_pair_count,
        "candidateContextsWithEnemyPlainNumericRuns": enemy_local_run_pair_count,
        "maxLocalPlainNumericRunCount": max_local_run_count,
        "plainNumericRunCount": numeric_run_count,
        "descriptorContextsWithPlainNumericRuns": contexts_with_runs,
        "promotableEnemyRowCount": 0,
        "promotableRewardRowCount": 0,
        "checks": checks,
        "candidateContexts": candidate_contexts,
        "sampleDescriptorContexts": contexts[:18],
        "promotionBlocker": PROMOTION_BLOCKER,
        "conclusion": (
            f"Scanned {len(contexts)} battle-related resource descriptor contexts and "
            f"{len(candidate_contexts)} EXE-nearest battle/enemy candidate pairs. "
            f"{numeric_run_count} descriptor-local plain-small numeric runs were observed; "
            f"{local_run_pair_count} candidate pairs carry a local run and "
            f"{close_local_run_pair_count} of those are close resource pairs, but none are promoted to original enemy "
            "rows because they are descriptor-local patterns without a handler edge, row stride, formation binding, "
            "reward binding, or runtime execution proof."
        ),
    }


def run_text(run: dict) -> str:
    values = ",".join(str(value) for value in run.get("values") or [])
    return (
        f"{run.get('startVaHex')}..{run.get('endVaHex')} "
        f"len={run.get('length')} values={values}"
    )


def run_summary(run: dict | None) -> dict | None:
    if not isinstance(run, dict) or not run:
        return None
    return {
        "startVaHex": run.get("startVaHex"),
        "endVaHex": run.get("endVaHex"),
        "startRelDword": run.get("startRelDword"),
        "endRelDword": run.get("endRelDword"),
        "length": run.get("length"),
        "values": run.get("values") or [],
        "nonConstantValueCount": run.get("nonConstantValueCount", 0),
        "maxValue": run.get("maxValue"),
        "promotable": run.get("promotable") is True,
        "promotionBlocker": run.get("promotionBlocker") or PROMOTION_BLOCKER,
    }


def first_run_text(run: dict | None) -> str:
    return run_text(run) if isinstance(run, dict) and run else "-"


def markdown(summary: dict) -> str:
    lines = [
        "# Original Battle Numeric Context",
        "",
        summary["conclusion"],
        "",
        f"- promotion status: `{summary['promotionStatus']}`",
        f"- descriptor contexts: {summary['descriptorContextCount']}",
        f"- candidate pair contexts: {summary['candidatePairContextCount']}",
        f"- close candidate pairs (<= {summary['closePairDistance']} bytes): {summary['closeCandidatePairContextCount']}",
        f"- candidate pairs with local numeric runs: {summary['candidateContextsWithLocalPlainNumericRuns']}",
        f"- close candidate pairs with local numeric runs: {summary['closeCandidateContextsWithLocalPlainNumericRuns']}",
        f"- plain numeric runs: {summary['plainNumericRunCount']}",
        f"- promotable enemy rows: {summary['promotableEnemyRowCount']}",
        "",
        "## Checks",
        "",
        "| check | value |",
        "| --- | --- |",
    ]
    for key, value in summary["checks"].items():
        lines.append(f"| {key} | {value} |")
    lines.extend([
        "",
        "## Candidate Pair Contexts",
        "",
        "| candidate | background | enemy sprite | distance | close | numeric runs | first local run | class | promotion |",
        "| --- | --- | --- | ---: | --- | ---: | --- | --- | --- |",
    ])
    for row in summary["candidateContexts"]:
        first_run = row.get("backgroundFirstNumericRun") or row.get("enemyFirstNumericRun")
        lines.append(
            f"| {row['candidateId']} | `{row['battleBackground']}` | `{row['enemyCns']}` | "
            f"{row['refDistance']} | {row['closeResourcePair']} | {row['localPlainNumericRunCount']} | "
            f"{first_run_text(first_run)} | {row['numericEvidenceClass']} | {row['promotionBlocker']} |"
        )
    lines.extend([
        "",
        "## Descriptor Samples",
        "",
        "| class | name | ref | runs | first run | promotion |",
        "| --- | --- | --- | ---: | --- | --- |",
    ])
    for row in summary["sampleDescriptorContexts"]:
        runs = row.get("plainNumericRuns") or []
        first = run_text(runs[0]) if runs else "-"
        lines.append(
            f"| {row['class']} | `{row['name']}` | `{row['refVaHex']}` | "
            f"{row['plainNumericRunCount']} | {first} | {row['promotionBlocker']} |"
        )
    return "\n".join(lines) + "\n"


def html_page(summary: dict) -> str:
    check_rows = "".join(
        f"<tr><td>{html.escape(key)}</td><td>{html.escape(str(value))}</td></tr>"
        for key, value in summary["checks"].items()
    )
    candidate_rows = "".join(
        "<tr>"
        f"<td>{html.escape(row['candidateId'])}</td>"
        f"<td><code>{html.escape(row['battleBackground'])}</code></td>"
        f"<td><code>{html.escape(row['enemyCns'])}</code></td>"
        f"<td>{row['refDistance']}</td>"
        f"<td>{html.escape(str(row['closeResourcePair']))}</td>"
        f"<td>{row['localPlainNumericRunCount']}</td>"
        f"<td>{html.escape(first_run_text(row.get('backgroundFirstNumericRun') or row.get('enemyFirstNumericRun')))}</td>"
        f"<td>{html.escape(row['numericEvidenceClass'])}</td>"
        f"<td>{html.escape(row['promotionBlocker'])}</td>"
        "</tr>"
        for row in summary["candidateContexts"]
    )
    sample_rows = []
    for row in summary["sampleDescriptorContexts"]:
        runs = row.get("plainNumericRuns") or []
        sample_rows.append(
            "<tr>"
            f"<td>{html.escape(row['class'])}</td>"
            f"<td><code>{html.escape(row['name'])}</code></td>"
            f"<td><code>{html.escape(row['refVaHex'])}</code></td>"
            f"<td>{row['plainNumericRunCount']}</td>"
            f"<td>{html.escape(run_text(runs[0]) if runs else '-')}</td>"
            f"<td>{html.escape(row['promotionBlocker'])}</td>"
            "</tr>"
        )
    return "\n".join([
        "<!doctype html>",
        '<html lang="en">',
        "<head>",
        '  <meta charset="utf-8">',
        "  <title>Original Battle Numeric Context</title>",
        "  <style>body{font-family:system-ui,sans-serif;margin:24px;line-height:1.45;max-width:1200px}table{border-collapse:collapse;width:100%;margin:16px 0}td,th{border:1px solid #ddd;padding:6px 8px;text-align:left;vertical-align:top}th{background:#f5f5f5}code{white-space:nowrap}</style>",
        "</head>",
        "<body>",
        "  <h1>Original Battle Numeric Context</h1>",
        f"  <p>{html.escape(summary['conclusion'])}</p>",
        (
            "  <p><b>Counts:</b> "
            f"descriptor contexts {summary['descriptorContextCount']}, "
            f"candidate pairs {summary['candidatePairContextCount']}, "
            f"close pairs {summary['closeCandidatePairContextCount']}, "
            f"candidate pairs with local runs {summary['candidateContextsWithLocalPlainNumericRuns']}, "
            f"close local-run pairs {summary['closeCandidateContextsWithLocalPlainNumericRuns']}, "
            f"plain numeric runs {summary['plainNumericRunCount']}, "
            f"promotable enemy rows {summary['promotableEnemyRowCount']}; "
            f"status <code>{html.escape(summary['promotionStatus'])}</code>.</p>"
        ),
        "  <h2>Checks</h2>",
        f"  <table><thead><tr><th>check</th><th>value</th></tr></thead><tbody>{check_rows}</tbody></table>",
        "  <h2>Candidate Pair Contexts</h2>",
        f"  <table><thead><tr><th>candidate</th><th>background</th><th>enemy sprite</th><th>distance</th><th>close</th><th>numeric runs</th><th>first local run</th><th>class</th><th>promotion</th></tr></thead><tbody>{candidate_rows}</tbody></table>",
        "  <h2>Descriptor Samples</h2>",
        f"  <table><thead><tr><th>class</th><th>name</th><th>ref</th><th>runs</th><th>first run</th><th>promotion</th></tr></thead><tbody>{''.join(sample_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 / "original_battle_numeric_context.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "original_battle_numeric_context.md").write_text(markdown(summary), encoding="utf-8")
    (out_dir / "original_battle_numeric_context.html").write_text(html_page(summary), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    data = args.exe.read_bytes()
    sections = read_sections(data)
    summary = build_summary(
        data,
        sections,
        load_json(args.out_dir / "original_battle_resource_descriptors.json", {}),
        load_json(args.out_dir / "battle_enemy_candidates.json", {}),
    )
    write_outputs(summary, args.out_dir)
    print(f"wrote original battle numeric context -> {args.out_dir / 'original_battle_numeric_context.md'}")


if __name__ == "__main__":
    main()
