#!/usr/bin/env python3
"""Review generic VM opcode 0x2a parent-stream evidence.

Opcode 0x2a is grounded as a linked-object script fanout helper.  The remaining
question is narrower: do any currently grounded active-object scripts actually
use it, and do raw 0x2a-looking bytes in data sections line up with real command
boundaries instead of operands inside other commands?
"""
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
from summarize_object_payload_442c75_callers import (  # noqa: E402
    decode_command,
    decode_stream,
    hex32,
    is_va,
    section_name_for,
)


GENERIC_HANDLER_TABLE_VA = 0x00440538
OPCODE_2A_HANDLER_TABLE_ENTRY_VA = GENERIC_HANDLER_TABLE_VA + 0x2A * 4
OPCODE_2A_HANDLER_VA = 0x004057EB


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 u8(exe: bytes, sections: list[dict[str, Any]], va: int) -> int:
    offset = va_to_offset(sections, va)
    if offset is None:
        raise ValueError(f"VA {hx(va)} is outside raw sections")
    return exe[offset]


def u16(exe: bytes, sections: list[dict[str, Any]], va: int) -> int:
    offset = va_to_offset(sections, va)
    if offset is None:
        raise ValueError(f"VA {hx(va)} is outside raw sections")
    return struct.unpack_from("<H", exe, offset)[0]


def u32(exe: bytes, sections: list[dict[str, Any]], va: int) -> int:
    offset = va_to_offset(sections, va)
    if offset is None:
        raise ValueError(f"VA {hx(va)} is outside raw sections")
    return struct.unpack_from("<I", exe, offset)[0]


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


def section_for_offset(sections: list[dict[str, Any]], offset: int) -> dict[str, Any] | None:
    for section in sections:
        start = int(section["raw"])
        end = start + int(section["raw_size"])
        if start <= offset < end:
            return section
    return None


def load_json(path: Path, fallback: Any) -> Any:
    if not path.exists():
        return fallback
    return json.loads(path.read_text(encoding="utf-8"))


def dword_refs_to(exe: bytes, sections: list[dict[str, Any]], target_va: int, limit: int = 16) -> list[dict[str, Any]]:
    needle = struct.pack("<I", target_va)
    rows: list[dict[str, Any]] = []
    pos = exe.find(needle)
    while pos != -1:
        ref_va = offset_to_va(sections, pos)
        section = section_for_offset(sections, pos)
        if ref_va is not None and section is not None:
            rows.append(
                {
                    "refVa": ref_va,
                    "refVaHex": hx(ref_va),
                    "section": section.get("name"),
                }
            )
            if len(rows) >= limit:
                break
        pos = exe.find(needle, pos + 1)
    return rows


def refs_count(exe: bytes, target_va: int) -> int:
    needle = struct.pack("<I", target_va)
    count = 0
    pos = exe.find(needle)
    while pos != -1:
        count += 1
        pos = exe.find(needle, pos + 1)
    return count


def safe_decode_command(exe: bytes, sections: list[dict[str, Any]], va: int) -> dict[str, Any] | None:
    try:
        return decode_command(exe, sections, va)
    except (ValueError, struct.error, IndexError):
        return None


def safe_decode_stream(
    exe: bytes,
    sections: list[dict[str, Any]],
    va: int,
    *,
    max_commands: int = 24,
    max_bytes: int = 0x160,
) -> list[dict[str, Any]]:
    try:
        decoded = decode_stream(exe, sections, va, max_commands=max_commands, max_bytes=max_bytes)
    except (ValueError, struct.error, IndexError):
        return []
    commands = decoded.get("commands", [])
    return commands if isinstance(commands, list) else []


def parent_boundary_paths(exe: bytes, sections: list[dict[str, Any]], va: int) -> list[dict[str, Any]]:
    section = section_for_va(sections, va)
    if section is None:
        return []
    start_va = int(section["va"])
    lower = max(start_va, va - 0xA0)
    lower += (-lower) % 4
    paths: list[dict[str, Any]] = []
    for start in range(lower, va, 4):
        cursor = start
        ops: list[str] = []
        starts: list[str] = []
        for _ in range(48):
            if cursor == va:
                paths.append(
                    {
                        "startVa": start,
                        "startVaHex": hx(start),
                        "startRefCount": refs_count(exe, start),
                        "commandCountBefore": len(ops),
                        "opsBefore": ops[-10:],
                        "commandStartsBefore": starts[-10:],
                    }
                )
                break
            if cursor > va or cursor + 1 > va + 0x20:
                break
            row = safe_decode_command(exe, sections, cursor)
            if not row:
                break
            length = int(row.get("length") or 0)
            if length <= 0:
                break
            ops.append(str(row.get("opcodeHex", "")))
            starts.append(str(row.get("vaHex", "")))
            cursor += length
    paths.sort(key=lambda item: (-int(item["startRefCount"]), int(item["commandCountBefore"])))
    return paths[:10]


def interior_operand_covers(exe: bytes, sections: list[dict[str, Any]], va: int) -> list[dict[str, Any]]:
    section = section_for_va(sections, va)
    if section is None:
        return []
    lower = max(int(section["va"]), va - 0x28)
    lower += (-lower) % 4
    rows: list[dict[str, Any]] = []
    for start in range(lower, va, 4):
        row = safe_decode_command(exe, sections, start)
        if not row:
            continue
        length = int(row.get("length") or 0)
        if start < va < start + length:
            rows.append(
                {
                    "coverStartVa": start,
                    "coverStartVaHex": hx(start),
                    "opcodeHex": row.get("opcodeHex"),
                    "opcodeName": row.get("opcodeName"),
                    "length": length,
                    "rawHex": row.get("rawHex"),
                    "summary": row.get("summary"),
                }
            )
    rows.sort(key=lambda item: (int(item["coverStartVa"]), int(item["length"])))
    return rows[:8]


def verify_snippets(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    snippets = [
        {
            "id": "handler-table-entry",
            "va": OPCODE_2A_HANDLER_TABLE_ENTRY_VA,
            "meaning": "generic handler table opcode 0x2a entry points at 0x004057eb",
            "expectedHex": struct.pack("<I", OPCODE_2A_HANDLER_VA).hex(" "),
        },
        {
            "id": "fanout-list-select",
            "va": 0x004057EB,
            "meaning": "stream+1 active-object list slot; high bit selects restore-child-cursor branch; stream+2 is object+0x14 mask",
            "expectedHex": "55 8b ec 83 ec 0c 53 56 57 8b 45 08 8b 40 40 33 c9 8a 48 01 83 e1 7f 89 4d f4",
        },
        {
            "id": "fanout-restore-child-cursor",
            "va": 0x00405893,
            "meaning": "high-bit branch saves target object+0x40, runs stream+4 through 0x00402321, restores target cursor",
            "expectedHex": "8b 45 f8 8b 40 40 89 45 fc 8b 45 08 8b 40 40 8b 40 04 8b 4d f8 89 41 40 8b 45 f8 50 e8 6d ca ff ff 83 c4 04 8b 45 fc 8b 4d f8 89 41 40",
        },
        {
            "id": "fanout-persistent-child-cursor",
            "va": 0x004058C5,
            "meaning": "normal branch writes stream+4 into target object+0x40 and keeps advanced target cursor",
            "expectedHex": "8b 45 08 8b 40 40 8b 40 04 8b 4d f8 89 41 40 8b 45 f8 50 e8 44 ca ff ff 83 c4 04",
        },
        {
            "id": "parent-stream-advance",
            "va": 0x004058E5,
            "meaning": "parent object+0x40 advances by +0x08 after opcode 0x2a fanout",
            "expectedHex": "8b 45 08 83 40 40 08 c7 05 b8 a1 55 00 00 00 00 00",
        },
    ]
    rows: list[dict[str, Any]] = []
    for item in snippets:
        expected = bytes.fromhex(str(item["expectedHex"]))
        actual = read_at(exe, sections, int(item["va"]), len(expected))
        rows.append(
            {
                "id": item["id"],
                "vaHex": hx(int(item["va"])),
                "meaning": item["meaning"],
                "matches": actual == expected,
                "expectedHex": item["expectedHex"],
                "actualHex": actual.hex(" "),
            }
        )
    return rows


def active_object_hits(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    inventory = load_json(OUT / "active_object_script_inventory.json", {})
    scripts = inventory.get("scripts", []) if isinstance(inventory, dict) else []
    hits: list[dict[str, Any]] = []
    for script in scripts:
        script_va = script.get("scriptVa")
        if not isinstance(script_va, int) or not is_va(sections, script_va):
            continue
        commands = safe_decode_stream(exe, sections, script_va, max_commands=120, max_bytes=0x500)
        for command in commands:
            if command.get("opcode") == 0x2A:
                hits.append(
                    {
                        "scriptVa": script_va,
                        "scriptVaHex": hx(script_va),
                        "commandVa": command.get("va"),
                        "commandVaHex": command.get("vaHex"),
                        "classification": script.get("classification"),
                        "command": command,
                    }
                )
    return hits


def scan_raw_candidates(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for section in sections:
        if section.get("name") not in {".data", ".rdata"}:
            continue
        raw_start = int(section["raw"])
        raw_end = raw_start + int(section["raw_size"])
        data = exe[raw_start:raw_end]
        index = data.find(b"\x2a")
        while index != -1:
            offset = raw_start + index
            va = offset_to_va(sections, offset)
            if va is not None and va + 8 <= int(section["va"]) + int(section["raw_size"]):
                child = u32(exe, sections, va + 4)
                if is_va(sections, child):
                    command = safe_decode_command(exe, sections, va)
                    if command and command.get("opcode") == 0x2A:
                        slot = int(command.get("slot", 0))
                        mask = int(command.get("filterMask", 0))
                        child_commands = safe_decode_stream(exe, sections, child, max_commands=12, max_bytes=0x80)
                        parent_paths = parent_boundary_paths(exe, sections, va)
                        covers = interior_operand_covers(exe, sections, va)
                        command_ref_count = refs_count(exe, va)
                        child_ref_count = refs_count(exe, child)
                        best_path = parent_paths[0] if parent_paths else None
                        likely_interior = bool(covers) and not parent_paths
                        score = 0
                        if command_ref_count:
                            score += 3
                        if parent_paths:
                            score += 2
                        if best_path and int(best_path.get("startRefCount", 0)):
                            score += 1
                        if slot <= 0x10:
                            score += 1
                        if mask == 0:
                            score += 1
                        if likely_interior:
                            score -= 3
                        if parent_paths:
                            classification = "review-boundary-candidate"
                        elif likely_interior:
                            classification = "likely-operand-false-positive"
                        else:
                            classification = "raw-va-shaped-candidate"
                        rows.append(
                            {
                                "candidateVa": va,
                                "candidateVaHex": hx(va),
                                "section": section.get("name"),
                                "classification": classification,
                                "score": score,
                                "rawHex": read_at(exe, sections, va, 16).hex(" "),
                                "slot": slot,
                                "rawSlot": command.get("rawSlot"),
                                "restoreCursor": command.get("restoreCursor"),
                                "filterMask": mask,
                                "filterMaskHex": command.get("filterMaskHex"),
                                "childScriptVa": child,
                                "childScriptVaHex": hx(child),
                                "childSection": section_name_for(sections, child),
                                "commandRefCount": command_ref_count,
                                "childRefCount": child_ref_count,
                                "parentBoundaryPathCount": len(parent_paths),
                                "bestParentBoundary": best_path,
                                "interiorCoverCount": len(covers),
                                "interiorCovers": covers,
                                "childOps": [row.get("opcodeHex") for row in child_commands[:10]],
                                "childCommandPreview": [
                                    {
                                        "vaHex": row.get("vaHex"),
                                        "opcodeHex": row.get("opcodeHex"),
                                        "opcodeName": row.get("opcodeName"),
                                        "summary": row.get("summary"),
                                    }
                                    for row in child_commands[:6]
                                ],
                                "promotedProducer": False,
                            }
                        )
            index = data.find(b"\x2a", index + 1)
    rows.sort(
        key=lambda item: (
            item["classification"] != "review-boundary-candidate",
            -int(item["score"]),
            int(item["candidateVa"]),
        )
    )
    return rows


def build_report() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    snippets = verify_snippets(exe, sections)
    active_hits = active_object_hits(exe, sections)
    raw_candidates = scan_raw_candidates(exe, sections)
    classes = Counter(row["classification"] for row in raw_candidates)
    strong_candidates = [
        row
        for row in raw_candidates
        if row["classification"] == "review-boundary-candidate" and int(row["score"]) >= 3
    ]
    summary = {
        "handlerTableEntryVaHex": hx(OPCODE_2A_HANDLER_TABLE_ENTRY_VA),
        "handlerVaHex": hx(OPCODE_2A_HANDLER_VA),
        "verifiedSnippetCount": sum(1 for row in snippets if row["matches"]),
        "verifiedSnippetTotal": len(snippets),
        "activeObjectEcOpcode2aCount": len(active_hits),
        "rawCandidateCount": len(raw_candidates),
        "classificationCounts": dict(classes),
        "boundaryCandidateCount": classes.get("review-boundary-candidate", 0),
        "interiorOperandLikelyCount": classes.get("likely-operand-false-positive", 0),
        "rawVaShapedCandidateCount": classes.get("raw-va-shaped-candidate", 0),
        "strongBoundaryCandidateCount": len(strong_candidates),
        "promotedParentProducerCount": 0,
        "mostReviewableCandidateVaHex": strong_candidates[0]["candidateVaHex"] if strong_candidates else None,
        "decision": (
            "Opcode 0x2a is a grounded linked-object script fanout primitive, but it is not present in the "
            "current strict active-object +0xec script inventory. Raw data-section bytes that look like 0x2a "
            "commands are mostly operand/data hits; review-boundary candidates remain route-neutral because no "
            "parent stream is linked to a scene/map/encounter producer."
        ),
    }
    return {
        "kind": "hwanse-generic-vm-opcode2a-parent-stream-review",
        "source": "tools/build_generic_vm_opcode2a_parent_stream_review.py",
        "status": "opcode-0x2a-grounded-fanout-no-active-ec-parent-producer",
        "summary": summary,
        "verifiedSnippets": snippets,
        "activeObjectEcHits": active_hits,
        "rawCandidates": raw_candidates,
        "topReviewCandidates": raw_candidates[:24],
        "nonClaims": [
            "A raw byte 0x2a plus a VA-shaped dword at +4 is not enough to claim an opcode 0x2a command.",
            "A command-boundary candidate is not a route producer unless its parent/root is linked to gameplay scene/map/encounter state.",
            "The active object +0xec inventory remains a negative result for opcode 0x2a after the decoder correction.",
        ],
    }


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    cards = [
        ("status", report["status"]),
        ("active +ec 0x2a", summary["activeObjectEcOpcode2aCount"]),
        ("raw candidates", summary["rawCandidateCount"]),
        ("boundary candidates", summary["boundaryCandidateCount"]),
        ("likely operands", summary["interiorOperandLikelyCount"]),
        ("strong boundary", summary["strongBoundaryCandidateCount"]),
        ("promoted producers", summary["promotedParentProducerCount"]),
    ]
    card_html = "".join(f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>" for k, v in cards)
    snippet_rows = "".join(
        "<tr>"
        f"<td>{h(row['id'])}<br><code>{h(row['vaHex'])}</code></td>"
        f"<td>{h(row['matches'])}</td>"
        f"<td>{h(row['meaning'])}</td>"
        f"<td><code>{h(row['actualHex'])}</code></td>"
        "</tr>"
        for row in report["verifiedSnippets"]
    )
    candidate_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['candidateVaHex'])}</code><br>{h(row['classification'])}</td>"
        f"<td>{h(row['score'])}</td>"
        f"<td>slot {h(row['slot'])}<br>restore {h(row['restoreCursor'])}<br>mask <code>{h(row['filterMaskHex'])}</code></td>"
        f"<td><code>{h(row['childScriptVaHex'])}</code><br>{h(row['childSection'])}<br>ops {h(', '.join(row['childOps']))}</td>"
        f"<td>cmd refs {h(row['commandRefCount'])}<br>child refs {h(row['childRefCount'])}<br>boundary {h(row['parentBoundaryPathCount'])}<br>interior {h(row['interiorCoverCount'])}</td>"
        f"<td><code>{h(row['rawHex'])}</code></td>"
        "</tr>"
        for row in report["topReviewCandidates"]
    ) or "<tr><td colspan='6'>none</td></tr>"
    active_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['scriptVaHex'])}</code></td>"
        f"<td><code>{h(row['commandVaHex'])}</code></td>"
        f"<td>{h(row['classification'])}</td>"
        f"<td>{h((row.get('command') or {}).get('summary'))}</td>"
        "</tr>"
        for row in report["activeObjectEcHits"]
    ) or "<tr><td colspan='4'>none</td></tr>"
    non_claims = "".join(f"<li>{h(item)}</li>" for item in report["nonClaims"])
    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>Generic VM Opcode 0x2A Parent Stream Review</title>
  <style>
    body {{ margin:0; background:#101318; color:#edf1f7; font-family:system-ui,sans-serif; }}
    main {{ max-width:1320px; margin:0 auto; padding:24px; }}
    a {{ color:#8ecbff; }}
    .nav {{ display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px; }}
    .chip {{ border:1px solid #334155; border-radius:999px; padding:6px 10px; text-decoration:none; background:#161b22; }}
    .summary {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:12px; margin:16px 0; }}
    .card {{ border:1px solid #2b3544; border-radius:8px; padding:12px; background:#161b22; }}
    .card b {{ display:block; color:#9fb1c9; font-size:12px; text-transform:uppercase; }}
    .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; overflow-wrap:anywhere; }}
    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="static_analysis_remaining_work.html">remaining work</a>
    <a class="chip" href="generic_vm_stream_producer_frontier_review.html">generic VM frontier</a>
  </div>
  <h1>Generic VM Opcode 0x2A Parent Stream Review</h1>
  <p>{h(summary["decision"])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Verified Handler Evidence</h2>
    <table><thead><tr><th>snippet</th><th>matches</th><th>meaning</th><th>actual bytes</th></tr></thead><tbody>{snippet_rows}</tbody></table>
  </section>
  <section>
    <h2>Strict Active Object +0xEC Hits</h2>
    <table><thead><tr><th>script</th><th>command</th><th>classification</th><th>summary</th></tr></thead><tbody>{active_rows}</tbody></table>
  </section>
  <section>
    <h2>Top Raw Candidates</h2>
    <table><thead><tr><th>candidate</th><th>score</th><th>operands</th><th>child</th><th>evidence</th><th>raw</th></tr></thead><tbody>{candidate_rows}</tbody></table>
  </section>
  <section>
    <h2>Non-Claims</h2>
    <ul>{non_claims}</ul>
  </section>
  <section>
    <h2>Raw JSON</h2>
    <pre id="payload"></pre>
  </section>
</main>
<script type="application/json" id="report-json">{h(payload)}</script>
<script>
  const data = JSON.parse(document.getElementById("report-json").textContent);
  document.getElementById("payload").textContent = JSON.stringify(data, null, 2);
  window.HWANSE_GENERIC_VM_OPCODE2A_PARENT_STREAM_REVIEW = data;
</script>
</body>
</html>
"""


def main() -> None:
    report = build_report()
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    (OUT / "generic_vm_opcode2a_parent_stream_review.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (WEB / "generic_vm_opcode2a_parent_stream_review.html").write_text(
        render_html(report),
        encoding="utf-8",
    )
    print(
        "wrote opcode 0x2a parent stream review "
        f"({report['summary']['activeObjectEcOpcode2aCount']} active +ec hits, "
        f"{report['summary']['strongBoundaryCandidateCount']} strong raw boundary candidates)"
    )


if __name__ == "__main__":
    main()
