#!/usr/bin/env python3
"""Summarize the opcode cluster around battle actor formation setup.

The previous formation stream review grounded opcode 0x1e as the compact
battle actor formation consumer.  This report keeps digging one layer earlier:
the adjacent handlers in the same table mutate the VM context pointer, create
party/enemy actor objects, clear actor slots, and dispatch nested scripts.

The important boundary is conservative: this cluster is battle-formation
adjacent, but it still does not prove a field walking encounter producer or a
map encounter/no-encounter table.
"""
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 offset_to_va, read_sections, va_to_offset  # noqa: E402


HANDLER_TABLE_BASE_VA = 0x00440720
DEFAULT_HANDLER_VA = 0x0040239F
CLUSTER_OPCODES = [0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x22]
EXPECTED_HANDLERS = {
    0x1B: 0x0040BBF3,
    0x1C: 0x0040BCC9,
    0x1D: 0x0040BE38,
    0x1E: 0x0040C084,
    0x1F: 0x0040C2FC,
    0x20: 0x0040C3F4,
    0x22: 0x0040C4E0,
}


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(path: Path, default: Any) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return default


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_at_va(exe: bytes, sections: list[dict[str, Any]], va: int, size: int) -> bytes:
    offset = va_to_offset(sections, va)
    if offset is None:
        return b""
    return exe[offset : offset + size]


def dword_at_va(exe: bytes, sections: list[dict[str, Any]], va: int) -> int | None:
    data = bytes_at_va(exe, sections, va, 4)
    if len(data) != 4:
        return None
    return struct.unpack_from("<I", data)[0]


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


def valid_stream_pointer(sections: list[dict[str, Any]], value: int) -> bool:
    section = section_for_va(sections, value)
    return section is not None and section["name"] in {".text", ".rdata", ".data"}


def pointer_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"]]
        cursor = 0
        while True:
            found = blob.find(needle, cursor)
            if found < 0:
                break
            va = section["va"] + found
            rows.append({"section": section["name"], "va": va, "vaHex": hx(va)})
            cursor = found + 1
    return rows


def handler_rows(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for opcode in CLUSTER_OPCODES:
        entry_va = HANDLER_TABLE_BASE_VA + opcode * 4
        actual = dword_at_va(exe, sections, entry_va)
        expected = EXPECTED_HANDLERS[opcode]
        rows.append(
            {
                "opcode": opcode,
                "opcodeHex": f"0x{opcode:02x}",
                "entryVa": entry_va,
                "entryVaHex": hx(entry_va),
                "handlerVa": actual,
                "handlerVaHex": hx(actual),
                "expectedHandlerVaHex": hx(expected),
                "matchesExpected": actual == expected,
                "handlerRefs": pointer_refs(exe, sections, expected),
            }
        )
    return rows


def scan_opcode1b_blocks(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Find plausible opcode 0x1b command blocks.

    Layout from handler 0x0040bbf3:
      mode 0: [1b 00 ?? ??], clears 0x59db24 and advances 4 bytes.
      mode 1: [1b 01 index ?? tablePtr32], pushes return ctx+8, then jumps
              to tablePtr[index] and sets 0x59db24=1.
      mode 2: [1b 02 ?? ?? targetPtr32], jumps to targetPtr only when
              0x59db24 != 0; otherwise advances 8 bytes.
    """
    rows: list[dict[str, Any]] = []
    for section in sections:
        if section["name"] not in {".data", ".rdata"}:
            continue
        start = section["raw"]
        end = start + section["raw_size"]
        for offset in range(start, max(start, end - 8)):
            if exe[offset] != 0x1B:
                continue
            mode = exe[offset + 1]
            if mode not in {0, 1, 2}:
                continue
            va = offset_to_va(sections, offset)
            if va is None:
                continue
            raw8 = exe[offset : offset + 8]
            row: dict[str, Any] = {
                "streamVa": va,
                "streamVaHex": hx(va),
                "section": section["name"],
                "mode": mode,
                "modeHex": f"0x{mode:02x}",
                "rawBytes": raw8.hex(" "),
                "classification": "opcode1b-plausible",
            }
            if mode == 0:
                row.update({"length": 4, "meaning": "clear branch-state global 0x0059db24"})
            else:
                ptr = struct.unpack_from("<I", exe, offset + 4)[0]
                row.update(
                    {
                        "length": 8,
                        "operandPointerHex": hx(ptr),
                        "operandPointerValid": valid_stream_pointer(sections, ptr),
                    }
                )
                if mode == 1:
                    index = exe[offset + 2]
                    table_targets: list[str | None] = []
                    table_valid = False
                    ptr_offset = va_to_offset(sections, ptr)
                    if ptr_offset is not None and ptr_offset + (index + 1) * 4 <= len(exe):
                        for table_index in range(index + 1):
                            target = struct.unpack_from("<I", exe, ptr_offset + table_index * 4)[0]
                            table_targets.append(hx(target))
                        table_valid = bool(table_targets) and valid_stream_pointer(
                            sections, int(table_targets[-1], 16)
                        )
                    row.update(
                        {
                            "indexByte": index,
                            "indexByteHex": f"0x{index:02x}",
                            "meaning": "call/jump through dword table and set branch-state global 0x0059db24",
                            "tableTargetsThroughIndex": table_targets,
                            "selectedTargetValid": table_valid,
                        }
                    )
                    if not table_valid:
                        row["classification"] = "opcode1b-mode1-pointer-unproven"
                else:
                    row["meaning"] = "conditional jump when branch-state global 0x0059db24 is set"
                    if not row["operandPointerValid"]:
                        row["classification"] = "opcode1b-mode2-pointer-unproven"
            rows.append(row)
    return rows


def opcode1c_pointer_count(mode: int, byte2: int, byte3: int) -> int:
    if mode in {0, 2}:
        return byte2
    if mode == 1:
        return byte3 - byte2
    return -1


def scan_opcode1c_blocks(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for section in sections:
        if section["name"] not in {".data", ".rdata"}:
            continue
        start = section["raw"]
        end = start + section["raw_size"]
        for offset in range(start, max(start, end - 12)):
            if exe[offset] != 0x1C:
                continue
            mode = exe[offset + 1]
            if mode not in {0, 1, 2}:
                continue
            b2 = exe[offset + 2]
            b3 = exe[offset + 3]
            count = opcode1c_pointer_count(mode, b2, b3)
            if count <= 0 or count > 64:
                continue
            ptrs: list[int] = []
            ok = True
            for index in range(count):
                ptr_offset = offset + 4 + index * 4
                if ptr_offset + 4 > len(exe):
                    ok = False
                    break
                ptr = struct.unpack_from("<I", exe, ptr_offset)[0]
                if not valid_stream_pointer(sections, ptr):
                    ok = False
                    break
                ptrs.append(ptr)
            if not ok:
                continue
            va = offset_to_va(sections, offset)
            if va is None:
                continue
            rows.append(
                {
                    "streamVa": va,
                    "streamVaHex": hx(va),
                    "section": section["name"],
                    "mode": mode,
                    "modeHex": f"0x{mode:02x}",
                    "byte2Hex": f"0x{b2:02x}",
                    "byte3Hex": f"0x{b3:02x}",
                    "pointerCount": count,
                    "pointerVasHex": [hx(ptr) for ptr in ptrs],
                }
            )
    return rows


def collect_script_handler_low_byte_refs(
    exe: bytes, sections: list[dict[str, Any]]
) -> list[dict[str, Any]]:
    table = read_json(OUT / "script_handler_table.json", {})
    rows: list[dict[str, Any]] = []
    wanted = {f"0x{opcode:02x}" for opcode in CLUSTER_OPCODES}
    for entry in table.get("entries") or []:
        opcode_hex = entry.get("opcodeHex")
        if opcode_hex not in wanted:
            continue
        for ref in entry.get("references") or []:
            word_va_hex = ref.get("wordVaHex")
            word_va = int(word_va_hex, 16) if isinstance(word_va_hex, str) else None
            raw = bytes_at_va(exe, sections, word_va, 8) if word_va is not None else b""
            opcode = int(opcode_hex, 16)
            rows.append(
                {
                    "opcodeHex": opcode_hex,
                    "streamKind": ref.get("streamKind"),
                    "streamVaHex": ref.get("streamVaHex"),
                    "wordVaHex": word_va_hex,
                    "valueHex": ref.get("valueHex"),
                    "source": ref.get("source"),
                    "target": ref.get("target"),
                    "cns": ref.get("cns"),
                    "rawBytesAtWord": raw.hex(" ") if raw else "",
                    "rawBeginsWithOpcode": bool(raw and raw[0] == opcode),
                    "interpretation": (
                        "direct opcode-looking byte at referenced word"
                        if raw and raw[0] == opcode
                        else "low-byte/pointer artifact; not executable command proof"
                    ),
                }
            )
    return rows


def opcode_semantics() -> list[dict[str, Any]]:
    return [
        {
            "opcodeHex": "0x1b",
            "name": "branch-state call/jump",
            "evidence": "handler 0x0040bbf3 writes VM ctx+0x40 and global 0x0059db24",
            "meaning": "mode 0 clears branch-state, mode 1 jumps through a pointer table and saves return ctx+8, mode 2 conditionally jumps when branch-state is set",
            "producerStatus": "stream-control primitive; not a field encounter producer by itself",
        },
        {
            "opcodeHex": "0x1c",
            "name": "selector / RNG jump",
            "evidence": "handler 0x0040bcc9 writes VM ctx+0x40; mode 2 calls RNG helper 0x00427730",
            "meaning": "selects a following dword command pointer by active actor count/order or RNG range",
            "producerStatus": "selector primitive; no well-formed static command stream found in current scan",
        },
        {
            "opcodeHex": "0x1d",
            "name": "party/generic actor initializer",
            "evidence": "handler 0x0040be38 creates actors using active party globals 0x004576e8/0x004576e9 or actor rows 0x00457c60 when 0x0059db24 is set",
            "meaning": "initializes player-side actors or generic row-based actors and advances ctx by 4",
            "producerStatus": "battle setup consumer; not a formation table producer",
        },
        {
            "opcodeHex": "0x1e",
            "name": "explicit actor formation stream",
            "evidence": "handler 0x0040c084 reads count, actor row byte, and x/y words; stores actors at 0x0059db3c",
            "meaning": "after a 4-byte header, consumes count entries of 8 bytes: row index at +0, x word at +4, y word at +6; bytes +1..+3 are not consumed by this handler",
            "producerStatus": "grounded formation consumer; producer remains unbound",
        },
        {
            "opcodeHex": "0x1f",
            "name": "actor slot clear/reset",
            "evidence": "handler 0x0040c2fc clears generated slots, releases 0x0059ddc0 handles, then restores party actor pointers",
            "meaning": "resets battle actor slots and active party references",
            "producerStatus": "cleanup/reset primitive",
        },
        {
            "opcodeHex": "0x20",
            "name": "party nested script dispatcher",
            "evidence": "handler 0x0040c3f4 calls 0x00402360 for script pointers attached to active party rows/global row",
            "meaning": "dispatches nested scripts for active party actors; mode 0 and mode 1 choose different script pointer fields",
            "producerStatus": "nested dispatch primitive; no field encounter producer proof",
        },
        {
            "opcodeHex": "0x22",
            "name": "global-gated branch",
            "evidence": "handler 0x0040c4e0 checks global 0x0059e358 and jumps to command+4 pointer if nonzero",
            "meaning": "condition branch using a global battle/UI state flag",
            "producerStatus": "condition primitive; current refs are not enough to bind map transitions or encounters",
        },
    ]


def build_report() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    handlers = handler_rows(exe, sections)
    opcode1b = scan_opcode1b_blocks(exe, sections)
    opcode1c = scan_opcode1c_blocks(exe, sections)
    low_byte_refs = collect_script_handler_low_byte_refs(exe, sections)
    formation_stream = read_json(OUT / "field_encounter_formation_stream_review.json", {})
    opcode1b_counts = Counter(row["classification"] for row in opcode1b)
    low_ref_counts = Counter(row["opcodeHex"] for row in low_byte_refs)
    handler_ok = all(row["matchesExpected"] for row in handlers)
    return {
        "kind": "hwanse-field-encounter-formation-opcode-cluster-review",
        "status": "battle-formation-opcode-cluster-grounded-producer-unbound",
        "source": [
            "Hwanse2.exe",
            "out/script_handler_table.json",
            "out/field_encounter_formation_stream_review.json",
            "tools/build_field_encounter_formation_opcode_cluster_review.py",
        ],
        "handlerTable": {
            "baseVaHex": hx(HANDLER_TABLE_BASE_VA),
            "defaultHandlerVaHex": hx(DEFAULT_HANDLER_VA),
            "clusterOpcodes": [f"0x{opcode:02x}" for opcode in CLUSTER_OPCODES],
            "allClusterHandlersMatchExpected": handler_ok,
            "rows": handlers,
        },
        "semantics": opcode_semantics(),
        "summary": {
            "opcode1bPlausibleBlockCount": len(opcode1b),
            "opcode1bClassificationCounts": dict(sorted(opcode1b_counts.items())),
            "opcode1cWellFormedBlockCount": len(opcode1c),
            "scriptHandlerTableClusterRefCount": len(low_byte_refs),
            "scriptHandlerTableClusterRefCounts": dict(sorted(low_ref_counts.items())),
            "formationStreamCandidateCount": (formation_stream.get("summary") or {}).get("candidateCount"),
            "formationStrongReferencedCandidateCount": (formation_stream.get("summary") or {}).get(
                "strongReferencedCandidateCount"
            ),
            "directFieldEncounterProducerFound": False,
            "mapEncounterClassificationPromoted": False,
            "decision": (
                "The 0x1b..0x20/0x22 cluster is grounded as a battle setup/control cluster around actor formation. "
                "Opcode 0x1e is the explicit formation consumer, but no static producer currently binds it to field walking encounters or map IDs. "
                "Keep map encounter/no-encounter classification unpromoted."
            ),
        },
        "opcode1bCandidateBlocks": opcode1b,
        "opcode1cWellFormedBlocks": opcode1c,
        "scriptHandlerTableClusterRefs": low_byte_refs,
        "nextFrontier": [
            "Find a command/root that reaches a well-formed opcode 0x1e stream through ctx+0x40, not just a byte pattern.",
            "Only after such a producer is found, correlate it with field step RNG and btl background setup.",
            "Do not use low-byte refs from script_handler_table as direct opcode proof unless raw bytes begin at the opcode and operand layout validates.",
        ],
    }


def render_table(rows: list[dict[str, Any]], columns: list[tuple[str, str]]) -> str:
    if not rows:
        return "<p class='muted'>No rows.</p>"
    head = "".join(f"<th>{h(label)}</th>" for _, label in columns)
    body = []
    for row in rows:
        cells = []
        for key, _ in columns:
            value = row.get(key)
            if isinstance(value, (list, dict)):
                value = json.dumps(value, ensure_ascii=False)
            cells.append(f"<td>{h(value)}</td>")
        body.append("<tr>" + "".join(cells) + "</tr>")
    return f"<table><thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody></table>"


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    cards = [
        ("status", report["status"]),
        ("handler table", report["handlerTable"]["baseVaHex"]),
        ("handler match", report["handlerTable"]["allClusterHandlersMatchExpected"]),
        ("0x1b blocks", summary["opcode1bPlausibleBlockCount"]),
        ("0x1c blocks", summary["opcode1cWellFormedBlockCount"]),
        ("producer", summary["directFieldEncounterProducerFound"]),
    ]
    card_html = "".join(f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>" for k, v in cards)
    handler_table = render_table(
        report["handlerTable"]["rows"],
        [
            ("opcodeHex", "opcode"),
            ("entryVaHex", "handler table entry"),
            ("handlerVaHex", "handler"),
            ("expectedHandlerVaHex", "expected"),
            ("matchesExpected", "match"),
        ],
    )
    semantics_rows = render_table(
        report["semantics"],
        [
            ("opcodeHex", "opcode"),
            ("name", "role"),
            ("meaning", "meaning"),
            ("producerStatus", "status"),
        ],
    )
    opcode1b_rows = render_table(
        report["opcode1bCandidateBlocks"][:80],
        [
            ("streamVaHex", "stream"),
            ("section", "section"),
            ("modeHex", "mode"),
            ("classification", "class"),
            ("operandPointerHex", "operand ptr"),
            ("selectedTargetValid", "selected target valid"),
            ("rawBytes", "raw"),
        ],
    )
    opcode1c_rows = render_table(
        report["opcode1cWellFormedBlocks"][:80],
        [
            ("streamVaHex", "stream"),
            ("section", "section"),
            ("modeHex", "mode"),
            ("pointerCount", "ptr count"),
            ("pointerVasHex", "targets"),
        ],
    )
    ref_rows = render_table(
        report["scriptHandlerTableClusterRefs"][:80],
        [
            ("opcodeHex", "opcode"),
            ("wordVaHex", "word"),
            ("valueHex", "value"),
            ("source", "source"),
            ("target", "target"),
            ("rawBytesAtWord", "raw"),
            ("interpretation", "interpretation"),
        ],
    )
    frontier = "".join(f"<li>{h(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 Opcode Cluster 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(160px,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:960px; 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; }}
    pre {{ white-space:pre-wrap; background:#0b0f14; border:1px solid #253044; border-radius:8px; padding:12px; max-height:360px; overflow:auto; }}
    .muted {{ color:#9fb1c9; }}
  </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_encounter_formation_stream_review.html">formation stream</a>
    <a class="chip" href="field_encounter_formation_boundary_review.html">formation boundary</a>
    <a class="chip" href="battle_analysis.html">battle analysis</a>
  </div>
  <h1>Field Encounter Formation Opcode Cluster Review</h1>
  <p>{h(summary["decision"])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Handler Table Grounding</h2>
    {handler_table}
  </section>
  <section>
    <h2>Cluster Semantics</h2>
    {semantics_rows}
  </section>
  <section>
    <h2>Opcode 0x1b Candidate Blocks</h2>
    <p class="muted">These are plausible data/rdata blocks only. They are not promoted unless a root dispatch path reaches them.</p>
    {opcode1b_rows}
  </section>
  <section>
    <h2>Opcode 0x1c Well-Formed Blocks</h2>
    {opcode1c_rows}
  </section>
  <section>
    <h2>script_handler_table Cluster Refs</h2>
    <p class="muted">Most previous refs are low-byte/pointer artifacts. Raw bytes must begin at the opcode and validate operands before promotion.</p>
    {ref_rows}
  </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_OPCODE_CLUSTER_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_FIELD_ENCOUNTER_FORMATION_OPCODE_CLUSTER_REVIEW, null, 2);
</script>
</body>
</html>
"""


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


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