#!/usr/bin/env python3
"""Summarize generic object-script opcode 0x5e as the object +0xec producer."""
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


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

GENERIC_SCRIPT_HANDLER_TABLE = 0x00440538
GENERIC_OBJECT_SCRIPT_RUNNER = 0x00402321
OPCODE_5D_HANDLER = 0x0040791F
OPCODE_5E_HANDLER = 0x004079FF
TRANSIENT_OBJECT_GLOBAL_5D = 0x0059DDA4
TRANSIENT_OBJECT_GLOBAL_5E = 0x0059DDA8
OBJECT_ALLOCATOR_FUNCTION = 0x00435B5B
OBJECT_DESTROY_FUNCTION = 0x00435C9D
PAYLOAD_RESOURCE_FUNCTION = 0x00423A2F
MAP_LOADER_FUNCTION = 0x0042449C
SCRIPT_CURSOR_FIELD = "+0x40"
OBJECT_PAYLOAD_FIELD = "+0xec"

PRODUCER_SNIPPETS = [
    {
        "id": "opcode-5e-handler-mode-read",
        "va": 0x00407A08,
        "meaning": "opcode 0x5e reads stream byte +1 as mode",
        "expectedHex": "8b 45 08 8b 40 40 33 c9 8a 48 01 89 4d ec",
    },
    {
        "id": "opcode-5e-mode1-create-object",
        "va": 0x00407A8C,
        "meaning": "mode 1 allocates a transient generic-script object and stores it in 0x0059dda8",
        "expectedHex": "6a 00 68 21 23 40 00 e8 c3 e0 02 00 83 c4 08 a3 a8 dd 59 00",
    },
    {
        "id": "opcode-5e-mode1-payload-copy",
        "va": 0x00407AA0,
        "meaning": "mode 1 copies dword [stream+4] into new object +0xec",
        "expectedHex": "8b 45 08 8b 40 40 8b 40 04 8b 0d a8 dd 59 00 89 81 ec 00 00 00",
    },
    {
        "id": "opcode-5e-mode1-cursor-from-payload",
        "va": 0x00407ABC,
        "meaning": "mode 1 switches execution to the new object and loads object +0x40 from [payload+8]",
        "expectedHex": "a1 a8 dd 59 00 89 45 08 8b 45 08 8b 80 ec 00 00 00 8b 40 08 8b 4d 08 89 41 40",
    },
    {
        "id": "opcode-5e-mode1-resource-open",
        "va": 0x00407AD6,
        "meaning": "mode 1 passes [payload+0] to 0x00423a2f after cursor setup",
        "expectedHex": "8b 45 08 8b 80 ec 00 00 00 8b 00 50 e8 48 bf 01 00",
    },
    {
        "id": "opcode-5e-mode0-resource-close",
        "va": 0x00407A28,
        "meaning": "mode 0 cleanup passes [payload+4] to 0x00423a2f, destroys the transient object, clears 0x0059dda8",
        "expectedHex": "a1 a8 dd 59 00 8b 80 ec 00 00 00 8b 40 04 50 e8 f3 bf 01 00",
    },
]


def hex32(value: int | None) -> str | None:
    if value is None:
        return None
    return f"0x{value:08x}"


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 snippet_row(exe: bytes, sections: list[dict], row: dict[str, Any]) -> dict[str, Any]:
    expected = bytes.fromhex(row["expectedHex"])
    actual = read_at(exe, sections, row["va"], len(expected))
    return {
        "id": row["id"],
        "va": row["va"],
        "vaHex": hex32(row["va"]),
        "meaning": row["meaning"],
        "expectedHex": row["expectedHex"],
        "actualHex": actual.hex(" "),
        "matches": actual == expected,
    }


def read_u32(exe: bytes, sections: list[dict], va: int) -> int:
    return struct.unpack("<I", read_at(exe, sections, va, 4))[0]


def is_va(sections: list[dict], value: int) -> bool:
    return va_to_offset(sections, value) is not None


def collect_mode1_candidates(exe: bytes, sections: list[dict], cns_strings: dict[int, str]) -> list[dict[str, Any]]:
    map_cns = {va: name for va, name in cns_strings.items() if name.startswith("map")}
    rows: list[dict[str, Any]] = []
    for section in sections:
        if section["name"] not in {".text", ".rdata", ".data"}:
            continue
        start = int(section["raw"])
        end = start + int(section["raw_size"])
        data = exe[start:end]
        for index in range(0, max(len(data) - 8, 0)):
            if data[index] != 0x5E or data[index + 1] != 0x01:
                continue
            payload = struct.unpack_from("<I", data, index + 4)[0]
            payload_offset = va_to_offset(sections, payload)
            if payload_offset is None:
                continue
            dwords = [struct.unpack_from("<I", exe, payload_offset + offset * 4)[0] for offset in range(4)]
            valid_payload_struct = all(is_va(sections, value) for value in dwords[:3])
            map_refs = []
            scan = exe[payload_offset: payload_offset + 128]
            for scan_offset in range(0, max(len(scan) - 3, 0)):
                value = struct.unpack_from("<I", scan, scan_offset)[0]
                if value in map_cns:
                    map_refs.append({
                        "offset": scan_offset,
                        "offsetHex": f"0x{scan_offset:02x}",
                        "vaHex": hex32(value),
                        "name": map_cns[value],
                    })
            rows.append({
                "commandVa": int(section["va"]) + index,
                "commandVaHex": hex32(int(section["va"]) + index),
                "section": section["name"],
                "payloadVa": payload,
                "payloadVaHex": hex32(payload),
                "payloadDwords": [hex32(value) for value in dwords],
                "validPayloadStruct": valid_payload_struct,
                "mapCnsRefsInFirst128Bytes": map_refs,
                "payloadHeadHex": exe[payload_offset: payload_offset + 32].hex(" "),
            })
    return rows


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)
    snippet_rows = [snippet_row(exe, sections, row) for row in PRODUCER_SNIPPETS]
    handler_5d = read_u32(exe, sections, GENERIC_SCRIPT_HANDLER_TABLE + 0x5D * 4)
    handler_5e = read_u32(exe, sections, GENERIC_SCRIPT_HANDLER_TABLE + 0x5E * 4)
    mode1_candidates = collect_mode1_candidates(exe, sections, cns_strings)
    valid_candidates = [row for row in mode1_candidates if row["validPayloadStruct"]]
    unique_payloads = sorted({row["payloadVa"] for row in mode1_candidates})
    unique_valid_payloads = sorted({row["payloadVa"] for row in valid_candidates})
    map_ref_count = sum(len(row["mapCnsRefsInFirst128Bytes"]) for row in mode1_candidates)
    producer_grounded = (
        all(row["matches"] for row in snippet_rows)
        and handler_5d == OPCODE_5D_HANDLER
        and handler_5e == OPCODE_5E_HANDLER
    )
    return {
        "title": "Object script payload producer",
        "summary": {
            "objectEcProducerFound": producer_grounded,
            "producerOpcodeHex": "0x5e",
            "cleanupOpcodeHex": "0x5d",
            "routePayloadProofFound": False,
            "fieldMapTransitionPayloadFound": False,
            "mode1CandidateCount": len(mode1_candidates),
            "validMode1PayloadStructCandidateCount": len(valid_candidates),
            "uniqueMode1PayloadCount": len(unique_payloads),
            "uniqueValidMode1PayloadCount": len(unique_valid_payloads),
            "mapCnsRefCountInPayloadHeads": map_ref_count,
            "classification": "object +0xec producer grounded; scanned static mode-1 payloads are display/object payloads, not proven field-map transition payloads",
        },
        "constants": {
            "genericHandlerTable": hex32(GENERIC_SCRIPT_HANDLER_TABLE),
            "genericObjectScriptRunner": hex32(GENERIC_OBJECT_SCRIPT_RUNNER),
            "opcode5dHandler": hex32(handler_5d),
            "opcode5eHandler": hex32(handler_5e),
            "transientObjectGlobal5d": hex32(TRANSIENT_OBJECT_GLOBAL_5D),
            "transientObjectGlobal5e": hex32(TRANSIENT_OBJECT_GLOBAL_5E),
            "objectAllocator": hex32(OBJECT_ALLOCATOR_FUNCTION),
            "objectDestroy": hex32(OBJECT_DESTROY_FUNCTION),
            "payloadResourceFunction": hex32(PAYLOAD_RESOURCE_FUNCTION),
            "mapLoaderFunction": hex32(MAP_LOADER_FUNCTION),
            "objectPayloadField": OBJECT_PAYLOAD_FIELD,
            "scriptCursorField": SCRIPT_CURSOR_FIELD,
        },
        "instructionEvidence": snippet_rows,
        "handlerTableEvidence": {
            "opcode5dEntryVaHex": hex32(GENERIC_SCRIPT_HANDLER_TABLE + 0x5D * 4),
            "opcode5dHandlerVaHex": hex32(handler_5d),
            "opcode5dMatchesExpected": handler_5d == OPCODE_5D_HANDLER,
            "opcode5eEntryVaHex": hex32(GENERIC_SCRIPT_HANDLER_TABLE + 0x5E * 4),
            "opcode5eHandlerVaHex": hex32(handler_5e),
            "opcode5eMatchesExpected": handler_5e == OPCODE_5E_HANDLER,
        },
        "mode1PayloadCandidates": mode1_candidates,
        "uniquePayloadsHex": [hex32(value) for value in unique_payloads],
        "uniqueValidPayloadsHex": [hex32(value) for value in unique_valid_payloads],
        "producerSemantics": [
            "stream[+1] selects mode",
            "mode 1 allocates a generic object with callback 0x00402321",
            "mode 1 stores dword stream[+4] into new object +0xec",
            "mode 1 sets the new object +0x40 script cursor from dword [payload + 8]",
            "mode 1 passes dword [payload + 0] to 0x00423a2f",
            "mode 0 cleanup passes dword [payload + 4] to 0x00423a2f, destroys the transient object, and clears 0x0059dda8",
        ],
        "missingEvidence": [
            "a 0x5e mode-1 payload whose head references field map CNS resources or map-loader targets",
            "a decoded payload script that calls map loader 0x0042449c or writes a selected map/root",
            "runtime active object payload values for a concrete field exit object",
        ],
        "nonClaims": [
            "Opcode 0x5e grounds object +0xec production, but does not by itself prove a map transition.",
            "The static mode-1 scan includes a text-like false-positive candidate; only valid pointer-triad payloads are treated as payload structs.",
            "No scanned payload head is promoted to a route until its script cursor stream is decoded to a map/root change.",
        ],
    }


def markdown(summary: dict[str, Any]) -> str:
    lines = [
        "# Object Script Payload Producer",
        "",
        f"- object +0xec producer found: {summary['summary']['objectEcProducerFound']}",
        f"- producer opcode: `{summary['summary']['producerOpcodeHex']}`",
        f"- cleanup opcode: `{summary['summary']['cleanupOpcodeHex']}`",
        f"- route payload proof found: {summary['summary']['routePayloadProofFound']}",
        f"- field-map transition payload found: {summary['summary']['fieldMapTransitionPayloadFound']}",
        f"- mode1 candidates: {summary['summary']['mode1CandidateCount']}",
        f"- valid payload-struct candidates: {summary['summary']['validMode1PayloadStructCandidateCount']}",
        f"- map CNS refs in payload heads: {summary['summary']['mapCnsRefCountInPayloadHeads']}",
        "",
        "## 결론",
        "",
        "`object +0xec`를 채우는 producer는 generic object script opcode `0x5e` handler `0x004079ff`로 확인된다.",
        "다만 정적 스캔된 `0x5e mode=1` payload들은 현재 field-map transition payload로 승격되지 않는다.",
        "",
        "## Semantics",
        "",
    ]
    for item in summary["producerSemantics"]:
        lines.append(f"- {item}")
    lines += [
        "",
        "## Handler Table",
        "",
    ]
    for key, value in summary["handlerTableEvidence"].items():
        lines.append(f"- {key}: `{value}`")
    lines += [
        "",
        "## Instruction Evidence",
        "",
    ]
    for row in summary["instructionEvidence"]:
        lines.append(f"- `{row['vaHex']}` `{row['id']}`: {row['meaning']} / matches={row['matches']}")
    lines += [
        "",
        "## Mode 1 Payload Candidates",
        "",
        "| command | payload | valid struct | map refs | dwords |",
        "|---|---|---:|---:|---|",
    ]
    for row in summary["mode1PayloadCandidates"]:
        lines.append(
            f"| `{row['commandVaHex']}` | `{row['payloadVaHex']}` | {row['validPayloadStruct']} | "
            f"{len(row['mapCnsRefsInFirst128Bytes'])} | {', '.join(f'`{value}`' for value in row['payloadDwords'])} |"
        )
    lines += [
        "",
        "## 아직 없는 증거",
        "",
    ]
    for item in summary["missingEvidence"]:
        lines.append(f"- {item}")
    lines += [
        "",
        "## 하지 않는 주장",
        "",
    ]
    for item in summary["nonClaims"]:
        lines.append(f"- {item}")
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict[str, Any]) -> str:
    instruction_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['vaHex'])}</code></td>"
        f"<td><code>{html.escape(row['id'])}</code></td>"
        f"<td>{html.escape(row['meaning'])}</td>"
        f"<td>{row['matches']}</td>"
        "</tr>"
        for row in summary["instructionEvidence"]
    )
    payload_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['commandVaHex'])}</code></td>"
        f"<td><code>{html.escape(row['payloadVaHex'])}</code></td>"
        f"<td>{row['validPayloadStruct']}</td>"
        f"<td>{len(row['mapCnsRefsInFirst128Bytes'])}</td>"
        f"<td><code>{html.escape(', '.join(row['payloadDwords']))}</code></td>"
        "</tr>"
        for row in summary["mode1PayloadCandidates"]
    )
    semantics = "\n".join(f"<li>{html.escape(item)}</li>" for item in summary["producerSemantics"])
    missing = "\n".join(f"<li>{html.escape(item)}</li>" for item in summary["missingEvidence"])
    non_claims = "\n".join(f"<li>{html.escape(item)}</li>" for item in summary["nonClaims"])
    return "\n".join([
        "<!doctype html>",
        '<html lang="ko">',
        "<head>",
        '  <meta charset="utf-8" />',
        "  <title>Object Script Payload Producer</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>Object Script Payload Producer</h1>",
        f"  <p class=\"marker\">object +0xec producer found: {summary['summary']['objectEcProducerFound']}</p>",
        f"  <p class=\"marker\">producer opcode: {summary['summary']['producerOpcodeHex']}</p>",
        f"  <p class=\"marker\">route payload proof found: {summary['summary']['routePayloadProofFound']}</p>",
        f"  <p class=\"marker\">field-map transition payload found: {summary['summary']['fieldMapTransitionPayloadFound']}</p>",
        "  <p><code>opcode 0x5e</code> handler <code>0x004079ff</code> is the grounded producer for <code>object +0xec</code>. Static payload candidates are not promoted to map routes here.</p>",
        "  <script>",
        "    window.HWANSE_OBJECT_SCRIPT_PAYLOAD_PRODUCER = {",
        f"      objectEcProducerFound: {str(summary['summary']['objectEcProducerFound']).lower()},",
        f"      routePayloadProofFound: {str(summary['summary']['routePayloadProofFound']).lower()},",
        f"      fieldMapTransitionPayloadFound: {str(summary['summary']['fieldMapTransitionPayloadFound']).lower()},",
        "      producerOpcode: '0x5e',",
        "      producerHandler: '0x004079ff'",
        "    };",
        "  </script>",
        "  <h2>Semantics</h2>",
        f"  <ul>{semantics}</ul>",
        "  <h2>Instruction Evidence</h2>",
        "  <table><thead><tr><th>VA</th><th>ID</th><th>meaning</th><th>match</th></tr></thead><tbody>",
        instruction_rows,
        "  </tbody></table>",
        "  <h2>Mode 1 Payload Candidates</h2>",
        "  <table><thead><tr><th>command</th><th>payload</th><th>valid struct</th><th>map refs</th><th>payload dwords</th></tr></thead><tbody>",
        payload_rows,
        "  </tbody></table>",
        "  <h2>아직 없는 증거</h2>",
        f"  <ul>{missing}</ul>",
        "  <h2>하지 않는 주장</h2>",
        f"  <ul>{non_claims}</ul>",
        "</body>",
        "</html>",
        "",
    ])


def write_outputs(summary: dict[str, Any], out_dir: Path = OUT, md_out: Path | None = None) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "object_script_payload_producer.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    if md_out is not None:
        md_out.write_text(markdown(summary), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--md-out", type=Path, help="Optional legacy markdown output path.")
    args = parser.parse_args()
    summary = build_summary(args.exe)
    write_outputs(summary, args.out_dir, args.md_out)
    print(f"wrote object script payload producer -> {args.out_dir / 'object_script_payload_producer.json'}")


if __name__ == "__main__":
    main()
