#!/usr/bin/env python3
"""Probe the remaining field-tick route gap for random encounters.

Previous reports separated the known pieces:

* movement/collision/tile mutation is grounded,
* shared RNG callsites are classified,
* opcode 0x1e is grounded as the battle actor formation consumer,
* resource runner calls are classified as generic resource consumers.

This report asks the narrower question: does any known field update/tick window
or direct callsite route to RNG, battle resource loading, or formation creation?
It is a boundary/negative-evidence report, not a new encounter heuristic.
"""
from __future__ import annotations

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


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

sys.path.insert(0, str(ROOT / "tools"))
from probe_exe_scene_tables import find_cns_strings, offset_to_va, read_sections, va_to_offset  # noqa: E402


KEY_TARGETS = {
    "shared-rng": 0x00427730,
    "generic-vm-rng-selector-handler": 0x0040BCC9,
    "generic-vm-rng-selector-callsite": 0x0040BDCC,
    "single-actor-handler": 0x0040BE38,
    "formation-actor-handler": 0x0040C084,
    "formation-handler-table": 0x00440720,
    "formation-handler-entry": 0x00440798,
    "resource-runner": 0x00423A2F,
    "map-resource-loader": 0x0042449C,
}


def h(value: Any) -> str:
    return html.escape("" if value is None else str(value), quote=True)


def hx(value: int | None) -> str | None:
    return None if value is None else f"0x{value:08x}"


def read_json(name: str, fallback: Any) -> Any:
    try:
        return json.loads((OUT / name).read_text(encoding="utf-8"))
    except FileNotFoundError:
        return fallback


def write_json(path: Path, payload: Any) -> None:
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def text_section(sections: list[dict[str, Any]]) -> dict[str, Any]:
    return next(section for section in sections if section["name"] == ".text")


def bytes_for_va(exe: bytes, sections: list[dict[str, Any]], start_va: int, end_va: int) -> bytes:
    start = va_to_offset(sections, start_va)
    end = va_to_offset(sections, end_va)
    if start is None or end is None or end <= start:
        return b""
    return exe[start:end]


def direct_calls_in_range(exe: bytes, sections: list[dict[str, Any]], start_va: int, end_va: int) -> list[dict[str, Any]]:
    section = text_section(sections)
    start = va_to_offset(sections, start_va)
    end = va_to_offset(sections, end_va)
    if start is None or end is None:
        return []
    lo = max(section["raw"], start)
    hi = min(section["raw"] + section["raw_size"], end)
    rows: list[dict[str, Any]] = []
    for offset in range(lo, max(lo, hi - 4)):
        if exe[offset] != 0xE8:
            continue
        source = offset_to_va(sections, offset)
        if source is None:
            continue
        rel = struct.unpack_from("<i", exe, offset + 1)[0]
        target = source + 5 + rel
        rows.append({"callVa": source, "callVaHex": hx(source), "targetVa": target, "targetVaHex": hx(target)})
    return rows


def all_direct_call_refs(exe: bytes, sections: list[dict[str, Any]], target: int) -> list[int]:
    section = text_section(sections)
    blob = exe[section["raw"] : section["raw"] + section["raw_size"]]
    rows: list[int] = []
    for offset in range(0, max(0, len(blob) - 4)):
        if blob[offset] != 0xE8:
            continue
        source = section["va"] + offset
        rel = struct.unpack_from("<i", blob, offset + 1)[0]
        if source + 5 + rel == target:
            rows.append(source)
    return rows


def exact_dword_refs(exe: bytes, sections: list[dict[str, Any]], target: int) -> list[dict[str, Any]]:
    needle = struct.pack("<I", target)
    rows: list[dict[str, Any]] = []
    for section in sections:
        blob = exe[section["raw"] : section["raw"] + section["raw_size"]]
        start = 0
        while True:
            offset = blob.find(needle, start)
            if offset < 0:
                break
            va = section["va"] + offset
            rows.append({"va": va, "vaHex": hx(va), "section": section["name"]})
            start = offset + 1
    return rows


def window_for_va(windows: list[dict[str, Any]], va: int) -> str | None:
    for window in windows:
        start = int(window["startVaHex"], 16)
        end = int(window["endVaHex"], 16)
        if start <= va < end:
            return str(window["key"])
    return None


def cns_refs_in_window(exe: bytes, sections: list[dict[str, Any]], start_va: int, end_va: int) -> list[str]:
    strings = find_cns_strings(exe, sections)
    blob = bytes_for_va(exe, sections, start_va, end_va)
    refs: set[str] = set()
    for offset in range(0, max(0, len(blob) - 3)):
        value = struct.unpack_from("<I", blob, offset)[0]
        if value in strings:
            refs.add(strings[value])
    return sorted(refs)


def classify_call_target(target: int) -> str:
    for label, va in KEY_TARGETS.items():
        if target == va:
            return label
    return "other"


def build_report() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    field_step = read_json("field_step_encounter_boundary_review.json", {})
    windows = field_step.get("windows") if isinstance(field_step.get("windows"), list) else []
    formation_stream = read_json("field_encounter_formation_stream_review.json", {})
    opcode1c = read_json("field_rng_selector_opcode1c_review.json", {})
    static = read_json("field_encounter_static_review.json", {})
    rng_classes_by_call = {
        row.get("callVaHex"): row.get("rngClass")
        for row in static.get("rngCalls", [])
        if isinstance(row, dict)
    }

    window_rows: list[dict[str, Any]] = []
    key_calls_inside_windows: list[dict[str, Any]] = []
    key_refs_inside_windows: list[dict[str, Any]] = []
    for window in windows:
        start = int(window["startVaHex"], 16)
        end = int(window["endVaHex"], 16)
        calls = direct_calls_in_range(exe, sections, start, end)
        call_classes = Counter(classify_call_target(row["targetVa"]) for row in calls)
        cns_refs = cns_refs_in_window(exe, sections, start, end)
        key_refs = []
        for label, target in KEY_TARGETS.items():
            for ref in exact_dword_refs(exe, sections, target):
                if start <= ref["va"] < end:
                    key_refs.append({"label": label, "refVaHex": ref["vaHex"], "section": ref["section"]})
        for row in calls:
            label = classify_call_target(row["targetVa"])
            if label != "other":
                enriched = {"window": window["key"], "label": label, **row}
                if label == "shared-rng":
                    enriched["rngClass"] = rng_classes_by_call.get(row["callVaHex"])
                key_calls_inside_windows.append(enriched)
        for ref in key_refs:
            key_refs_inside_windows.append({"window": window["key"], **ref})
        window_rows.append(
            {
                "key": window.get("key"),
                "role": window.get("role"),
                "startVaHex": window.get("startVaHex"),
                "endVaHex": window.get("endVaHex"),
                "directCallCount": len(calls),
                "keyDirectCallCount": sum(count for label, count in call_classes.items() if label != "other"),
                "keyDirectCallClasses": {k: v for k, v in sorted(call_classes.items()) if k != "other"},
                "keyDwordRefCount": len(key_refs),
                "keyDwordRefs": key_refs,
                "cnsRefCount": len(cns_refs),
                "battleOrMonsterCnsRefs": [
                    name for name in cns_refs
                    if name.startswith("btl_") or name.startswith("z") or name.startswith("boss_")
                ],
            }
        )

    target_call_rows: list[dict[str, Any]] = []
    for label, target in KEY_TARGETS.items():
        calls = all_direct_call_refs(exe, sections, target)
        if not calls:
            continue
        target_call_rows.append(
            {
                "label": label,
                "targetVaHex": hx(target),
                "directCallCount": len(calls),
                "insideKnownFieldWindowCount": sum(1 for va in calls if window_for_va(windows, va)),
                "callSites": [
                    {"callVaHex": hx(va), "knownFieldWindow": window_for_va(windows, va)}
                    for va in calls[:80]
                ],
            }
        )

    formation_candidate_rows = formation_stream.get("candidates") or formation_stream.get("rows") or []
    formation_refs: list[dict[str, Any]] = []
    for row in formation_candidate_rows:
        stream_va_hex = row.get("streamVaHex")
        if not isinstance(stream_va_hex, str):
            continue
        stream_va = int(stream_va_hex, 16)
        refs = exact_dword_refs(exe, sections, stream_va)
        formation_refs.append(
            {
                "streamVaHex": stream_va_hex,
                "classification": row.get("classification"),
                "entryNames": row.get("entryNames") or [],
                "hasEnemyActor": row.get("hasEnemyActor") is True,
                "exactPointerRefCount": len(refs),
                "exactPointerRefs": refs[:20],
            }
        )

    shared_rng_inside = [row for row in key_calls_inside_windows if row["label"] == "shared-rng"]
    encounter_like_key_calls = [
        row for row in key_calls_inside_windows
        if row["label"] != "shared-rng" or row.get("rngClass") not in {"scene-event-random-gate"}
    ]
    summary = {
        "knownFieldWindowCount": len(windows),
        "keyDirectCallsInsideKnownFieldWindows": len(key_calls_inside_windows),
        "promotableKeyDirectCallsInsideKnownFieldWindows": len(encounter_like_key_calls),
        "sharedRngCallsInsideKnownFieldWindows": len(shared_rng_inside),
        "sharedRngEncounterClassInsideKnownFieldWindows": sum(
            1 for row in shared_rng_inside
            if row.get("rngClass") not in {"scene-event-random-gate"}
        ),
        "keyDwordRefsInsideKnownFieldWindows": len(key_refs_inside_windows),
        "battleOrMonsterCnsRefsInsideKnownFieldWindows": sum(len(row["battleOrMonsterCnsRefs"]) for row in window_rows),
        "keyTargetDirectCallRows": len(target_call_rows),
        "formationCandidateCount": len(formation_refs),
        "formationCandidateExactPointerRefCount": sum(row["exactPointerRefCount"] for row in formation_refs),
        "opcode1cWellFormedBlockCount": (opcode1c.get("summary") or {}).get("wellFormedOpcode1cBlockCount", 0),
        "rngDirectFieldEncounterEvidenceCount": (static.get("summary") or {}).get("rngDirectFieldEncounterEvidenceCount", 0),
        "fieldTickRouteProducerPromoted": False,
        "decision": (
            "known field update/tick/movement windows include only one shared-RNG direct call, and that call is the "
            "already separated scene-event random gate. They do not directly call/resource-reference the resource "
            "runner or actor formation consumers as an encounter producer. Formation stream candidates also remain "
            "unreferenced by exact pointers. Keep field encounter producer unpromoted."
        ),
    }

    return {
        "kind": "hwanse-field-encounter-tick-route-boundary-review",
        "status": "field-tick-route-not-promoted",
        "source": [
            "Hwanse2.exe",
            "out/field_step_encounter_boundary_review.json",
            "out/field_encounter_formation_stream_review.json",
            "out/field_rng_selector_opcode1c_review.json",
            "out/field_encounter_static_review.json",
            "tools/build_field_encounter_tick_route_boundary_review.py",
        ],
        "summary": summary,
        "knownFieldWindows": window_rows,
        "keyCallsInsideKnownFieldWindows": key_calls_inside_windows,
        "keyDwordRefsInsideKnownFieldWindows": key_refs_inside_windows,
        "keyTargetDirectCalls": target_call_rows,
        "formationCandidatePointerRefs": formation_refs,
        "nextFrontier": [
            "known field windows 밖의 higher-level mode/state dispatcher에서 field step success 이후 battle setup을 호출하는지 확인한다.",
            "dynamic/materialized command stream 가능성 때문에 opcode 0x1c/0x1e는 runtime watchpoint 없이는 producer로 승격하지 않는다.",
            "필드 인카운트 확정에는 field tick root, encounter threshold/RNG, formation stream 선택, btl background 선택이 한 route 안에서 이어지는 증거가 필요하다.",
        ],
    }


def render_html(report: dict[str, Any]) -> str:
    s = report["summary"]
    cards = [
        ("status", report["status"]),
        ("field windows", s["knownFieldWindowCount"]),
        ("key calls in windows", s["keyDirectCallsInsideKnownFieldWindows"]),
        ("promotable key calls", s["promotableKeyDirectCallsInsideKnownFieldWindows"]),
        ("key refs in windows", s["keyDwordRefsInsideKnownFieldWindows"]),
        ("formation ptr refs", s["formationCandidateExactPointerRefCount"]),
    ]
    card_html = "".join(f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>" for k, v in cards)

    window_rows = []
    for row in report["knownFieldWindows"]:
        window_rows.append(
            "<tr>"
            f"<td><code>{h(row['key'])}</code><br><span>{h(row['role'])}</span></td>"
            f"<td><code>{h(row['startVaHex'])}</code>..<code>{h(row['endVaHex'])}</code></td>"
            f"<td>{h(row['directCallCount'])}</td>"
            f"<td>{h(row['keyDirectCallCount'])}<br><code>{h(row['keyDirectCallClasses'])}</code></td>"
            f"<td>{h(row['keyDwordRefCount'])}</td>"
            f"<td>{h(row['battleOrMonsterCnsRefs'])}</td>"
            "</tr>"
        )

    target_rows = []
    for row in report["keyTargetDirectCalls"]:
        sample = " ".join(
            f"<code>{h(item['callVaHex'])}</code>{'/' + h(item['knownFieldWindow']) if item.get('knownFieldWindow') else ''}"
            for item in row["callSites"][:10]
        )
        target_rows.append(
            "<tr>"
            f"<td><b>{h(row['label'])}</b><br><code>{h(row['targetVaHex'])}</code></td>"
            f"<td>{h(row['directCallCount'])}</td>"
            f"<td>{h(row['insideKnownFieldWindowCount'])}</td>"
            f"<td>{sample}</td>"
            "</tr>"
        )

    formation_rows = []
    for row in report["formationCandidatePointerRefs"]:
        names = ", ".join(str(name) for name in row.get("entryNames") or [])
        formation_rows.append(
            "<tr>"
            f"<td><code>{h(row['streamVaHex'])}</code></td>"
            f"<td>{h(row['classification'])}</td>"
            f"<td>{h(row['hasEnemyActor'])}</td>"
            f"<td>{h(row['exactPointerRefCount'])}</td>"
            f"<td>{h(names)}</td>"
            "</tr>"
        )

    payload = json.dumps(report, ensure_ascii=False)
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Field Encounter Tick Route Boundary Review</title>
  <style>
    body {{ margin:0; background:#101318; color:#edf1f7; font-family:system-ui,sans-serif; }}
    main {{ max-width:1280px; margin:0 auto; padding:24px; }}
    a {{ color:#8ecbff; }}
    .nav {{ display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px; }}
    .chip {{ border:1px solid #334155; border-radius:999px; padding:6px 10px; text-decoration:none; background:#161b22; }}
    .summary {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:12px; margin:16px 0; }}
    .card {{ border:1px solid #2b3544; border-radius:8px; padding:12px; background:#161b22; }}
    .card b {{ display:block; color:#9fb1c9; font-size:12px; text-transform:uppercase; }}
    .card span {{ display:block; margin-top:8px; font-size:18px; overflow-wrap:anywhere; }}
    section {{ border:1px solid #273244; border-radius:10px; padding:16px; margin:16px 0; background:#141922; overflow:auto; }}
    table {{ border-collapse:collapse; width:100%; min-width:1000px; font-size:13px; }}
    th,td {{ border-bottom:1px solid #283342; padding:8px; text-align:left; vertical-align:top; }}
    th {{ color:#b9c7dc; background:#111722; }}
    code {{ color:#dbeafe; overflow-wrap:anywhere; }}
    pre {{ white-space:pre-wrap; background:#0b0f14; border:1px solid #253044; border-radius:8px; padding:12px; max-height:360px; overflow:auto; }}
  </style>
</head>
<body>
<main>
  <div class="nav">
    <a class="chip" href="index.html">index</a>
    <a class="chip" href="field_encounter_producer_boundary_review.html">producer boundary</a>
    <a class="chip" href="field_step_encounter_boundary_review.html">step boundary</a>
    <a class="chip" href="field_encounter_formation_boundary_review.html">formation boundary</a>
    <a class="chip" href="field_rng_selector_opcode1c_review.html">opcode 0x1c</a>
  </div>
  <h1>Field Encounter Tick Route Boundary Review</h1>
  <p>{h(s["decision"])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Known Field Windows</h2>
    <table>
      <thead><tr><th>window</th><th>range</th><th>calls</th><th>key calls</th><th>key dword refs</th><th>btl/monster CNS refs</th></tr></thead>
      <tbody>{''.join(window_rows)}</tbody>
    </table>
  </section>
  <section>
    <h2>Key Target Direct Calls</h2>
    <table>
      <thead><tr><th>target</th><th>direct calls</th><th>inside known field window</th><th>samples</th></tr></thead>
      <tbody>{''.join(target_rows)}</tbody>
    </table>
  </section>
  <section>
    <h2>Formation Stream Pointer Refs</h2>
    <table>
      <thead><tr><th>stream</th><th>classification</th><th>enemy actor</th><th>exact refs</th><th>entries</th></tr></thead>
      <tbody>{''.join(formation_rows)}</tbody>
    </table>
  </section>
  <section>
    <h2>Raw JSON</h2>
    <pre id="json"></pre>
  </section>
</main>
<script>
window.HWANSE_FIELD_ENCOUNTER_TICK_ROUTE_BOUNDARY_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_FIELD_ENCOUNTER_TICK_ROUTE_BOUNDARY_REVIEW, null, 2);
</script>
</body>
</html>
"""


def main() -> None:
    report = build_report()
    write_json(OUT / "field_encounter_tick_route_boundary_review.json", report)
    html_text = render_html(report)
    print("field_encounter_tick_route_boundary_review ok")


if __name__ == "__main__":
    main()
