#!/usr/bin/env python3
"""Build a review for the field movement -> encounter boundary.

This page answers a narrow question: does the already-grounded movement path
directly contain encounter RNG, battle-background CNS references, or monster
resource references?  At the moment it does not.  The report keeps that as a
reproducible boundary so future work does not keep re-checking the same
movement/collision windows.
"""
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


RNG_HELPER_VA = 0x00427730

SCAN_WINDOWS = [
    {
        "key": "main-update-loop",
        "label": "main update loop",
        "start": 0x00411476,
        "end": 0x0041157E,
        "role": "per-frame wrapper; calls input, active-object callback, script/timer updates",
    },
    {
        "key": "input-polling",
        "label": "input/action mask update",
        "start": 0x00422D74,
        "end": 0x00422F30,
        "role": "packs keyboard/pad state into continuous and pressed-edge masks",
    },
    {
        "key": "active-object-callback",
        "label": "active object callback update",
        "start": 0x00435C04,
        "end": 0x00435D80,
        "role": "iterates active object callbacks",
    },
    {
        "key": "script-timer",
        "label": "object script/timer update",
        "start": 0x00432FF0,
        "end": 0x00433380,
        "role": "runs object frame scripts; contains known scene random-gate surface",
    },
    {
        "key": "actor-controller",
        "label": "actor controller / tile mutation",
        "start": 0x0043022D,
        "end": 0x004319F4,
        "role": "reads input mask, collision helper result, mutates actor tile fields +0xe8/+0xea and trail ring",
    },
    {
        "key": "collision-helper",
        "label": "collision helper",
        "start": 0x004319F8,
        "end": 0x00432020,
        "role": "tests layer1 collision flag grid 0x0058d7d0/0x0058d7ce against actor footprint",
    },
    {
        "key": "action-packer",
        "label": "input action packer",
        "start": 0x0042F6D2,
        "end": 0x0042F800,
        "role": "translates key table state to action bitmasks",
    },
]

KNOWN_GLOBALS = {
    0x0059E310: "continuous input mask",
    0x0059E312: "pressed-edge input mask",
    0x0058D7D0: "layer1 collision flag grid",
    0x0058D7CE: "layer1 collision edge helper grid",
    0x00595AF0: "layer0 tile word grid",
    0x00574540: "party trail cursor ring",
    0x00574550: "party trail tile X table",
    0x00574552: "party trail tile Y table",
    0x00574554: "party trail direction table",
    0x0059DD70: "active actor pointer table",
    0x004576E8: "active actor count",
    0x00574538: "active actor slot table",
    0x00595ADA: "map width",
    0x00595ADC: "map height",
}


def esc(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(path: Path, fallback: Any) -> Any:
    try:
        return json.loads(path.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 bytes_for_window(exe: bytes, sections: list[dict[str, Any]], start: int, end: int) -> tuple[int | None, bytes]:
    start_off = va_to_offset(sections, start)
    end_off = va_to_offset(sections, end - 1)
    if start_off is None or end_off is None or end_off < start_off:
        return None, b""
    return start_off, exe[start_off : end_off + 1]


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


def collect_calls(window_va: int, window_offset: int, data: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for rel_off in range(0, max(0, len(data) - 4)):
        if data[rel_off] != 0xE8:
            continue
        rel = struct.unpack_from("<i", data, rel_off + 1)[0]
        call_va = window_va + rel_off
        target_va = call_va + 5 + rel
        rows.append(
            {
                "callVa": call_va,
                "callVaHex": hx(call_va),
                "targetVa": target_va,
                "targetVaHex": hx(target_va),
                "targetSection": section_name_for_va(sections, target_va),
                "isSharedRng": target_va == RNG_HELPER_VA,
            }
        )
    return rows


def cns_kind(name: str) -> str:
    stem = name[:-4] if name.endswith(".cns") else name
    if stem.startswith("btl_"):
        return "battle-background"
    if stem.startswith("boss_") or stem.startswith("z"):
        return "monster-or-battle-actor"
    if stem.startswith("map"):
        return "map-or-tileset"
    if stem.startswith("cara_") or stem.startswith("btl_"):
        return "character"
    return "other"


def collect_cns_refs(data: bytes, window_va: int, cns_strings: dict[int, str]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for rel_off in range(0, max(0, len(data) - 3)):
        value = struct.unpack_from("<I", data, rel_off)[0]
        name = cns_strings.get(value)
        if not name:
            continue
        rows.append(
            {
                "refVa": window_va + rel_off,
                "refVaHex": hx(window_va + rel_off),
                "stringVaHex": hx(value),
                "name": name,
                "kind": cns_kind(name),
            }
        )
    return rows


def collect_global_refs(data: bytes, window_va: int) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for rel_off in range(0, max(0, len(data) - 3)):
        value = struct.unpack_from("<I", data, rel_off)[0]
        label = KNOWN_GLOBALS.get(value)
        if not label:
            continue
        rows.append({"refVa": window_va + rel_off, "refVaHex": hx(window_va + rel_off), "valueHex": hx(value), "label": label})
    return rows


def scan_windows(exe: bytes, sections: list[dict[str, Any]], cns_strings: dict[int, str]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for spec in SCAN_WINDOWS:
        start = int(spec["start"])
        end = int(spec["end"])
        offset, data = bytes_for_window(exe, sections, start, end)
        calls = collect_calls(start, offset or 0, data, sections) if offset is not None else []
        cns_refs = collect_cns_refs(data, start, cns_strings) if offset is not None else []
        global_refs = collect_global_refs(data, start) if offset is not None else []
        cns_kind_counts = Counter(row["kind"] for row in cns_refs)
        rows.append(
            {
                "key": spec["key"],
                "label": spec["label"],
                "role": spec["role"],
                "startVaHex": hx(start),
                "endVaHex": hx(end),
                "size": len(data),
                "callCount": len(calls),
                "calls": calls,
                "sharedRngCalls": [row for row in calls if row["isSharedRng"]],
                "cnsRefCount": len(cns_refs),
                "cnsKindCounts": dict(sorted(cns_kind_counts.items())),
                "battleBackgroundRefs": [row for row in cns_refs if row["kind"] == "battle-background"],
                "monsterOrActorRefs": [row for row in cns_refs if row["kind"] == "monster-or-battle-actor"],
                "cnsRefs": cns_refs,
                "knownGlobalRefs": global_refs,
                "knownGlobalLabels": sorted({row["label"] for row in global_refs}),
            }
        )
    return rows


def movement_tile_mutation_summary(runtime: dict[str, Any]) -> dict[str, Any]:
    actor = ((runtime.get("actorMotion") or {}).get("actorController") or {})
    trail = actor.get("partyTrailHistory") or {}
    timing = trail.get("callTiming") or {}
    return {
        "actorControllerVaHex": actor.get("functionVaHex"),
        "collisionHelperVaHex": ((actor.get("originalCollisionHelper") or {}).get("functionVaHex") or "0x004319f8"),
        "tileMutationVas": timing.get("tileMutationVas") or {},
        "leaderWriteAfterMutationVas": timing.get("leaderWriteAfterMutationVas") or {},
        "followerReadAfterLeaderWriteVas": timing.get("followerReadAfterLeaderWriteVas") or {},
        "objectTileFields": {"x": "+0xe8", "y": "+0xea"},
        "partyTrailRing": {
            "cursor": trail.get("indexTableHex"),
            "tileX": trail.get("tileXTableHex"),
            "tileY": trail.get("tileYTableHex"),
            "direction": trail.get("directionTableHex"),
            "slotCount": trail.get("slotCount"),
        },
    }


def build_report() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    cns_strings = find_cns_strings(exe, sections)
    runtime = read_json(OUT / "runtime_movement.json", {})
    encounter = read_json(OUT / "field_encounter_static_review.json", {})
    opcode1c = read_json(OUT / "field_rng_selector_opcode1c_review.json", {})
    windows = scan_windows(exe, sections, cns_strings)
    core_movement_keys = {
        "input-polling",
        "active-object-callback",
        "actor-controller",
        "collision-helper",
        "action-packer",
    }
    shared_rng_in_windows = sum(len(row["sharedRngCalls"]) for row in windows)
    shared_rng_in_core_movement = sum(
        len(row["sharedRngCalls"]) for row in windows if row["key"] in core_movement_keys
    )
    known_non_encounter_rng = [
        {
            "window": row["key"],
            "callVaHex": call["callVaHex"],
            "targetVaHex": call["targetVaHex"],
            "classification": "scene-event-random-gate",
            "reason": "scene_event_vm_random_gate_review에서 별도 장면 랜덤 게이트로 이미 분리된 shared RNG 호출이다.",
        }
        for row in windows
        for call in row["sharedRngCalls"]
        if row["key"] == "script-timer" and call["callVa"] == 0x004330EB
    ]
    battle_refs = sum(len(row["battleBackgroundRefs"]) for row in windows)
    monster_refs = sum(len(row["monsterOrActorRefs"]) for row in windows)
    return {
        "kind": "hwanse-field-step-encounter-boundary-review",
        "source": [
            "Hwanse2.exe",
            "out/runtime_movement.json",
            "out/field_encounter_static_review.json",
            "out/field_rng_selector_opcode1c_review.json",
            "tools/build_field_step_encounter_boundary_review.py",
        ],
        "summary": {
            "movementConsumerGrounded": True,
            "actorTileMutationGrounded": True,
            "collisionHelperGrounded": True,
            "knownMovementWindowCount": len(windows),
            "sharedRngCallsInKnownWindows": shared_rng_in_windows,
            "sharedRngCallsInCoreMovementWindows": shared_rng_in_core_movement,
            "knownNonEncounterRngCallsInKnownWindows": len(known_non_encounter_rng),
            "battleBackgroundCnsRefsInKnownMovementWindows": battle_refs,
            "monsterOrBattleActorCnsRefsInKnownMovementWindows": monster_refs,
            "encounterRngPromoted": False,
            "battleEntryConsumerPromoted": False,
            "conclusion": (
                "이동 성공과 충돌 판정은 actor controller/collision helper까지 확정됐지만, "
                "core movement 경계 안에서는 인카운트 RNG, battle background, monster CNS 직접 참조가 검출되지 않았다. "
                "per-frame script/timer 창에 shared RNG 1건이 있으나 이미 장면 랜덤 게이트로 분리된 호출이다. "
                "따라서 전투 진입 판정은 알려진 이동 좌표 갱신 함수 내부가 아니라 별도 active-object callback, "
                "field/event script, 또는 아직 분리되지 않은 후속 소비처에서 찾아야 한다."
            ),
        },
        "knownNonEncounterRngCalls": known_non_encounter_rng,
        "movementTileMutation": movement_tile_mutation_summary(runtime),
        "previousEncounterReview": {
            "rngDirectFieldEncounterEvidenceCount": (encounter.get("summary") or {}).get("rngDirectFieldEncounterEvidenceCount"),
            "rngCallSitesWithNearbyCnsCount": (encounter.get("summary") or {}).get("rngCallSitesWithNearbyCnsCount"),
            "fieldEncounterConsumerPromoted": (encounter.get("summary") or {}).get("fieldEncounterConsumerPromoted"),
        },
        "opcode1cReview": {
            "wellFormedOpcode1cBlockCount": (opcode1c.get("summary") or {}).get("wellFormedOpcode1cBlockCount"),
            "handlerOnlyUnbound": (opcode1c.get("summary") or {}).get("fieldEncounterBindingPromoted") is False,
        },
        "windows": windows,
    }


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    mutation = report["movementTileMutation"]
    cards = [
        ("movement grounded", summary["movementConsumerGrounded"]),
        ("core movement RNG", summary["sharedRngCallsInCoreMovementWindows"]),
        ("known non-enc RNG", summary["knownNonEncounterRngCallsInKnownWindows"]),
        ("battle CNS refs", summary["battleBackgroundCnsRefsInKnownMovementWindows"]),
        ("monster CNS refs", summary["monsterOrBattleActorCnsRefsInKnownMovementWindows"]),
        ("battle entry promoted", summary["battleEntryConsumerPromoted"]),
    ]
    card_html = "\n".join(
        f"<div class='card'><b>{esc(label)}</b><span>{esc(value)}</span></div>" for label, value in cards
    )
    rows = []
    for row in report["windows"]:
        rng = len(row["sharedRngCalls"])
        btl = len(row["battleBackgroundRefs"])
        mon = len(row["monsterOrActorRefs"])
        globals_text = ", ".join(row["knownGlobalLabels"]) or "-"
        calls = ", ".join(item["targetVaHex"] for item in row["calls"][:12])
        if len(row["calls"]) > 12:
            calls += f" ... +{len(row['calls']) - 12}"
        rows.append(
            "<tr>"
            f"<td><b>{esc(row['label'])}</b><br><code>{esc(row['startVaHex'])}..{esc(row['endVaHex'])}</code></td>"
            f"<td>{esc(row['role'])}</td>"
            f"<td>{esc(row['callCount'])}<br><small>{esc(calls or '-')}</small></td>"
            f"<td class='{esc('bad' if rng else 'ok')}'>{esc(rng)}</td>"
            f"<td class='{esc('bad' if btl else 'ok')}'>{esc(btl)}</td>"
            f"<td class='{esc('bad' if mon else 'ok')}'>{esc(mon)}</td>"
            f"<td>{esc(globals_text)}</td>"
            "</tr>"
        )
    tile_mutations = mutation.get("tileMutationVas") or {}
    mutation_rows = "\n".join(
        f"<tr><td>{esc(key)}</td><td><code>{esc(value)}</code></td></tr>" for key, value in tile_mutations.items()
    )
    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 Step Encounter Boundary Review</title>
  <style>
    body {{ margin:0; font-family: system-ui, sans-serif; background:#101318; color:#edf1f7; }}
    main {{ max-width: 1240px; 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; letter-spacing:.04em; }}
    .card span {{ display:block; margin-top:8px; font-size:22px; }}
    section {{ border:1px solid #273244; border-radius:10px; padding:16px; margin:16px 0; background:#141922; }}
    table {{ border-collapse:collapse; width:100%; font-size:13px; }}
    th, td {{ border-bottom:1px solid #283342; padding:8px; text-align:left; vertical-align:top; }}
    th {{ color:#b9c7dc; background:#111722; position:sticky; top:0; }}
    code {{ color:#dbeafe; }}
    small {{ color:#95a3b8; }}
    .ok {{ color:#8ee6a2; font-weight:700; }}
    .bad {{ color:#ffadad; font-weight:700; }}
    pre {{ white-space:pre-wrap; background:#0b0f14; border:1px solid #253044; border-radius:8px; padding:12px; max-height:420px; overflow:auto; }}
  </style>
</head>
<body>
<main>
  <div class="nav">
    <a class="chip" href="index.html">index</a>
    <a class="chip" href="field_encounter_static_review.html">field encounter static</a>
    <a class="chip" href="field_rng_selector_opcode1c_review.html">opcode 0x1c selector</a>
  </div>
  <h1>Field Step Encounter Boundary Review</h1>
  <p>{esc(summary["conclusion"])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Movement Tile Mutation Surface</h2>
    <p>확정된 이동 좌표 갱신 경계입니다. 여기까지는 “걸어서 한 타일 이동했다”를 원본 EXE 기준으로 설명할 수 있습니다.</p>
    <table><thead><tr><th>mutation</th><th>VA</th></tr></thead><tbody>{mutation_rows}</tbody></table>
  </section>
  <section>
    <h2>Known Window Scan</h2>
    <p>아래 창에서 shared RNG <code>0x00427730</code>, <code>btl_*.cns</code>, monster/battle actor CNS 직접 참조를 찾았습니다.</p>
    <table>
      <thead><tr><th>window</th><th>role</th><th>calls</th><th>RNG</th><th>btl refs</th><th>monster refs</th><th>known globals</th></tr></thead>
      <tbody>{''.join(rows)}</tbody>
    </table>
  </section>
  <section>
    <h2>Raw JSON</h2>
    <pre id="json"></pre>
  </section>
</main>
<script>
window.HWANSE_FIELD_STEP_ENCOUNTER_BOUNDARY_REVIEW_READY = true;
window.HWANSE_FIELD_STEP_ENCOUNTER_BOUNDARY_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_FIELD_STEP_ENCOUNTER_BOUNDARY_REVIEW, null, 2);
</script>
</body>
</html>
"""


def main() -> int:
    report = build_report()
    write_json(OUT / "field_step_encounter_boundary_review.json", report)
    html_text = render_html(report)
    (WEB / "field_step_encounter_boundary_review.html").write_text(html_text, encoding="utf-8")
    print("field_step_encounter_boundary_review ok")
    return 0


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