#!/usr/bin/env python3
"""Build a static review for field walking encounter candidates.

This intentionally does not promote map -> encounter tables.  It separates:

* EXE-backed battle/background CNS resource descriptors.
* Family-derived field map -> battle background candidates.
* Nearby scene/resource groups that mention btl_* resources.
* Walking-step/RNG evidence and remaining gaps.
"""
from __future__ import annotations

import html
import json
import re
import struct
import sys
from collections import Counter, defaultdict
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  # noqa: E402
from summarize_original_battle_resource_descriptors import build_summary as build_resource_descriptor_summary  # noqa: E402


MAP_RE = re.compile(r"^map(?P<chapter>\d+)_(?P<index>\d+)(?P<family>[a-z])$")
BTL_RE = re.compile(r"^btl_(?P<family>[a-z])(?P<variant>\d+)$")
BTL_CNS_RE = re.compile(r"^btl_[a-z]\d+\.cns$")


def load_json(path: Path, fallback: Any) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return fallback


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


def js_json(data: Any) -> str:
    return json.dumps(data, ensure_ascii=False, separators=(",", ":"))


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


def is_ignored_event_map(name: str) -> str:
    if re.fullmatch(r"map0_\d+n", name):
        return "event-map-map0-n"
    match = re.fullmatch(r"map2_(\d+)j", name)
    if match and 20 <= int(match.group(1)) <= 39:
        return "event-cleaning-map-map2-20j-through-39j"
    return ""


def map_family(name: str) -> str:
    match = MAP_RE.fullmatch(name)
    return match.group("family") if match else ""


def build_btl_family_index(backgrounds: dict[str, Any]) -> dict[str, list[str]]:
    result: dict[str, list[str]] = defaultdict(list)
    for name in backgrounds:
        match = BTL_RE.fullmatch(name)
        if match:
            result[match.group("family")].append(name)
    return {family: sorted(items) for family, items in sorted(result.items())}


def scene_resource_btl_refs(scene_link: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
    result: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for group in scene_link.get("groups") or []:
        resources = [str(item) for item in group.get("resources") or []]
        for record in group.get("resourceRecords") or []:
            resources.extend(str(item) for item in record.get("resources") or [])
        btls = sorted({item[:-4] for item in resources if BTL_CNS_RE.fullmatch(item)})
        if not btls:
            continue
        maps = set()
        if group.get("map"):
            maps.add(str(group["map"]))
        maps.update(str(item) for item in group.get("fieldMaps") or [])
        for record in group.get("resourceRecords") or []:
            if record.get("map"):
                maps.add(str(record["map"]))
        for name in sorted(maps):
            result[name].append(
                {
                    "groupId": group.get("id"),
                    "contextKind": group.get("contextKind"),
                    "contextLabel": group.get("contextLabel"),
                    "rootVaHex": group.get("rootVaHex"),
                    "recordVaHex": group.get("recordVaHex"),
                    "evidenceStatus": group.get("evidenceStatus"),
                    "linkClass": group.get("linkClass"),
                    "battleBackgrounds": btls,
                    "directEventRootProofFound": group.get("directEventRootProofFound") is True,
                }
            )
    return dict(result)


def battle_enemy_by_background(candidates: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
    result: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for row in candidates.get("candidates") or []:
        btl = str(row.get("battleBackground") or "")
        if not btl:
            continue
        result[btl].append(
            {
                "candidateId": row.get("candidateId"),
                "blockId": row.get("blockId"),
                "enemyCns": row.get("enemyCns"),
                "enemyRefVaHex": row.get("enemyRefVaHex"),
                "battleBackgroundRefVaHex": row.get("battleBackgroundRefVaHex"),
                "selectionKind": row.get("selectionKind"),
                "originalEnemyRowBound": row.get("originalEnemyRowBound") is True,
            }
        )
    return {key: value for key, value in sorted(result.items())}


def find_rng_calls(exe: bytes, sections: list[dict[str, Any]], target_va: int = 0x00427730) -> list[dict[str, Any]]:
    strings = find_cns_strings(exe, sections)
    calls = []
    for offset, byte in enumerate(exe[:-4]):
        if byte != 0xE8:
            continue
        rel = struct.unpack_from("<i", exe, offset + 1)[0]
        va = offset_to_va(sections, offset)
        if va is None or va + 5 + rel != target_va:
            continue
        nearby = []
        lo = max(0, offset - 512)
        hi = min(len(exe), offset + 512)
        for scan in range(lo, hi - 3):
            value = struct.unpack_from("<I", exe, scan)[0]
            if value in strings:
                nearby.append(strings[value])
        item = {
            "callVa": va,
            "callVaHex": f"0x{va:08x}",
            "nearbyCns": sorted(set(nearby)),
        }
        item.update(classify_rng_call(exe, sections, va, offset))
        calls.append(item)
    return calls


def pushed_argument_summary(exe: bytes, call_offset: int) -> dict[str, Any]:
    if call_offset >= 5 and exe[call_offset - 5] == 0x68:
        value = struct.unpack_from("<I", exe, call_offset - 4)[0]
        return {"kind": "push-imm32", "value": value, "valueHex": f"0x{value:08x}"}
    if call_offset >= 2 and exe[call_offset - 2] == 0x6A:
        value = exe[call_offset - 1]
        return {"kind": "push-imm8", "value": value, "valueHex": f"0x{value:02x}"}
    if call_offset >= 1 and exe[call_offset - 1] == 0x50:
        return {"kind": "push-eax"}
    return {"kind": "unknown"}


def hex_context(exe: bytes, call_offset: int, radius: int = 24) -> str:
    lo = max(0, call_offset - radius)
    hi = min(len(exe), call_offset + 5 + radius)
    return exe[lo:hi].hex(" ")


def classify_rng_call(exe: bytes, sections: list[dict[str, Any]], va: int, call_offset: int) -> dict[str, Any]:
    """Classify uses of the shared rand16_mod helper.

    The goal is not to prove every function name.  It removes already-grounded
    RNG consumers from the field-encounter search surface and leaves only the
    calls that still need event/field binding.
    """
    push_arg = pushed_argument_summary(exe, call_offset)
    label = "unclassified"
    confidence = "low"
    reason = "No grounded static context yet."

    if 0x00421700 <= va <= 0x00421A50:
        label = "level-up-stat-random"
        confidence = "high"
        reason = "레벨업 스탯 상승 루프 주변 RNG다. 필드 이동/맵/전투 배경 참조가 없다."
    elif va == 0x004330EB:
        label = "scene-event-random-gate"
        confidence = "high"
        reason = "scene_event_vm_random_gate_review에서 0x004330e0 gate tick의 rand(10000) 호출로 확정됐다."
    elif va == 0x00435795:
        label = "battle-end-item-drop-random"
        confidence = "high"
        reason = "enemy actor +0x40 item id, +0x41 확률 분모를 읽고 성공 시 0x422093으로 아이템 1개를 추가한다."
    elif 0x00433400 <= va <= 0x00435280:
        label = "battle-result-formula-random"
        confidence = "high"
        reason = "명중/회피, 크리티컬, 상태이상, 데미지 흔들림, 결과/이펙트 분기 클러스터 안의 RNG다."
    elif 0x004056DE <= va <= 0x0040570B:
        label = "battle-display-helper-random-range"
        confidence = "high"
        reason = (
            "generic/display handler table 0x00440538의 opcode 0x2b다. "
            "script word를 rand modulo로 넘기고 결과를 display/context +0x58에 저장한다. "
            "battle helper child script review의 random-range opcode와 일치하므로 필드 인카운트 후보에서 분리한다."
        )
    elif 0x0040BCC9 <= va <= 0x0040BE37:
        label = "generic-vm-random-jump-selector"
        confidence = "medium"
        reason = (
            "handler table 0x00440720 opcode 0x1c의 mode 2 분기다. rand(n)으로 command pointer 후보를 골라 VM context +0x40을 갱신한다. "
            "현재 EXE 정적 스캔에서는 이 opcode를 정상 소비할 수 있는 well-formed command block이 0개라 field encounter 증거로 승격하지 않는다."
        )
    elif 0x0040CCEA <= va <= 0x0040CF09:
        label = "player-skill-growth-random"
        confidence = "medium"
        reason = "actor +0x59 skill/action id, 0x4d2488 payload, 0x4577be 숙련 카운터, 0x4404e0 chance table을 함께 사용한다."

    result = {
        "rngClass": label,
        "classificationConfidence": confidence,
        "classificationReason": reason,
        "pushedArgument": push_arg,
        "contextBytes": hex_context(exe, call_offset),
    }
    if label == "battle-display-helper-random-range":
        result.update(
            {
                "handlerTableVaHex": "0x00440538",
                "helperOpcodeHex": "0x2b",
                "handlerEntryVaHex": "0x004405e4",
                "handlerVaHex": "0x004056de",
                "relatedArtifact": "out/battle_helper_child_script_review.json",
            }
        )
    if label == "generic-vm-random-jump-selector":
        result.update(
            {
                "handlerTableVaHex": "0x00440720",
                "selectorOpcodeHex": "0x1c",
                "handlerEntryVaHex": "0x00440790",
                "handlerVaHex": "0x0040bcc9",
                "selectorRngCallVaHex": "0x0040bdcc",
                "wellFormedOpcode1cBlockCount": 0,
                "relatedArtifact": "out/field_rng_selector_opcode1c_review.json",
            }
        )
    return result


def build_map_rows(
    maps: dict[str, Any],
    btl_by_family: dict[str, list[str]],
    scene_btls: dict[str, list[dict[str, Any]]],
    enemy_by_btl: dict[str, list[dict[str, Any]]],
) -> list[dict[str, Any]]:
    rows = []
    for name, layout in sorted(maps.items()):
        family = map_family(name)
        ignored_reason = is_ignored_event_map(name)
        family_btls = btl_by_family.get(family, [])
        scene_refs = scene_btls.get(name, [])
        scene_btl_names = sorted({btl for ref in scene_refs for btl in ref.get("battleBackgrounds") or []})
        candidate_btls = sorted(set(family_btls) | set(scene_btl_names))
        enemy_candidates = {
            btl: enemy_by_btl.get(btl, [])
            for btl in candidate_btls
            if enemy_by_btl.get(btl)
        }
        if ignored_reason:
            status = "ignored-event-map"
        elif scene_btl_names:
            status = "scene-resource-btl-candidate-not-encounter-consumer"
        elif family_btls:
            status = "family-derived-btl-candidate"
        else:
            status = "no-btl-family-candidate"
        rows.append(
            {
                "map": name,
                "family": family,
                "width": layout.get("width") or layout.get("accepted", {}).get("width"),
                "height": layout.get("height") or layout.get("accepted", {}).get("height"),
                "tilesets": layout.get("tilesets") or [],
                "ignoredReason": ignored_reason,
                "walkEncounterModel": "requires-successful-field-step-before-rng-or-threshold-check",
                "status": status,
                "familyBattleBackgroundCandidates": family_btls,
                "sceneResourceBattleBackgroundCandidates": scene_refs,
                "candidateBattleBackgrounds": candidate_btls,
                "enemySpriteProximityCandidatesByBattleBackground": enemy_candidates,
                "originalEncounterTableBound": False,
                "originalEncounterProbabilityBound": False,
                "originalFormationBound": False,
            }
        )
    return rows


def evidence_summary(rows: list[dict[str, Any]], rng_calls: list[dict[str, Any]], resource_summary: dict[str, Any]) -> dict[str, Any]:
    statuses = Counter(row["status"] for row in rows)
    family_count = Counter(row["family"] for row in rows if row.get("family"))
    candidate_btl_count = Counter()
    for row in rows:
        for btl in row.get("candidateBattleBackgrounds") or []:
            candidate_btl_count[btl] += 1
    checks = resource_summary.get("checks") or {}
    rng_class_count = Counter(row.get("rngClass", "unclassified") for row in rng_calls)
    rng_field_bound = [
        row for row in rng_calls
        if row.get("nearbyCns")
        and any(str(name).startswith(("map", "btl_", "z")) for name in row.get("nearbyCns") or [])
    ]
    return {
        "mapCount": len(rows),
        "statusCounts": dict(sorted(statuses.items())),
        "familyCounts": dict(sorted(family_count.items())),
        "candidateBattleBackgroundMapCounts": dict(sorted(candidate_btl_count.items())),
        "rngFunctionVaHex": "0x00427730",
        "rngCallSiteCount": len(rng_calls),
        "rngCallSitesWithNearbyCnsCount": sum(1 for row in rng_calls if row.get("nearbyCns")),
        "rngClassCounts": dict(sorted(rng_class_count.items())),
        "rngDirectFieldEncounterEvidenceCount": len(rng_field_bound),
        "rngDisplayHelperRandomRangeCallCount": sum(
            1 for row in rng_calls
            if row.get("rngClass") == "battle-display-helper-random-range"
        ),
        "rngRemainingGenericVmCallCount": sum(
            1 for row in rng_calls
            if row.get("rngClass") == "generic-vm-random-jump-selector"
        ),
        "battleBackgroundDescriptorsFound": checks.get("battleBackgroundDescriptorsFound") is True,
        "battleBackgroundDescriptorsStandard": checks.get("battleBackgroundDescriptorsStandard") is True,
        "enemySpriteDescriptorsFound": checks.get("enemySpriteDescriptorsFound") is True,
        "resourceDescriptorsProveBattleEntry": checks.get("resourceDescriptorsProveBattleEntry") is True,
    }


def render_html(report: dict[str, Any]) -> str:
    rows = report["rows"]
    summary = report["summary"]
    sample_rows = rows
    rng_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for call in report.get("rngCalls") or []:
        rng_groups[call.get("rngClass", "unclassified")].append(call)

    def chips(items: list[Any], limit: int = 8) -> str:
        if not items:
            return '<span class="muted">-</span>'
        visible = items[:limit]
        tail = len(items) - len(visible)
        body = " ".join(f"<code>{esc(item)}</code>" for item in visible)
        if tail:
            body += f' <span class="muted">+{tail}</span>'
        return body

    table_rows = []
    for row in sample_rows:
        scene_refs = row.get("sceneResourceBattleBackgroundCandidates") or []
        scene_text = '<span class="muted">-</span>'
        if scene_refs:
            scene_text = "<br>".join(
                f"<code>{esc(ref.get('groupId'))}</code> {chips(ref.get('battleBackgrounds') or [], 6)}"
                f"<br><span class=\"muted\">{esc(ref.get('evidenceStatus'))}</span>"
                for ref in scene_refs[:3]
            )
            if len(scene_refs) > 3:
                scene_text += f"<br><span class=\"muted\">+{len(scene_refs)-3}</span>"
        enemy_lines = []
        for btl, enemies in (row.get("enemySpriteProximityCandidatesByBattleBackground") or {}).items():
            labels = sorted({item.get("enemyCns") for item in enemies if item.get("enemyCns")})
            enemy_lines.append(f"<code>{esc(btl)}</code> {chips(labels, 5)}")
        enemy_text = "<br>".join(enemy_lines) if enemy_lines else '<span class="muted">-</span>'
        table_rows.append(
            "<tr>"
            f"<td><code>{esc(row['map'])}</code><br><span class=\"muted\">family {esc(row.get('family'))}</span></td>"
            f"<td><span class=\"tag {esc(status_class(row['status']))}\">{esc(row['status'])}</span>"
            + (f"<br><span class=\"muted\">{esc(row['ignoredReason'])}</span>" if row.get("ignoredReason") else "")
            + "</td>"
            f"<td>{chips(row.get('familyBattleBackgroundCandidates') or [])}</td>"
            f"<td>{scene_text}</td>"
            f"<td>{enemy_text}</td>"
            "</tr>"
        )

    rng_class_rows = []
    for label, calls in sorted(rng_groups.items()):
        first = calls[0]
        samples = " ".join(f"<code>{esc(row['callVaHex'])}</code>" for row in calls[:8])
        if len(calls) > 8:
            samples += f' <span class="muted">+{len(calls) - 8}</span>'
        rng_class_rows.append(
            "<tr>"
            f"<td><span class=\"tag neutral\">{esc(label)}</span></td>"
            f"<td>{len(calls)}</td>"
            f"<td>{esc(first.get('classificationConfidence'))}</td>"
            f"<td>{samples}</td>"
            f"<td>{esc(first.get('classificationReason'))}</td>"
            "</tr>"
        )

    rng_call_rows = []
    for call in sorted(report.get("rngCalls") or [], key=lambda item: item["callVa"]):
        pushed = call.get("pushedArgument") or {}
        pushed_text = pushed.get("kind", "")
        if pushed.get("valueHex"):
            pushed_text += f" {pushed['valueHex']}"
        rng_call_rows.append(
            "<tr>"
            f"<td><code>{esc(call.get('callVaHex'))}</code></td>"
            f"<td><span class=\"tag neutral\">{esc(call.get('rngClass'))}</span></td>"
            f"<td>{esc(pushed_text)}</td>"
            f"<td>{chips(call.get('nearbyCns') or [], 5)}</td>"
            f"<td><code>{esc(call.get('contextBytes'))}</code></td>"
            "</tr>"
        )

    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>필드 인카운트 정적 리뷰</title>
  <style>
    body {{ margin:0; background:#f6f7f9; color:#20242b; font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }}
    main {{ width:min(1320px, calc(100vw - 28px)); margin:0 auto; padding:22px 0 36px; }}
    h1 {{ margin:0 0 6px; font-size:26px; }}
    h2 {{ margin:24px 0 10px; font-size:18px; }}
    p {{ margin:0 0 10px; color:#606878; }}
    a {{ color:#2459a6; text-decoration:none; font-weight:600; }}
    a:hover {{ text-decoration:underline; }}
    .nav {{ display:flex; flex-wrap:wrap; gap:8px; margin:14px 0 18px; }}
    .chip {{ border:1px solid #d6dce7; background:#fff; border-radius:7px; padding:6px 10px; }}
    .cards {{ display:grid; grid-template-columns:repeat(auto-fit, minmax(210px,1fr)); gap:10px; }}
    .card {{ background:#fff; border:1px solid #d8dee8; border-radius:8px; padding:12px; }}
    .num {{ font-size:24px; font-weight:800; }}
    .label {{ color:#687080; font-size:13px; }}
    .panel {{ background:#fff; border:1px solid #d8dee8; border-radius:8px; padding:14px; margin-top:12px; }}
    .table-wrap {{ overflow:auto; border:1px solid #d8dee8; border-radius:8px; background:#fff; }}
    table {{ width:100%; border-collapse:collapse; min-width:1000px; }}
    th, td {{ border-bottom:1px solid #edf0f4; padding:9px 10px; text-align:left; vertical-align:top; font-size:13px; }}
    th {{ background:#fafbfd; position:sticky; top:0; z-index:1; }}
    code {{ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; background:#f1f4f8; border-radius:4px; padding:1px 4px; }}
    .muted {{ color:#727b8b; }}
    .tag {{ display:inline-block; border-radius:999px; padding:2px 7px; font-size:12px; font-weight:700; }}
    .ok {{ background:#e8f6ef; color:#176b3a; }}
    .warn {{ background:#fff2cf; color:#825b00; }}
    .bad {{ background:#fbe7e7; color:#9b2424; }}
    .neutral {{ background:#edf1f6; color:#485465; }}
    .small-table td {{ font-size:12px; }}
    pre {{ white-space:pre-wrap; overflow:auto; background:#101820; color:#edf3fb; border-radius:8px; padding:12px; }}
  </style>
</head>
<body>
<main>
  <h1>필드 인카운트 정적 리뷰</h1>
  <p>필드에서 실제 이동 step이 발생한 뒤 인카운트 판정이 실행된다는 전제를 분리해서 정리한 리뷰입니다. 전투 배경 후보는 정리하지만, 원본 랜덤 인카운트 테이블/확률/편성은 아직 승격하지 않습니다.</p>
  <nav class="nav">
    <a class="chip" href="index.html">index</a>
    <a class="chip" href="map_review.html">map review</a>
    <a class="chip" href="battle_background_review.html">battle backgrounds</a>
    <a class="chip" href="battle_analysis.html">battle analysis</a>
    <a class="chip" href="field_rng_selector_opcode1c_review.html">opcode 0x1c RNG selector</a>
    <a class="chip" href="../out/field_encounter_static_review.json">artifact json</a>
  </nav>
  <section class="cards">
    {metric("maps", summary["mapCount"])}
    {metric("family candidates", summary["statusCounts"].get("family-derived-btl-candidate", 0))}
    {metric("scene btl refs", summary["statusCounts"].get("scene-resource-btl-candidate-not-encounter-consumer", 0))}
    {metric("rng calls", summary["rngCallSiteCount"])}
    {metric("rng+cns near", summary["rngCallSitesWithNearbyCnsCount"])}
    {metric("rng field-bound", summary["rngDirectFieldEncounterEvidenceCount"])}
    {metric("remaining generic rng", summary["rngRemainingGenericVmCallCount"])}
  </section>
  <section class="panel">
    <h2>결론</h2>
    <ul>
      <li>EXE 리소스 descriptor는 <code>btl_*.cns</code> 전투 배경 로드 형태를 뒷받침한다.</li>
      <li>필드 <code>map_* family</code>와 전투 <code>btl_* family</code>는 후보를 만들 수 있을 만큼 정합적이다.</li>
      <li>하지만 walking step → RNG → encounter group → formation → battle background를 잇는 직접 consumer는 아직 없다.</li>
      <li>RNG 호출지 51개는 레벨업, scene random gate, 전투 공식/보상, 전투 표시 helper, 범용 VM 랜덤 selector로 분리되며, 현재 직접 map/btl/monster table에 붙은 RNG 호출은 0개다.</li>
      <li><code>0x004056ef</code>는 display helper opcode <code>0x2b</code> random-range로 승격되어 필드 인카운트 후보에서 제외했다.</li>
      <li>남은 범용 RNG selector <code>0x0040bdcc</code>는 handler table <code>0x00440720</code> opcode <code>0x1c</code>의 mode 2 분기다. 별도 스캔에서 well-formed opcode 0x1c command block은 0개라 field encounter 증거로 승격하지 않는다.</li>
      <li>따라서 아래 표의 전투 배경/몬스터는 원본 인카운트 확정값이 아니라 정적 후보와 근접 리소스 후보이다.</li>
    </ul>
  </section>
  <h2>RNG 호출지 분류</h2>
  <p>공유 난수 함수 <code>0x00427730</code>의 호출지를 용도별로 분리했습니다. 필드 인카운트라면 걷기 step, 맵 상태, 전투 진입 리소스와 함께 소비되는 호출이 남아야 하지만 현재 직접 결합 증거는 없습니다.</p>
  <div class="table-wrap"><table>
    <thead><tr><th>class</th><th>count</th><th>confidence</th><th>sample callsites</th><th>reason</th></tr></thead>
    <tbody>{''.join(rng_class_rows)}</tbody>
  </table></div>
  <details class="panel">
    <summary>RNG callsite detail</summary>
    <div class="table-wrap"><table class="small-table">
      <thead><tr><th>call</th><th>class</th><th>argument</th><th>nearby CNS</th><th>byte context</th></tr></thead>
      <tbody>{''.join(rng_call_rows)}</tbody>
    </table></div>
  </details>
  <h2>맵별 후보</h2>
  <div class="table-wrap"><table>
    <thead><tr><th>map</th><th>status</th><th>family btl candidates</th><th>scene/resource btl refs</th><th>enemy sprite proximity</th></tr></thead>
    <tbody>
      {''.join(table_rows)}
    </tbody>
  </table></div>
  <h2>정적 evidence summary</h2>
  <pre>{esc(json.dumps(summary, ensure_ascii=False, indent=2))}</pre>
  <script>
    window.HWANSE_FIELD_ENCOUNTER_STATIC_REVIEW_READY = {js_json({
        "status": "ok",
        "mapCount": summary["mapCount"],
        "rngCallSiteCount": summary["rngCallSiteCount"],
        "originalEncounterTableBound": False,
    })};
  </script>
</main>
</body>
</html>
"""


def status_class(status: str) -> str:
    if status == "family-derived-btl-candidate":
        return "ok"
    if status == "scene-resource-btl-candidate-not-encounter-consumer":
        return "warn"
    if status == "ignored-event-map":
        return "neutral"
    return "bad"


def metric(label: str, value: Any) -> str:
    return f'<div class="card"><div class="num">{esc(value)}</div><div class="label">{esc(label)}</div></div>'


def build_report() -> dict[str, Any]:
    maps = load_json(OUT / "map_layout_reviews.json", {})
    backgrounds = load_json(OUT / "battle_backgrounds.json", {})
    scene_link = load_json(OUT / "scene_seq_resource_record_link_review.json", {})
    enemy_candidates = load_json(OUT / "battle_enemy_candidates.json", {})
    cns_payloads = load_json(OUT / "cns_payloads.json", [])

    exe = EXE.read_bytes()
    sections = read_sections(exe)
    rng_calls = find_rng_calls(exe, sections)
    resource_summary = build_resource_descriptor_summary(exe, sections, cns_payloads)

    btl_by_family = build_btl_family_index(backgrounds)
    scene_btls = scene_resource_btl_refs(scene_link)
    enemy_by_btl = battle_enemy_by_background(enemy_candidates)
    rows = build_map_rows(maps, btl_by_family, scene_btls, enemy_by_btl)
    summary = evidence_summary(rows, rng_calls, resource_summary)
    return {
        "kind": "hwanse-field-encounter-static-review",
        "promotionStatus": "field-encounter-background-candidates-not-original-table",
        "source": [
            "Hwanse2.exe",
            "out/map_layout_reviews.json",
            "out/battle_backgrounds.json",
            "out/scene_seq_resource_record_link_review.json",
            "out/battle_enemy_candidates.json",
        ],
        "decisions": [
            {
                "item": "walking step",
                "decision": "required-before-encounter-check",
                "evidence": "User/original behavior observation: encounters are checked while walking, not while idle. Browser prototype also keeps this as step-gated but is not original proof.",
            },
            {
                "item": "field map -> battle background",
                "decision": "family-derived-candidate",
                "evidence": "map_* family letters match available btl_* family letters; EXE btl resource descriptors are grounded, but the encounter consumer is not.",
            },
            {
                "item": "field map -> monster formation",
                "decision": "not-promoted",
                "evidence": "Existing battle_enemy_candidates are event/resource-proximity candidates, not original random encounter rows.",
            },
            {
                "item": "RNG",
                "decision": "classified-but-no-direct-field-encounter-consumer",
                "evidence": "0x00427730 call sites split into level-up/stat, scene random gate, battle formula/reward, battle display helper random-range, and generic VM random selector. No callsite has direct nearby map/btl/monster table binding.",
            },
            {
                "item": "display helper RNG opcode",
                "decision": "not-field-encounter",
                "evidence": "0x4056ef is handler table 0x00440538 opcode 0x2b. It stores rand(script word) to display/context +0x58 and matches battle helper child script random-range rows.",
            },
            {
                "item": "remaining generic VM random selector",
                "decision": "handler-only-unbound",
                "evidence": "0x40bdcc is handler table 0x00440720 opcode 0x1c mode 2. A static scan found zero well-formed opcode 0x1c command blocks, and the old script_handler_table refs are low-byte pointer artifacts, so it is not promoted as encounter evidence.",
            },
        ],
        "summary": summary,
        "btlByFamily": btl_by_family,
        "rngCalls": rng_calls,
        "rows": rows,
        "knownGaps": [
            "Exact walking step counter memory/threshold in original EXE.",
            "Original map/scene/flag eligibility for random encounters.",
            "Original encounter group table.",
            "Original monster formation table and per-formation probability.",
            "Original battle background selection consumer.",
            "Runtime-only possibility: a dynamically materialized stream could still reach opcode 0x1c, but no static well-formed stream currently does.",
        ],
    }


def main() -> None:
    OUT.mkdir(parents=True, exist_ok=True)
    WEB.mkdir(parents=True, exist_ok=True)
    report = build_report()
    dump_json(OUT / "field_encounter_static_review.json", report)
    html_text = render_html(report)
    (WEB / "field_encounter_static_review.html").write_text(html_text, encoding="utf-8")
    print("wrote field encounter static review")


if __name__ == "__main__":
    main()
