#!/usr/bin/env python3
"""Inventory strict active-object initializers and their +0xec scripts.

This is the broader follow-up to the 0x442c75 caller trace.  It scans only
valid command-boundary ``opcode 0x08`` object initializers that:

* write a valid VA into ``object +0xec``
* write the active/scripted gate ``object +0x14 = 0x0101``

The scan is intentionally strict to avoid the old false-positive raw-byte
candidate problem.  It classifies decoded ``+0xec`` scripts by evidence:
text/prompt payloads, map CNS operands, and direct map-loader references.
"""
from __future__ import annotations

import argparse
import html
import json
import struct
from pathlib import Path
from typing import Any

from probe_exe_scene_tables import find_cns_strings, read_sections, va_to_offset
from summarize_object_payload_442c75_callers import (
    decode_initializer,
    decode_stream,
    hex32,
    is_va,
)


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
EXE = ROOT / "Hwanse2.exe"
MAP_LOADER_FUNCTION = 0x0042449C


def read_at(exe: bytes, sections: list[dict], va: int, size: int) -> bytes:
    offset = va_to_offset(sections, va)
    if offset is None:
        raise ValueError(f"VA {hex32(va)} is outside raw sections")
    return exe[offset: offset + size]


def cns_refs_in_span(
    exe: bytes,
    sections: list[dict],
    cns_strings: dict[int, str],
    start_va: int,
    size: int = 0x180,
) -> list[dict[str, Any]]:
    try:
        data = read_at(exe, sections, start_va, size)
    except ValueError:
        return []
    refs = []
    for offset in range(0, max(len(data) - 3, 0)):
        value = struct.unpack_from("<I", data, offset)[0]
        if value in cns_strings:
            refs.append({
                "atVa": start_va + offset,
                "atVaHex": hex32(start_va + offset),
                "targetVa": value,
                "targetVaHex": hex32(value),
                "name": cns_strings[value],
            })
    return refs


def field_value(init: dict[str, Any], field_offset: int) -> int | None:
    for write in init["writes"]:
        if write["fieldOffset"] == field_offset:
            return int(write["value"])
    return None


def field_value_hex(init: dict[str, Any], field_offset: int) -> str:
    value = field_value(init, field_offset)
    return hex32(value) if value is not None and value > 0xFFFF else (f"0x{value:04x}" if value is not None else "")


def scan_initializers(exe: bytes, sections: list[dict]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for section in sections:
        if section["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 = 0
        while True:
            hit = data.find(b"\x08\x00\x00\x00", index)
            if hit < 0:
                break
            va = int(section["va"]) + hit
            init = decode_initializer(exe, sections, va)
            if init:
                ec_writes = [
                    write
                    for write in init["ecWrites"]
                    if is_va(sections, int(write["value"]))
                ]
                active_gate = bool(init["activeGateWrites"])
                if ec_writes and active_gate:
                    row = dict(init)
                    row["section"] = section["name"]
                    rows.append(row)
            index = hit + 1
    return rows


def script_row(
    exe: bytes,
    sections: list[dict],
    cns_strings: dict[int, str],
    script_va: int,
) -> dict[str, Any]:
    stream = decode_stream(exe, sections, script_va, max_commands=80, max_bytes=0x240)
    span_cns_refs = cns_refs_in_span(exe, sections, cns_strings, script_va, 0x240)
    operand_cns_refs = []
    for command in stream["commands"]:
        for operand in command.get("pointerOperands", []):
            target = operand.get("value")
            if target in cns_strings:
                operand_cns_refs.append({
                    "atVa": command["va"],
                    "atVaHex": command["vaHex"],
                    "targetVa": target,
                    "targetVaHex": operand["valueHex"],
                    "name": cns_strings[target],
                })
    text_refs = stream["textPayloadRefs"]
    map_operand_refs = [ref for ref in operand_cns_refs if ref["name"].startswith("map")]
    return {
        "scriptVa": script_va,
        "scriptVaHex": hex32(script_va),
        "decodedCommandCount": stream["decodedCommandCount"],
        "textPayloadRefCount": len(text_refs),
        "textPayloadPreviews": [
            {
                "valueHex": ref["valueHex"],
                "preview": ref["textPreview"],
            }
            for ref in text_refs[:6]
        ],
        "mapCnsOperandRefCount": len(map_operand_refs),
        "mapCnsNearbyRefCount": len([ref for ref in span_cns_refs if ref["name"].startswith("map")]),
        "operandCnsRefs": operand_cns_refs[:12],
        "nearbyCnsRefs": span_cns_refs[:12],
        "mapLoaderRefFound": stream["mapLoaderRefFound"],
        "routeProofFound": stream["routeProofFound"],
        "classification": (
            "route-proof"
            if stream["routeProofFound"]
            else "text/prompt-interaction"
            if text_refs
            else "map-cns-operand-candidate"
            if map_operand_refs
            else "script-no-text-no-map-proof"
        ),
        "firstCommands": [
            {
                "vaHex": cmd.get("vaHex"),
                "opcodeHex": cmd.get("opcodeHex"),
                "opcodeName": cmd.get("opcodeName"),
                "summary": cmd.get("summary", ""),
            }
            for cmd in stream["commands"][:10]
        ],
    }


def build_summary(exe_path: Path) -> dict[str, Any]:
    exe = exe_path.read_bytes()
    sections = read_sections(exe)
    cns_strings = find_cns_strings(exe, sections)
    initializers = scan_initializers(exe, sections)

    unique_script_vas = sorted({
        int(write["value"])
        for init in initializers
        for write in init["ecWrites"]
        if is_va(sections, int(write["value"]))
    })
    scripts = {va: script_row(exe, sections, cns_strings, va) for va in unique_script_vas}

    inventory = []
    for init in initializers:
        ec_target = int(init["ecWrites"][0]["value"])
        script = scripts[ec_target]
        inventory.append({
            "initializerVa": init["va"],
            "initializerVaHex": init["vaHex"],
            "scriptVa": ec_target,
            "scriptVaHex": hex32(ec_target),
            "xCandidate": field_value(init, 0xE8),
            "yCandidate": field_value(init, 0xEA),
            "wCandidate": field_value(init, 0xE6),
            "hCandidate": field_value(init, 0xE7),
            "field0x28Hex": field_value_hex(init, 0x28),
            "field0x2cHex": field_value_hex(init, 0x2C),
            "field0x16Hex": field_value_hex(init, 0x16),
            "scriptClassification": script["classification"],
            "textPayloadRefCount": script["textPayloadRefCount"],
            "mapCnsOperandRefCount": script["mapCnsOperandRefCount"],
            "mapCnsNearbyRefCount": script["mapCnsNearbyRefCount"],
            "routeProofFound": script["routeProofFound"],
        })

    class_counts: dict[str, int] = {}
    for row in scripts.values():
        class_counts[row["classification"]] = class_counts.get(row["classification"], 0) + 1
    route_scripts = [row for row in scripts.values() if row["routeProofFound"]]
    map_context_scripts = [row for row in scripts.values() if row["classification"] == "map-cns-operand-candidate"]
    text_scripts = [row for row in scripts.values() if row["classification"] == "text/prompt-interaction"]
    unknown_scripts = [row for row in scripts.values() if row["classification"] == "script-no-text-no-map-proof"]

    return {
        "title": "Active object script inventory",
        "summary": {
            "strictInitializerCount": len(initializers),
            "uniqueObjectEcScriptCount": len(unique_script_vas),
            "routeProofScriptCount": len(route_scripts),
            "mapCnsOperandCandidateScriptCount": len(map_context_scripts),
            "textPromptScriptCount": len(text_scripts),
            "unknownScriptCount": len(unknown_scripts),
            "classificationCounts": class_counts,
            "mapLoaderFunctionHex": hex32(MAP_LOADER_FUNCTION),
            "sceneAutoTransitionClaim": False,
            "manualMovementAssumption": True,
        },
        "inventory": inventory,
        "scripts": [scripts[va] for va in unique_script_vas],
        "routeProofScripts": route_scripts,
        "mapCnsOperandCandidateScripts": map_context_scripts[:40],
        "textPromptScripts": text_scripts[:40],
        "unknownScripts": unknown_scripts[:80],
        "nonClaims": [
            "A strict active object initializer is not automatically a map transition.",
            "Text/prompt scripts prove manual interaction scripting, not route behavior.",
            "Map CNS proximity is only a context candidate unless the decoded script writes a selected map/root or reaches the map loader.",
        ],
    }


def render_html(summary: dict[str, Any]) -> str:
    s = summary["summary"]

    def row_script(row: dict[str, Any]) -> str:
        commands = "<br>".join(
            f"<code>{html.escape(str(cmd['opcodeHex']))}</code> {html.escape(str(cmd['opcodeName']))} {html.escape(str(cmd['summary']))}"
            for cmd in row["firstCommands"][:4]
        )
        return (
            "<tr>"
            f"<td><code>{row['scriptVaHex']}</code></td>"
            f"<td>{html.escape(row['classification'])}</td>"
            f"<td>{row['textPayloadRefCount']}</td>"
            f"<td>{row['mapCnsOperandRefCount']}</td>"
            f"<td>{commands}</td>"
            "</tr>"
        )

    route_rows = [row_script(row) for row in summary["routeProofScripts"]]
    map_rows = [row_script(row) for row in summary["mapCnsOperandCandidateScripts"][:40]]
    text_rows = [row_script(row) for row in summary["textPromptScripts"][:40]]
    unknown_rows = [row_script(row) for row in summary["unknownScripts"][:80]]
    class_items = "".join(
        f"<li>{html.escape(key)}: {value}</li>"
        for key, value in sorted(s["classificationCounts"].items())
    )
    return "\n".join([
        "<!doctype html>",
        '<html lang="ko">',
        "<head>",
        '  <meta charset="utf-8" />',
        "  <title>Active Object Script Inventory</title>",
        "  <style>",
        "    body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;margin:24px;line-height:1.5;color:#1f2937;background:#f8fafc}",
        "    code{background:#e5e7eb;border-radius:4px;padding:1px 4px}",
        "    table{border-collapse:collapse;width:100%;background:white;margin:12px 0 24px}",
        "    th,td{border:1px solid #d1d5db;padding:8px;text-align:left;vertical-align:top}",
        "    th{background:#f3f4f6}",
        "    .marker{font-size:12px;color:#475569}",
        "  </style>",
        "</head>",
        "<body>",
        "  <h1>Active Object Script Inventory</h1>",
        f"  <p class=\"marker\">strict initializer count: {s['strictInitializerCount']}</p>",
        f"  <p class=\"marker\">unique object +0xec script count: {s['uniqueObjectEcScriptCount']}</p>",
        f"  <p class=\"marker\">route proof script count: {s['routeProofScriptCount']}</p>",
        f"  <p class=\"marker\">map CNS operand candidate script count: {s['mapCnsOperandCandidateScriptCount']}</p>",
        f"  <p class=\"marker\">text/prompt script count: {s['textPromptScriptCount']}</p>",
        f"  <p class=\"marker\">unknown script count: {s['unknownScriptCount']}</p>",
        f"  <p class=\"marker\">scene auto transition claim: {s['sceneAutoTransitionClaim']}</p>",
        f"  <p class=\"marker\">manual movement assumption: {s['manualMovementAssumption']}</p>",
        "  <p>This page inventories strict active-object initializers. It does not promote any script to a route without map-loader/root evidence.</p>",
        "  <script>",
        "    window.HWANSE_ACTIVE_OBJECT_SCRIPT_INVENTORY = {",
        f"      strictInitializerCount: {s['strictInitializerCount']},",
        f"      uniqueObjectEcScriptCount: {s['uniqueObjectEcScriptCount']},",
        f"      routeProofScriptCount: {s['routeProofScriptCount']},",
        f"      mapCnsOperandCandidateScriptCount: {s['mapCnsOperandCandidateScriptCount']},",
        f"      textPromptScriptCount: {s['textPromptScriptCount']},",
        f"      unknownScriptCount: {s['unknownScriptCount']},",
        f"      sceneAutoTransitionClaim: {str(s['sceneAutoTransitionClaim']).lower()}",
        "    };",
        "  </script>",
        "  <h2>Classification Counts</h2>",
        f"  <ul>{class_items}</ul>",
        "  <h2>Route Proof Scripts</h2>",
        "  <table><thead><tr><th>script</th><th>class</th><th>text refs</th><th>map CNS refs</th><th>first commands</th></tr></thead><tbody>",
        *(route_rows or ["<tr><td colspan=\"5\">none</td></tr>"]),
        "  </tbody></table>",
        "  <h2>Map CNS Operand Candidates</h2>",
        "  <table><thead><tr><th>script</th><th>class</th><th>text refs</th><th>map CNS refs</th><th>first commands</th></tr></thead><tbody>",
        *map_rows,
        "  </tbody></table>",
        "  <h2>Text/Prompt Scripts</h2>",
        "  <table><thead><tr><th>script</th><th>class</th><th>text refs</th><th>map CNS refs</th><th>first commands</th></tr></thead><tbody>",
        *text_rows,
        "  </tbody></table>",
        "  <h2>Unknown Scripts</h2>",
        "  <table><thead><tr><th>script</th><th>class</th><th>text refs</th><th>map CNS refs</th><th>first commands</th></tr></thead><tbody>",
        *unknown_rows,
        "  </tbody></table>",
        "  <h2>하지 않는 주장</h2>",
        "  <ul>",
        *[f"    <li>{html.escape(item)}</li>" for item in summary["nonClaims"]],
        "  </ul>",
        "</body>",
        "</html>",
    ])


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument(
        "--html-out",
        type=Path,
        default=None,
        help="Optional HTML review path. By default only the JSON evidence is written.",
    )
    args = parser.parse_args()

    OUT.mkdir(exist_ok=True)
    summary = build_summary(args.exe)
    (OUT / "active_object_script_inventory.json").write_text(
        json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    if args.html_out is not None:
        args.html_out.parent.mkdir(parents=True, exist_ok=True)
        args.html_out.write_text(render_html(summary), encoding="utf-8")
    print(json.dumps(summary["summary"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
