#!/usr/bin/env python3
"""Build the field encounter formation boundary review.

The previous field/battle reviews closed several nearby surfaces:

* movement/collision does not directly contain encounter RNG or battle CNS refs,
* active object trigger scripts do not prove map-transition or battle-entry
  consumers,
* resource VM callers are generic resource consumers, not encounter producers.

This report narrows the next missing link: the producer that turns a successful
field walk/trigger into a concrete battle formation.  It deliberately keeps
resource co-location, battle-event adjacency, and enemy sprite proximity as
candidate-only evidence until a formation/actor-instantiation consumer is found.
"""
from __future__ import annotations

import html
import json
import re
import struct
import sys
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 read_sections, va_to_offset  # noqa: E402


PROMOTION_STATUS = "formation-consumer-grounded-field-producer-not-found"
ACTOR_ROW_TABLE_VA = 0x00457C60
EXPORTED_ENEMY_TABLE_VA = 0x00457D48
ENEMY_NAME_POINTER_TABLE_VA = 0x004EC2C4
FORMATION_HANDLER_TABLE_BASE_VA = 0x00440720
FORMATION_ACTOR_OPCODE = 0x1E
FORMATION_ACTOR_HANDLER_ENTRY_VA = FORMATION_HANDLER_TABLE_BASE_VA + FORMATION_ACTOR_OPCODE * 4
FORMATION_ACTOR_HANDLER_VA = 0x0040C084
SINGLE_ACTOR_OPCODE = 0x1D
SINGLE_ACTOR_HANDLER_ENTRY_VA = FORMATION_HANDLER_TABLE_BASE_VA + SINGLE_ACTOR_OPCODE * 4
SINGLE_ACTOR_HANDLER_VA = 0x0040BE38
ACTOR_ROW_TABLE_HANDLER_VAS = [0x0040BED8, 0x0040BF89, 0x0040C0F6, 0x0040C1B6]


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


def hx(value: int | None) -> str:
    return "" 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 is_field_map_cns(name: str) -> bool:
    return name.startswith("map_") and name.endswith(".cns")


def is_battle_background_cns(name: str) -> bool:
    return bool(re.match(r"^btl_[a-z][0-9]\.cns$", name))


def is_monster_cns(name: str) -> bool:
    return (name.startswith("z") or name.startswith("boss_")) and name.endswith(".cns")


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


def exact_refs_all_sections(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
            rows.append({"va": section["va"] + offset, "vaHex": hx(section["va"] + offset), "section": section["name"]})
            start = offset + 1
    return rows


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


def read_window_hex(exe: bytes, sections: list[dict[str, Any]], va: int, before: int = 10, after: int = 10) -> str:
    offset = va_to_offset(sections, va - before)
    if offset is None:
        return ""
    return exe[offset : offset + before + after].hex(" ")


def selector_root_classification(selector_review: dict[str, Any]) -> dict[str, Any]:
    roots = selector_review.get("roots") or []
    rows: list[dict[str, Any]] = []
    counts = {
        "rootCount": len(roots),
        "fieldMapRootCount": 0,
        "battleBackgroundRootCount": 0,
        "monsterRootCount": 0,
        "fieldMapAndMonsterRootCount": 0,
        "fieldMapAndBattleBackgroundRootCount": 0,
        "battleBackgroundAndMonsterRootCount": 0,
        "allThreeRootCount": 0,
    }
    for root in roots:
        linked = root.get("linkedCns") or []
        field_maps = [name for name in linked if is_field_map_cns(name)]
        battle_backgrounds = [name for name in linked if is_battle_background_cns(name)]
        monsters = [name for name in linked if is_monster_cns(name)]
        has_map = bool(field_maps)
        has_btl = bool(battle_backgrounds)
        has_monster = bool(monsters)
        counts["fieldMapRootCount"] += int(has_map)
        counts["battleBackgroundRootCount"] += int(has_btl)
        counts["monsterRootCount"] += int(has_monster)
        counts["fieldMapAndMonsterRootCount"] += int(has_map and has_monster)
        counts["fieldMapAndBattleBackgroundRootCount"] += int(has_map and has_btl)
        counts["battleBackgroundAndMonsterRootCount"] += int(has_btl and has_monster)
        counts["allThreeRootCount"] += int(has_map and has_btl and has_monster)
        if has_map or has_btl or has_monster:
            rows.append(
                {
                    "rootVaHex": root.get("rootVaHex"),
                    "selectorKeys": root.get("selectorKeys") or [],
                    "rootClass": root.get("rootClass"),
                    "fieldMaps": field_maps,
                    "battleBackgrounds": battle_backgrounds,
                    "monsters": monsters,
                    "resourceRefCount": root.get("resourceRefCount"),
                    "promptCount": root.get("promptCount"),
                    "formationProof": False,
                    "reason": "selector root resource grouping only; no walk/encounter producer or actor formation binding",
                }
            )
    rows.sort(key=lambda row: (not row["fieldMaps"], not row["battleBackgrounds"], not row["monsters"], row["rootVaHex"] or ""))
    return {"counts": counts, "rows": rows}


def source_layer_rows(
    field_static: dict[str, Any],
    battle_event: dict[str, Any],
    battle_enemy: dict[str, Any],
    enemy_stats: dict[str, Any],
    actor_layout: dict[str, Any],
    monster_action: dict[str, Any],
    reward: dict[str, Any],
) -> list[dict[str, Any]]:
    field_summary = field_static.get("summary") or {}
    return [
        {
            "layer": "field family -> btl candidate",
            "status": field_static.get("promotionStatus") or "candidate",
            "count": field_summary.get("mapCount"),
            "provesFormation": False,
            "note": "map family can suggest battle backgrounds, but candidate backgrounds do not bind monster formation or probability.",
        },
        {
            "layer": "battle event dialogue adjacency",
            "status": "candidate-only",
            "count": battle_event.get("candidateCount"),
            "provesFormation": False,
            "note": "btl resources near dialogue/event blocks are event adjacency candidates, not random encounter tables.",
        },
        {
            "layer": "nearest enemy sprite proximity",
            "status": battle_enemy.get("promotionStatus") or "candidate",
            "count": battle_enemy.get("candidateCount"),
            "provesFormation": False,
            "note": "nearest z*/boss sprite names are visual proximity hints; original enemy rows/formations are not bound.",
        },
        {
            "layer": "enemy stat table",
            "status": enemy_stats.get("promotionStatus") or "grounded",
            "count": enemy_stats.get("rowCount"),
            "provesFormation": False,
            "note": "80 monster rows are grounded, but no field map/formation selector points to row indexes yet.",
        },
        {
            "layer": "battle actor stat layout",
            "status": actor_layout.get("status") or "grounded",
            "count": len(actor_layout.get("enemyActorRows") or []),
            "provesFormation": False,
            "note": "actor row copy/layout is grounded after an actor exists; the actor-instantiation producer is still missing.",
        },
        {
            "layer": "monster turn action selection",
            "status": monster_action.get("status") or "grounded",
            "count": (monster_action.get("metrics") or {}).get("descriptorScriptPairCount"),
            "provesFormation": False,
            "note": "action VM chooses a monster skill during battle; it is downstream of formation creation.",
        },
        {
            "layer": "battle end reward",
            "status": reward.get("status") or "grounded",
            "count": (reward.get("metrics") or {}).get("enemyRewardRows"),
            "provesFormation": False,
            "note": "EXP/gold/drop handling is downstream of defeated actors, not the entry selector.",
        },
    ]


def build_report() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    selector_review = read_json(OUT / "selector_root_structure_review.json", {})
    battle_event = read_json(OUT / "battle_event_candidates.json", {})
    battle_enemy = read_json(OUT / "battle_enemy_candidates.json", {})
    field_static = read_json(OUT / "field_encounter_static_review.json", {})
    field_step = read_json(OUT / "field_step_encounter_boundary_review.json", {})
    field_resource = read_json(OUT / "field_battle_resource_boundary_review.json", {})
    enemy_stats = read_json(OUT / "enemy_stat_table.json", {})
    actor_layout = read_json(OUT / "battle_actor_stat_layout_review.json", {})
    monster_action = read_json(OUT / "battle_monster_action_selection_review.json", {})
    reward = read_json(OUT / "battle_end_reward_review.json", {})
    opcode_cluster = read_json(OUT / "field_encounter_formation_opcode_cluster_review.json", {})
    opcode_cluster_summary = opcode_cluster.get("summary") or {}

    selector_class = selector_root_classification(selector_review)
    exact_refs = {
        "actorRowTableBase": exact_text_refs(exe, sections, ACTOR_ROW_TABLE_VA),
        "exportedEnemyTableBase": exact_text_refs(exe, sections, EXPORTED_ENEMY_TABLE_VA),
        "enemyNamePointerTable": exact_text_refs(exe, sections, ENEMY_NAME_POINTER_TABLE_VA),
    }
    single_actor_init_calls = call_refs(exe, sections, SINGLE_ACTOR_HANDLER_VA)
    formation_actor_handler_refs = exact_refs_all_sections(exe, sections, FORMATION_ACTOR_HANDLER_VA)
    actor_init_windows = [
        {"callVaHex": hx(va), "bytes": read_window_hex(exe, sections, va)}
        for va in single_actor_init_calls[:20]
    ]
    field_summary = field_static.get("summary") or {}
    step_summary = field_step.get("summary") or {}
    resource_summary = field_resource.get("summary") or {}
    counts = selector_class["counts"]
    source_layers = source_layer_rows(field_static, battle_event, battle_enemy, enemy_stats, actor_layout, monster_action, reward)

    return {
        "kind": "hwanse-field-encounter-formation-boundary-review",
        "promotionStatus": PROMOTION_STATUS,
        "source": [
            "out/selector_root_structure_review.json",
            "out/battle_event_candidates.json",
            "out/battle_enemy_candidates.json",
            "out/field_encounter_static_review.json",
            "out/field_step_encounter_boundary_review.json",
            "out/field_battle_resource_boundary_review.json",
            "out/enemy_stat_table.json",
            "out/battle_actor_stat_layout_review.json",
            "out/battle_monster_action_selection_review.json",
            "out/battle_end_reward_review.json",
            "out/field_encounter_formation_opcode_cluster_review.json",
            "tools/build_field_encounter_formation_boundary_review.py",
        ],
        "summary": {
            "selectorRootCount": counts["rootCount"],
            "selectorFieldMapRootCount": counts["fieldMapRootCount"],
            "selectorBattleBackgroundRootCount": counts["battleBackgroundRootCount"],
            "selectorMonsterRootCount": counts["monsterRootCount"],
            "selectorMapMonsterRootCount": counts["fieldMapAndMonsterRootCount"],
            "selectorBtlMonsterRootCount": counts["battleBackgroundAndMonsterRootCount"],
            "selectorAllThreeRootCount": counts["allThreeRootCount"],
            "battleEventCandidateCount": battle_event.get("candidateCount"),
            "battleEnemyCandidateCount": battle_enemy.get("candidateCount"),
            "enemyStatRowCount": enemy_stats.get("rowCount"),
            "rngDirectFieldEncounterEvidenceCount": field_summary.get("rngDirectFieldEncounterEvidenceCount"),
            "coreMovementBtlRefCount": step_summary.get("battleBackgroundCnsRefsInKnownMovementWindows"),
            "coreMovementMonsterRefCount": step_summary.get("monsterOrBattleActorCnsRefsInKnownMovementWindows"),
            "resourceRunnerBattleEntryProofCount": resource_summary.get("directResourceRunnerBattleEntryProofCount"),
            "actorRowTableExactTextRefCount": len(exact_refs["actorRowTableBase"]),
            "exportedEnemyTableExactTextRefCount": len(exact_refs["exportedEnemyTableBase"]),
            "enemyNamePointerTableExactTextRefCount": len(exact_refs["enemyNamePointerTable"]),
            "singleActorHandlerVaHex": hx(SINGLE_ACTOR_HANDLER_VA),
            "singleActorHandlerDirectCallCount": len(single_actor_init_calls),
            "formationActorHandlerVaHex": hx(FORMATION_ACTOR_HANDLER_VA),
            "formationActorHandlerTableBaseVaHex": hx(FORMATION_HANDLER_TABLE_BASE_VA),
            "formationActorHandlerOpcodeHex": f"0x{FORMATION_ACTOR_OPCODE:02x}",
            "formationActorHandlerEntryVaHex": hx(FORMATION_ACTOR_HANDLER_ENTRY_VA),
            "formationActorHandlerPointerRefCount": len(formation_actor_handler_refs),
            "formationConsumerPromoted": True,
            "fieldFormationProducerPromoted": False,
            "encounterProbabilityPromoted": False,
            "battleEntryBindingPromoted": False,
            "opcodeClusterDirectProducerFound": opcode_cluster_summary.get("directFieldEncounterProducerFound", False),
            "opcodeClusterPlausibleBlockCount": opcode_cluster_summary.get("opcode1bPlausibleBlockCount", 0),
            "opcodeClusterStrongFormationRefs": opcode_cluster_summary.get("formationStrongReferencedCandidateCount", 0),
            "conclusion": (
                "전투 actor 편성 소비자 0x0040c084는 stream byte row index 목록을 읽어 enemy actor를 생성하는 handler로 확인됐다. "
                "하지만 필드 걷기/트리거에서 이 stream을 선택하거나 공급하는 producer는 아직 발견되지 않았다. "
                "selector root 안에서도 map root와 monster root는 분리되어 있고, btl background root는 직접 검출되지 않는다."
            ),
        },
        "decisions": [
            {
                "id": "formation-actor-consumer",
                "status": "grounded-consumer",
                "decision": "handler 0x0040c084 is a battle actor formation consumer.",
                "evidence": (
                    "handler table base 0x00440720 opcode 0x1e entry 0x00440798 -> 0x0040c084; "
                    "stream[1] count, then each 8-byte entry uses +0 row index, +4 X, +6 Y"
                ),
            },
            {
                "id": "selector-root-co-location",
                "status": "negative-evidence",
                "decision": "selector root alone cannot be used as a field encounter formation table.",
                "evidence": (
                    f"map roots={counts['fieldMapRootCount']}, monster roots={counts['monsterRootCount']}, "
                    f"btl roots={counts['battleBackgroundRootCount']}, all-three roots={counts['allThreeRootCount']}"
                ),
            },
            {
                "id": "stat-table-binding",
                "status": "consumer-grounded",
                "decision": "enemy stat rows and row-index consumption are grounded, but field-side row-index production is not.",
                "evidence": f"enemy rows={enemy_stats.get('rowCount')}; exact .text refs to exported table base={len(exact_refs['exportedEnemyTableBase'])}",
            },
            {
                "id": "battle-candidates",
                "status": "candidate-only",
                "decision": "battle event and sprite proximity candidates remain non-promoting.",
                "evidence": f"event candidates={battle_event.get('candidateCount')}; sprite candidates={battle_enemy.get('candidateCount')}",
            },
            {
                "id": "formation-opcode-cluster",
                "status": "adjacent-consumer-cluster",
                "decision": "opcode 0x1b..0x20/0x22 cluster is battle setup adjacent, but still producer-unbound.",
                "evidence": (
                    f"opcode1b plausible blocks={opcode_cluster_summary.get('opcode1bPlausibleBlockCount', 0)}; "
                    f"opcode1c well formed={opcode_cluster_summary.get('opcode1cWellFormedBlockCount', 0)}; "
                    f"strong formation refs={opcode_cluster_summary.get('formationStrongReferencedCandidateCount', 0)}; "
                    f"direct field producer={opcode_cluster_summary.get('directFieldEncounterProducerFound', False)}"
                ),
            },
            {
                "id": "movement-resource-boundary",
                "status": "blocked",
                "decision": "known movement/resource boundaries still do not contain the formation producer.",
                "evidence": (
                    f"movement btl refs={step_summary.get('battleBackgroundCnsRefsInKnownMovementWindows')}; "
                    f"movement monster refs={step_summary.get('monsterOrBattleActorCnsRefsInKnownMovementWindows')}; "
                    f"resource-runner entry proof={resource_summary.get('directResourceRunnerBattleEntryProofCount')}"
                ),
            },
        ],
        "sourceLayers": source_layers,
        "selectorRootClassification": selector_class,
        "exactTextRefs": {key: [hx(value) for value in values] for key, values in exact_refs.items()},
        "formationActorConsumer": {
            "handlerVaHex": hx(FORMATION_ACTOR_HANDLER_VA),
            "handlerTableBaseVaHex": hx(FORMATION_HANDLER_TABLE_BASE_VA),
            "handlerOpcodeHex": f"0x{FORMATION_ACTOR_OPCODE:02x}",
            "handlerEntryVaHex": hx(FORMATION_ACTOR_HANDLER_ENTRY_VA),
            "handlerPointerRefs": formation_actor_handler_refs,
            "neighborHandlers": [
                {"opcodeHex": f"0x{SINGLE_ACTOR_OPCODE:02x}", "handlerEntryVaHex": hx(SINGLE_ACTOR_HANDLER_ENTRY_VA), "handlerVaHex": hx(SINGLE_ACTOR_HANDLER_VA), "role": "single/party actor row initializer"},
                {"opcodeHex": f"0x{FORMATION_ACTOR_OPCODE:02x}", "handlerEntryVaHex": hx(FORMATION_ACTOR_HANDLER_ENTRY_VA), "handlerVaHex": hx(FORMATION_ACTOR_HANDLER_VA), "role": "multi enemy actor formation initializer"},
                {"opcodeHex": "0x1f", "handlerEntryVaHex": "0x0044079c", "handlerVaHex": "0x0040c2fc", "role": "neighbor battle actor/display initializer"},
            ],
            "streamLayout": {
                "headerByte0": "unresolved",
                "headerByte1": "enemy actor count copied to 0x0059db28",
                "headerAdvance": 4,
                "entrySize": 8,
                "entryByte0": "actor/enemy stat row index into 0x00457c60 + rowIndex * 0x38",
                "entryWord4": "battle actor X position, stored as fixed-point actor +0x8c",
                "entryWord6": "battle actor Y position, stored as fixed-point actor +0x90",
                "entryBytes1To3": "unresolved",
            },
            "actorRowBaseRefsInHandler": [hx(value) for value in ACTOR_ROW_TABLE_HANDLER_VAS],
            "promotesFieldEncounterProducer": False,
        },
        "singleActorInitDirectCalls": actor_init_windows,
        "nextFrontier": [
            "Find the event/encounter stream producer that reaches handler-table opcode 0x1e / 0x0040c084.",
            "Use the 0x1b..0x20/0x22 opcode cluster as battle-setup context, not as map encounter classification by itself.",
            "Search for indirect actor row table base arithmetic or row-index generation near battle setup, not exact dword refs.",
            "Separate event battle setup from random field encounter setup if their producers diverge.",
            "Only promote map -> battle/monster binding when a producer consumes field state and writes battle actors/background together.",
        ],
    }


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    cards = [
        ("status", report["promotionStatus"]),
        ("selector roots", summary["selectorRootCount"]),
        ("map roots", summary["selectorFieldMapRootCount"]),
        ("monster roots", summary["selectorMonsterRootCount"]),
        ("btl roots", summary["selectorBattleBackgroundRootCount"]),
        ("all-three roots", summary["selectorAllThreeRootCount"]),
        ("enemy rows", summary["enemyStatRowCount"]),
        ("formation consumer", summary["formationConsumerPromoted"]),
        ("field producer", summary["fieldFormationProducerPromoted"]),
    ]
    card_html = "".join(f"<div class='card'><b>{esc(k)}</b><span>{esc(v)}</span></div>" for k, v in cards)
    decision_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['id'])}</code></td>"
        f"<td><span class='status {esc(row['status'])}'>{esc(row['status'])}</span></td>"
        f"<td>{esc(row['decision'])}</td>"
        f"<td>{esc(row['evidence'])}</td>"
        "</tr>"
        for row in report["decisions"]
    )
    layer_rows = "".join(
        "<tr>"
        f"<td>{esc(row['layer'])}</td>"
        f"<td>{esc(row['status'])}</td>"
        f"<td>{esc(row['count'])}</td>"
        f"<td>{esc(row['provesFormation'])}</td>"
        f"<td>{esc(row['note'])}</td>"
        "</tr>"
        for row in report["sourceLayers"]
    )
    selector_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['rootVaHex'])}</code><br>{esc(', '.join(row['selectorKeys']))}</td>"
        f"<td>{esc(row['rootClass'])}</td>"
        f"<td>{esc(', '.join(row['fieldMaps'][:8]))}</td>"
        f"<td>{esc(', '.join(row['battleBackgrounds'][:8]))}</td>"
        f"<td>{esc(', '.join(row['monsters'][:8]))}</td>"
        f"<td>{esc(row['formationProof'])}</td>"
        "</tr>"
        for row in report["selectorRootClassification"]["rows"][:120]
    )
    exact_rows = "".join(
        "<tr>"
        f"<td>{esc(key)}</td>"
        f"<td>{esc(len(values))}</td>"
        f"<td>{esc(', '.join(values[:24]))}</td>"
        "</tr>"
        for key, values in report["exactTextRefs"].items()
    )
    consumer = report["formationActorConsumer"]
    consumer_rows = "".join(
        "<tr>"
        f"<td>{esc(key)}</td>"
        f"<td>{esc(value)}</td>"
        "</tr>"
        for key, value in consumer["streamLayout"].items()
    )
    neighbor_rows = "".join(
        "<tr>"
        f"<td>{esc(row['opcodeHex'])}<br><code>{esc(row['handlerEntryVaHex'])}</code></td>"
        f"<td><code>{esc(row['handlerVaHex'])}</code></td>"
        f"<td>{esc(row['role'])}</td>"
        "</tr>"
        for row in consumer["neighborHandlers"]
    )
    frontier = "".join(f"<li>{esc(item)}</li>" for item in report["nextFrontier"])
    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 Formation 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(150px,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: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:880px; 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; }}
    .status {{ border-radius:999px; padding:2px 8px; background:#374151; white-space:nowrap; }}
    .negative-evidence, .candidate-only {{ background:#92400e; }}
    .downstream-grounded {{ background:#065f46; }}
    .blocked {{ background:#7f1d1d; }}
    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_static_review.html">field encounter static</a>
    <a class="chip" href="field_step_encounter_boundary_review.html">step boundary</a>
    <a class="chip" href="field_battle_resource_boundary_review.html">battle resource boundary</a>
    <a class="chip" href="selector_root_structure_review.html">selector root</a>
    <a class="chip" href="monster_stats.html">monster stats</a>
  </div>
  <h1>Field Encounter Formation Boundary Review</h1>
  <p>{esc(summary["conclusion"])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Formation Actor Consumer</h2>
    <p>Handler table base <code>{esc(consumer["handlerTableBaseVaHex"])}</code> opcode <code>{esc(consumer["handlerOpcodeHex"])}</code> entry <code>{esc(consumer["handlerEntryVaHex"])}</code> points to <code>{esc(consumer["handlerVaHex"])}</code>. This is now a grounded consumer of battle actor formation stream entries, but not the field-side producer.</p>
    <table><thead><tr><th>stream field</th><th>meaning</th></tr></thead><tbody>{consumer_rows}</tbody></table>
    <h3>Neighbor handlers</h3>
    <table><thead><tr><th>opcode / entry</th><th>handler</th><th>role</th></tr></thead><tbody>{neighbor_rows}</tbody></table>
  </section>
  <section>
    <h2>Decisions</h2>
    <table><thead><tr><th>id</th><th>status</th><th>decision</th><th>evidence</th></tr></thead><tbody>{decision_rows}</tbody></table>
  </section>
  <section>
    <h2>Source Layers</h2>
    <table><thead><tr><th>layer</th><th>status</th><th>count</th><th>proves formation</th><th>note</th></tr></thead><tbody>{layer_rows}</tbody></table>
  </section>
  <section>
    <h2>Selector Root Resource Split</h2>
    <table><thead><tr><th>root</th><th>class</th><th>map refs</th><th>btl refs</th><th>monster refs</th><th>formation proof</th></tr></thead><tbody>{selector_rows}</tbody></table>
  </section>
  <section>
    <h2>Exact Table References In .text</h2>
    <table><thead><tr><th>target</th><th>count</th><th>refs</th></tr></thead><tbody>{exact_rows}</tbody></table>
  </section>
  <section>
    <h2>Next Frontier</h2>
    <ul>{frontier}</ul>
  </section>
  <section>
    <h2>Raw JSON</h2>
    <pre id="json"></pre>
  </section>
</main>
<script>
window.HWANSE_FIELD_ENCOUNTER_FORMATION_BOUNDARY_REVIEW_READY = true;
window.HWANSE_FIELD_ENCOUNTER_FORMATION_BOUNDARY_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_FIELD_ENCOUNTER_FORMATION_BOUNDARY_REVIEW, null, 2);
</script>
</body>
</html>
"""


def main() -> int:
    report = build_report()
    write_json(OUT / "field_encounter_formation_boundary_review.json", report)
    html_text = render_html(report)
    (WEB / "field_encounter_formation_boundary_review.html").write_text(html_text, encoding="utf-8")
    print("field_encounter_formation_boundary_review ok")
    print(json.dumps(report["summary"], ensure_ascii=False, indent=2))
    return 0


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