#!/usr/bin/env python3
"""Scan candidate opcode 0x1e battle actor formation streams.

The handler at 0x0040c084 is grounded as the consumer that creates multiple
battle actors from a compact stream:

  byte0 opcode 0x1e
  byte1 actor count
  byte2..3 padding/reserved bytes skipped by the handler
  then actorCount * 8-byte entries

Each entry uses byte +0 as an actor row index and words +4/+6 as battle X/Y.
Bytes +1..+3 are not consumed by handler 0x0040c084 and are treated as
reserved/padding until a producer proves a stronger meaning.
This report deliberately does not promote field encounter bindings unless the
stream candidate is tied back to a producer or field/map selector.
"""
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


HANDLER_TABLE_BASE_VA = 0x00440720
OPCODE = 0x1E
HANDLER_ENTRY_VA = HANDLER_TABLE_BASE_VA + OPCODE * 4
HANDLER_VA = 0x0040C084
ACTOR_ROW_TABLE_VA = 0x00457C60
ACTOR_ROW_SIZE = 0x38
ENEMY_ACTOR_ROW_BIAS = 4


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 read_dword_at_va(exe: bytes, sections: list[dict[str, Any]], va: int) -> int | None:
    offset = va_to_offset(sections, va)
    if offset is None or offset + 4 > len(exe):
        return None
    return struct.unpack_from("<I", exe, offset)[0]


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


def nearby_cns_refs(exe: bytes, sections: list[dict[str, Any]], strings: dict[int, str], offset: int) -> list[str]:
    lo = max(0, offset - 512)
    hi = min(len(exe), offset + 512)
    names: set[str] = set()
    for scan in range(lo, max(lo, hi - 3)):
        value = struct.unpack_from("<I", exe, scan)[0]
        if value in strings:
            names.add(strings[value])
    return sorted(names)


def actor_row_label(row_index: int, enemy_rows: list[dict[str, Any]]) -> dict[str, Any]:
    actual_row_va = ACTOR_ROW_TABLE_VA + row_index * ACTOR_ROW_SIZE
    if 0 <= row_index < ENEMY_ACTOR_ROW_BIAS:
        return {
            "rowIndex": row_index,
            "rowIndexHex": f"0x{row_index:02x}",
            "rowVaHex": hx(actual_row_va),
            "kind": "party-or-reserved-actor-row",
            "name": f"actor-row-{row_index}",
            "enemyIndex": None,
        }
    enemy_index = row_index - ENEMY_ACTOR_ROW_BIAS
    if 0 <= enemy_index < len(enemy_rows):
        row = enemy_rows[enemy_index]
        return {
            "rowIndex": row_index,
            "rowIndexHex": f"0x{row_index:02x}",
            "rowVaHex": hx(actual_row_va),
            "kind": "enemy-actor-row",
            "name": row.get("name") or row.get("rawName") or f"enemy-{enemy_index}",
            "enemyIndex": enemy_index,
            "hp": row.get("hp"),
            "gold": row.get("gold"),
            "level": row.get("level"),
        }
    return {
        "rowIndex": row_index,
        "rowIndexHex": f"0x{row_index:02x}",
        "rowVaHex": hx(actual_row_va),
        "kind": "unknown-actor-row",
        "name": f"unknown-row-{row_index}",
        "enemyIndex": None,
    }


def scan_opcode1e_candidates(
    exe: bytes,
    sections: list[dict[str, Any]],
    enemy_rows: list[dict[str, Any]],
    strings: dict[int, str],
) -> list[dict[str, Any]]:
    max_actor_row_index = ENEMY_ACTOR_ROW_BIAS + len(enemy_rows) - 1
    candidates: list[dict[str, Any]] = []
    for section in sections:
        if section["name"] not in {".data", ".rdata", ".text"}:
            continue
        start = section["raw"]
        end = section["raw"] + section["raw_size"]
        for offset in range(start, max(start, end - 12)):
            if exe[offset] != OPCODE:
                continue
            count = exe[offset + 1]
            if not 1 <= count <= 16:
                continue
            byte2 = exe[offset + 2]
            byte3 = exe[offset + 3]
            entries: list[dict[str, Any]] = []
            ok = True
            for index in range(count):
                entry_offset = offset + 4 + index * 8
                if entry_offset + 8 > len(exe):
                    ok = False
                    break
                row_index = exe[entry_offset]
                if row_index > max_actor_row_index:
                    ok = False
                    break
                x = struct.unpack_from("<H", exe, entry_offset + 4)[0]
                y = struct.unpack_from("<H", exe, entry_offset + 6)[0]
                if x > 800 or y > 600:
                    ok = False
                    break
                entries.append(
                    {
                        **actor_row_label(row_index, enemy_rows),
                        "entryIndex": index,
                        "rawBytes": exe[entry_offset : entry_offset + 8].hex(" "),
                        "byte1": exe[entry_offset + 1],
                        "byte1Hex": f"0x{exe[entry_offset + 1]:02x}",
                        "byte2": exe[entry_offset + 2],
                        "byte2Hex": f"0x{exe[entry_offset + 2]:02x}",
                        "byte3": exe[entry_offset + 3],
                        "byte3Hex": f"0x{exe[entry_offset + 3]:02x}",
                        "x": x,
                        "y": y,
                    }
                )
            if not ok:
                continue
            va = offset_to_va(sections, offset)
            if va is None:
                continue
            refs = pointer_refs_to_va(exe, sections, va)
            has_enemy = any(entry["kind"] == "enemy-actor-row" for entry in entries)
            has_only_known_rows = all(entry["kind"] != "unknown-actor-row" for entry in entries)
            header_zero = byte2 == 0 and byte3 == 0
            classification = "well-formed-unbound"
            if not has_enemy:
                classification = "well-formed-party-or-reserved-only"
            if refs:
                classification = "referenced-well-formed-candidate"
            candidates.append(
                {
                    "streamVa": va,
                    "streamVaHex": hx(va),
                    "section": section["name"],
                    "opcodeHex": f"0x{OPCODE:02x}",
                    "actorCount": count,
                    "headerByte2": byte2,
                    "headerByte2Hex": f"0x{byte2:02x}",
                    "headerByte3": byte3,
                    "headerByte3Hex": f"0x{byte3:02x}",
                    "headerZero": header_zero,
                    "entries": entries,
                    "entryNames": [entry["name"] for entry in entries],
                    "allRowsKnown": has_only_known_rows,
                    "hasEnemyActor": has_enemy,
                    "pointerRefs": refs,
                    "pointerRefCount": len(refs),
                    "nearbyCns": nearby_cns_refs(exe, sections, strings, offset),
                    "contextBytes": exe[max(0, offset - 24) : min(len(exe), offset + 4 + count * 8 + 24)].hex(" "),
                    "classification": classification,
                    "fieldEncounterBinding": False,
                    "note": "stream-like bytes only; no field/map producer points here yet",
                }
            )
    candidates.sort(key=lambda row: (row["pointerRefCount"] == 0, row["streamVa"]))
    return candidates


def selector_root_owners(roots: list[dict[str, Any]], va: int) -> list[dict[str, Any]]:
    owners: list[dict[str, Any]] = []
    for root in roots:
        start = root.get("rootVa")
        end = root.get("rangeEndVa")
        if not isinstance(start, int) or not isinstance(end, int):
            continue
        if not start <= va < end:
            continue
        owners.append(
            {
                "rootVaHex": root.get("rootVaHex"),
                "rangeEndVaHex": root.get("rangeEndVaHex"),
                "rootClass": root.get("rootClass"),
                "selectorKeys": root.get("selectorKeys") or [],
                "fieldMaps": root.get("fieldMaps") or [],
                "linkedCns": root.get("linkedCns") or [],
                "sequenceGroupIds": root.get("sequenceGroupIds") or [],
                "resourceRefCount": root.get("resourceRefCount"),
                "promptCount": root.get("promptCount"),
            }
        )
    return owners


def build_report() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    strings = find_cns_strings(exe, sections)
    actor_layout = read_json(OUT / "battle_actor_stat_layout_review.json", {})
    selector_review = read_json(OUT / "selector_root_structure_review.json", {})
    selector_roots = selector_review.get("roots") if isinstance(selector_review.get("roots"), list) else []
    enemy_rows = actor_layout.get("enemyActorRows") or []
    candidates = scan_opcode1e_candidates(exe, sections, enemy_rows, strings)
    for row in candidates:
        owners = selector_root_owners(selector_roots, int(row["streamVaHex"], 16))
        row["selectorRootOwners"] = owners
        row["selectorRootOwnerCount"] = len(owners)
        row["selectorRootOwnerClasses"] = sorted({str(owner.get("rootClass")) for owner in owners})
        row["selectorRootOwnerFieldMaps"] = sorted({
            str(field_map)
            for owner in owners
            for field_map in owner.get("fieldMaps", [])
        })
        row["selectorRootOwnerLinkedCns"] = sorted({
            str(name)
            for owner in owners
            for name in owner.get("linkedCns", [])
        })
    class_counts = Counter(row["classification"] for row in candidates)
    actor_count_counts = Counter(str(row["actorCount"]) for row in candidates)
    handler_entry_value = read_dword_at_va(exe, sections, HANDLER_ENTRY_VA)
    strong = [
        row
        for row in candidates
        if row["pointerRefCount"] and row["hasEnemyActor"] and row["allRowsKnown"]
    ]
    return {
        "kind": "hwanse-field-encounter-formation-stream-review",
        "status": "opcode1e-consumer-grounded-stream-producer-unbound",
        "source": [
            "Hwanse2.exe",
            "out/battle_actor_stat_layout_review.json",
            "out/selector_root_structure_review.json",
            "tools/build_field_encounter_formation_stream_review.py",
        ],
        "handler": {
            "handlerTableBaseVaHex": hx(HANDLER_TABLE_BASE_VA),
            "opcodeHex": f"0x{OPCODE:02x}",
            "handlerEntryVaHex": hx(HANDLER_ENTRY_VA),
            "handlerEntryValueHex": hx(handler_entry_value),
            "expectedHandlerVaHex": hx(HANDLER_VA),
            "handlerEntryMatchesExpected": handler_entry_value == HANDLER_VA,
            "actorRowTableVaHex": hx(ACTOR_ROW_TABLE_VA),
            "actorRowSizeHex": f"0x{ACTOR_ROW_SIZE:02x}",
            "enemyActorRowBias": ENEMY_ACTOR_ROW_BIAS,
        },
        "summary": {
            "candidateCount": len(candidates),
            "strongReferencedCandidateCount": len(strong),
            "classificationCounts": dict(sorted(class_counts.items())),
            "actorCountDistribution": dict(sorted(actor_count_counts.items(), key=lambda item: int(item[0]))),
            "candidateInsideSelectorRootCount": sum(1 for row in candidates if row["selectorRootOwnerCount"]),
            "candidateInsideSceneResourceRootCount": sum(
                1
                for row in candidates
                if "sequence-root-with-resource-structure" in row["selectorRootOwnerClasses"]
            ),
            "candidateOwnerWithFieldMapCount": sum(1 for row in candidates if row["selectorRootOwnerFieldMaps"]),
            "candidateExactPointerRefCount": sum(row["pointerRefCount"] for row in candidates),
            "directFieldEncounterBindingFound": False,
            "mapEncounterClassificationPromoted": False,
            "decision": (
                "opcode 0x1e handler 0x0040c084 is a grounded battle actor formation consumer, "
                "but static byte-stream scanning found no producer-bound field encounter formation table. "
                "The stream-like candidates are inline inside selector/resource roots and have no exact pointer refs. "
                "The candidates below are useful search anchors only and must not classify random-encounter maps."
            ),
        },
        "streamLayout": {
            "headerByte0": "opcode 0x1e",
            "headerByte1": "actor count copied by 0x0040c084 to 0x0059db28",
            "headerByte2Byte3": "padding/reserved; handler 0x0040c084 advances past these bytes without reading them",
            "entrySize": 8,
            "entryByte0": "actor row index into 0x00457c60 + index * 0x38; monster rows begin at index 4",
            "entryByte1To3": "padding/reserved for this handler; 0x0040c084 does not read these bytes",
            "entryWord4": "battle actor X position stored to actor +0x8c as fixed point",
            "entryWord6": "battle actor Y position stored to actor +0x90 as fixed point",
        },
        "candidates": candidates,
        "nextFrontier": [
            "Find a caller/producer that writes VM context +0x40 to one of these opcode 0x1e streams before battle entry.",
            "Correlate formation stream candidates with battle background setup and field walk encounter RNG only after a shared producer is found.",
            "Keep field map encounter/no-encounter classification unpromoted until the producer is found.",
        ],
    }


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    handler = report["handler"]
    cards = [
        ("status", report["status"]),
        ("opcode", handler["opcodeHex"]),
        ("handler", handler["expectedHandlerVaHex"]),
        ("candidates", summary["candidateCount"]),
        ("referenced strong", summary["strongReferencedCandidateCount"]),
        ("inside selector roots", summary["candidateInsideSelectorRootCount"]),
        ("owner has maps", summary["candidateOwnerWithFieldMapCount"]),
        ("field binding", summary["directFieldEncounterBindingFound"]),
    ]
    card_html = "".join(f"<div class='card'><b>{h(key)}</b><span>{h(value)}</span></div>" for key, value in cards)
    layout_rows = "".join(
        "<tr>"
        f"<td>{h(key)}</td>"
        f"<td>{h(value)}</td>"
        "</tr>"
        for key, value in report["streamLayout"].items()
    )
    def entry_position_summary(row: dict[str, Any]) -> str:
        return "; ".join(
            f"{entry['rowIndexHex']} {entry['name']} @ {entry['x']},{entry['y']}"
            for entry in row["entries"]
        )

    def owner_summary(row: dict[str, Any]) -> str:
        owners = row.get("selectorRootOwners") or []
        if not owners:
            return "-"
        owner = owners[0]
        maps = ", ".join((owner.get("fieldMaps") or [])[:5])
        cns = ", ".join((owner.get("linkedCns") or [])[:5])
        return (
            f"{owner.get('rootVaHex')} {owner.get('rootClass')} "
            f"selectors={','.join(owner.get('selectorKeys') or [])} "
            f"maps=[{maps}] cns=[{cns}]"
        )

    candidate_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['streamVaHex'])}</code><br>{h(row['section'])}</td>"
        f"<td>{h(row['classification'])}<br>refs {h(row['pointerRefCount'])}</td>"
        f"<td>{h(row['actorCount'])}</td>"
        f"<td><code>{h(row['headerByte2Hex'])}</code> <code>{h(row['headerByte3Hex'])}</code></td>"
        f"<td>{h(', '.join(row['entryNames']))}</td>"
        f"<td>{h(entry_position_summary(row))}</td>"
        f"<td>{h(', '.join(row['nearbyCns'][:8]))}</td>"
        f"<td>{h(owner_summary(row))}</td>"
        "</tr>"
        for row in report["candidates"]
    )
    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 Stream 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:980px; 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; }}
  </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_boundary_review.html">formation boundary</a>
    <a class="chip" href="battle_analysis.html">battle analysis</a>
  </div>
  <h1>Field Encounter Formation Stream Review</h1>
  <p>{h(summary["decision"])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Handler</h2>
    <p>Handler table base <code>{h(handler["handlerTableBaseVaHex"])}</code>, opcode <code>{h(handler["opcodeHex"])}</code>, entry <code>{h(handler["handlerEntryVaHex"])}</code> -> <code>{h(handler["handlerEntryValueHex"])}</code>.</p>
    <table><thead><tr><th>field</th><th>meaning</th></tr></thead><tbody>{layout_rows}</tbody></table>
  </section>
  <section>
    <h2>Opcode 0x1e Stream Candidates</h2>
    <table><thead><tr><th>stream</th><th>class</th><th>count</th><th>header b2/b3</th><th>entries</th><th>row / position</th><th>near CNS</th><th>selector root owner</th></tr></thead><tbody>{candidate_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_STREAM_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_FIELD_ENCOUNTER_FORMATION_STREAM_REVIEW, null, 2);
</script>
</body>
</html>
"""


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


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