#!/usr/bin/env python3
"""Summarize the manual movement overlap -> object script bridge."""
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 read_sections, va_to_offset
from summarize_active_object_script_inventory import scan_initializers
from summarize_object_payload_442c75_callers import decode_stream, is_va


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

ACTOR_COLLISION_HELPER_FUNCTION = 0x004319F8
ACTOR_OVERLAP_LOOP = 0x00431CA6
COLLISION_OBJECT_LIST_HEAD = 0x00574100
COLLISION_OBJECT_LIST_SENTINEL_BASE = 0x00573B40
COLLISION_OBJECT_LIST_SENTINEL_DELTA = 0x06AC
COLLISION_OBJECT_LIST_SENTINEL = COLLISION_OBJECT_LIST_SENTINEL_BASE + COLLISION_OBJECT_LIST_SENTINEL_DELTA
ACTOR_DIRECTION_LATCH = 0x00574533
SCRIPT_RUNNER_FUNCTION = 0x00402321
SCRIPT_HANDLER_TABLE = 0x00440538
SCRIPT_STOP_FLAG = 0x0055A1B8

JUMP_TABLE_VA = 0x00431F9B
JUMP_TABLE_EXPECTED = [
    0x00431D03,
    0x00431DBA,
    0x00431E71,
    0x00431EF8,
    0x00431D03,
    0x00431DBA,
    0x00431E71,
    0x00431EF8,
]

SNIPPETS = [
    {
        "id": "object-list-head-load",
        "va": 0x00431CA6,
        "meaning": "start scanning active collision/display objects from 0x00574100",
        "expectedHex": "a1 00 41 57 00",
    },
    {
        "id": "object-list-sentinel",
        "va": 0x00431CBC,
        "meaning": "sentinel is 0x00573b40 + 0x06ac = 0x005741ec",
        "expectedHex": "b8 40 3b 57 00 05 ac 06 00 00 3b 45 fc",
    },
    {
        "id": "active-object-mask",
        "va": 0x00431CCF,
        "meaning": "skip objects whose +0x14 active mask lacks 0x0101",
        "expectedHex": "8b 45 fc 33 c9 66 8b 48 14 f7 c1 01 01 00 00",
    },
    {
        "id": "object-script-payload-nonzero-gate",
        "va": 0x00431CE4,
        "meaning": "only objects with nonzero +0xec participate in this bridge",
        "expectedHex": "8b 45 fc 83 b8 ec 00 00 00 00 0f 84 ea 02 00 00",
    },
    {
        "id": "direction-latch-read",
        "va": 0x00431CF4,
        "meaning": "read movement direction latch 0x00574533 before overlap case dispatch",
        "expectedHex": "33 c0 a0 33 45 57 00 89 45 ec",
    },
    {
        "id": "direction-jump-table-dispatch",
        "va": 0x00431F84,
        "meaning": "dispatch latch values 1..8 through 0x00431f9b table",
        "expectedHex": "ff 4d ec 83 7d ec 07 0f 87 2a 00 00 00 8b 45 ec ff 24 85 9b 1f 43 00",
    },
    {
        "id": "object-script-payload-to-cursor-copy",
        "va": 0x00431FBB,
        "meaning": "when latch has been consumed, copy object +0xec into object +0x40 script cursor",
        "expectedHex": "33 c0 a0 33 45 57 00 85 c0 0f 85 14 00 00 00 8b 45 fc 8b 80 ec 00 00 00 8b 4d fc 89 41 40",
    },
    {
        "id": "generic-object-script-runner",
        "va": 0x00402327,
        "meaning": "generic runner resets 0x0055a1b8, reads object +0x40 opcode, dispatches through 0x00440538",
        "expectedHex": "c7 05 b8 a1 55 00 00 00 00 00 83 3d b8 a1 55 00 00 0f 85 1d 00 00 00 8b 45 08 50 8b 45 08 8b 40 40 33 c9 8a 08 ff 14 8d 38 05 44 00",
    },
]

STATE_PAIR_SNIPPETS = [
    {
        "id": "vertical-overlap-response-a",
        "va": 0x00431D9D,
        "actorState": 1,
        "objectState": 2,
        "expectedHex": "c7 40 68 01 00 00 00 8b 45 fc c7 40 68 02 00 00 00 c6 05 33 45 57 00 00",
    },
    {
        "id": "vertical-overlap-response-b",
        "va": 0x00431E54,
        "actorState": 2,
        "objectState": 1,
        "expectedHex": "c7 40 68 02 00 00 00 8b 45 fc c7 40 68 01 00 00 00 c6 05 33 45 57 00 00",
    },
    {
        "id": "horizontal-overlap-response-a",
        "va": 0x00431EDB,
        "actorState": 3,
        "objectState": 4,
        "expectedHex": "c7 40 68 03 00 00 00 8b 45 fc c7 40 68 04 00 00 00 c6 05 33 45 57 00 00",
    },
    {
        "id": "horizontal-overlap-response-b",
        "va": 0x00431F62,
        "actorState": 4,
        "objectState": 3,
        "expectedHex": "c7 40 68 04 00 00 00 8b 45 fc c7 40 68 03 00 00 00 c6 05 33 45 57 00 00",
    },
]

MANUAL_SCRIPT_OPCODES = {
    0x55: {
        "name": "global-position-step",
        "meaning": "steps global field position words 0x4576dc/0x4576de toward a target coordinate",
        "routeImpact": "manual movement helper; no destination map/root write",
    },
    0x65: {
        "name": "active-object-position-branch",
        "meaning": "branches to command+8 target when an active object's object+0xe8/+0xea position matches",
        "routeImpact": "manual trigger/object position gate; no destination map/root write",
    },
    0x66: {
        "name": "active-object-sequence-gate",
        "meaning": "matches active object kind, compares/increments object+0x70 sequence counter, and queues a value",
        "routeImpact": "object state progression; no destination map/root write",
    },
    0x67: {
        "name": "active-object-position-gate",
        "meaning": "waits on active object object+0xe8/+0xea position and can stall the script runner",
        "routeImpact": "object position wait/gate; no destination map/root write",
    },
    0x70: {
        "name": "active-object-motion-state",
        "meaning": "sets linked active object position/motion/state fields",
        "routeImpact": "object animation/motion setup; no destination map/root write",
    },
}


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 bytes_hex(exe: bytes, sections: list[dict], va: int, size: int) -> str:
    return read_at(exe, sections, va, size).hex(" ")


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 state_pair_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"]),
        "actorState": row["actorState"],
        "objectState": row["objectState"],
        "expectedHex": row["expectedHex"],
        "actualHex": actual.hex(" "),
        "matches": actual == expected,
    }


def read_jump_table(exe: bytes, sections: list[dict]) -> list[int]:
    data = read_at(exe, sections, JUMP_TABLE_VA, 4 * len(JUMP_TABLE_EXPECTED))
    return [struct.unpack_from("<I", data, index * 4)[0] for index in range(len(JUMP_TABLE_EXPECTED))]


def manual_opcode_review(exe: bytes, sections: list[dict]) -> dict[str, Any]:
    initializers = scan_initializers(exe, sections)
    script_vas = sorted({
        int(write["value"])
        for init in initializers
        for write in init["ecWrites"]
        if is_va(sections, int(write["value"]))
    })
    opcode_counts = {f"0x{opcode:02x}": 0 for opcode in MANUAL_SCRIPT_OPCODES}
    trusted_opcode_counts = {f"0x{opcode:02x}": 0 for opcode in MANUAL_SCRIPT_OPCODES}
    rows: list[dict[str, Any]] = []
    map_loader_rows: list[str] = []
    for script_va in script_vas:
        stream = decode_stream(exe, sections, script_va, max_commands=80, max_bytes=0x300)
        commands = []
        after_untrusted_unknown = False
        for command in stream["commands"]:
            opcode = command.get("opcode")
            if opcode in MANUAL_SCRIPT_OPCODES:
                key = f"0x{opcode:02x}"
                opcode_counts[key] += 1
                if not after_untrusted_unknown:
                    trusted_opcode_counts[key] += 1
                commands.append({
                    "vaHex": command.get("vaHex"),
                    "opcodeHex": command.get("opcodeHex"),
                    "opcodeName": command.get("opcodeName"),
                    "summary": command.get("summary", ""),
                    "alignmentConfidence": (
                        "tentative-after-unknown-opcode"
                        if after_untrusted_unknown
                        else "trusted-before-unknown-opcode"
                    ),
                })
            if command.get("opcodeName") == "unknown" and opcode not in {0x02}:
                after_untrusted_unknown = True
        if commands:
            if stream["mapLoaderRefFound"]:
                map_loader_rows.append(hex32(script_va) or "")
            rows.append({
                "scriptVa": script_va,
                "scriptVaHex": hex32(script_va),
                "classification": (
                    "text/prompt-manual-object-script"
                    if stream["textPayloadRefs"]
                    else "manual-object-script-no-text"
                ),
                "textPreview": (
                    stream["textPayloadRefs"][0].get("textPreview", "")
                    if stream["textPayloadRefs"]
                    else ""
                ),
                "commands": commands,
                "mapLoaderRefFound": stream["mapLoaderRefFound"],
                "routeProofFound": stream["routeProofFound"],
            })
    return {
        "opcodeSemantics": [
            {
                "opcodeHex": f"0x{opcode:02x}",
                **metadata,
            }
            for opcode, metadata in MANUAL_SCRIPT_OPCODES.items()
        ],
        "opcodeCounts": opcode_counts,
        "trustedOpcodeCounts": trusted_opcode_counts,
        "scriptCount": len(rows),
        "mapLoaderRows": map_loader_rows,
        "routeProofFound": bool(map_loader_rows),
        "scripts": rows,
    }


def build_summary(exe_path: Path) -> dict[str, Any]:
    exe = exe_path.read_bytes()
    sections = read_sections(exe)
    snippet_rows = [snippet_row(exe, sections, row) for row in SNIPPETS]
    state_rows = [state_pair_row(exe, sections, row) for row in STATE_PAIR_SNIPPETS]
    jump_table = read_jump_table(exe, sections)
    jump_table_matches = jump_table == JUMP_TABLE_EXPECTED
    all_bridge_rows_match = all(row["matches"] for row in snippet_rows + state_rows) and jump_table_matches
    manual_opcodes = manual_opcode_review(exe, sections)
    return {
        "title": "Manual movement trigger bridge",
        "summary": {
            "manualMovementToObjectScriptBridgeFound": all_bridge_rows_match,
            "routeProofFound": False,
            "sceneAutoTransitionSupported": False,
            "manualMovementConsumerStatus": "object-overlap-script-cursor-bridge-found-route-target-unresolved",
            "manualScriptOpcodeCount": manual_opcodes["scriptCount"],
            "manualScriptOpcodeRouteProofFound": manual_opcodes["routeProofFound"],
            "scope": "manual movement overlap bridge only; concrete map transition target is not proven here",
        },
        "constants": {
            "actorCollisionHelperFunction": hex32(ACTOR_COLLISION_HELPER_FUNCTION),
            "actorOverlapLoop": hex32(ACTOR_OVERLAP_LOOP),
            "collisionObjectListHead": hex32(COLLISION_OBJECT_LIST_HEAD),
            "collisionObjectListSentinel": hex32(COLLISION_OBJECT_LIST_SENTINEL),
            "actorDirectionLatch": hex32(ACTOR_DIRECTION_LATCH),
            "objectPayloadField": "+0xec",
            "objectScriptCursorField": "+0x40",
            "genericObjectScriptRunner": hex32(SCRIPT_RUNNER_FUNCTION),
            "scriptHandlerTable": hex32(SCRIPT_HANDLER_TABLE),
            "scriptStopFlag": hex32(SCRIPT_STOP_FLAG),
        },
        "bridgeSteps": [
            {
                "step": 1,
                "label": "scan active collision object list",
                "evidence": "0x00431ca6 loads 0x00574100 and stops at 0x005741ec",
            },
            {
                "step": 2,
                "label": "filter overlap-capable scripted objects",
                "evidence": "object +0x14 must include 0x0101 and object +0xec must be nonzero",
            },
            {
                "step": 3,
                "label": "direction-specific overlap response",
                "evidence": "0x00574533 selects one of four overlap branches, mirrored for 1..4 and 5..8",
            },
            {
                "step": 4,
                "label": "arm object script cursor",
                "evidence": "0x00431fd6 copies object +0xec -> +0x40 after the latch is consumed",
            },
            {
                "step": 5,
                "label": "generic object script runner consumes +0x40",
                "evidence": "0x00402321 reads byte at object +0x40 and dispatches through 0x00440538",
            },
        ],
        "jumpTable": {
            "va": JUMP_TABLE_VA,
            "vaHex": hex32(JUMP_TABLE_VA),
            "entries": [hex32(value) for value in jump_table],
            "expectedEntries": [hex32(value) for value in JUMP_TABLE_EXPECTED],
            "matches": jump_table_matches,
            "latchMapping": {
                "1": hex32(jump_table[0]),
                "2": hex32(jump_table[1]),
                "3": hex32(jump_table[2]),
                "4": hex32(jump_table[3]),
                "5": hex32(jump_table[4]),
                "6": hex32(jump_table[5]),
                "7": hex32(jump_table[6]),
                "8": hex32(jump_table[7]),
            },
        },
        "instructionEvidence": snippet_rows,
        "statePairEvidence": state_rows,
        "manualScriptOpcodeReview": manual_opcodes,
        "missingEvidence": [
            "a concrete field-map active object descriptor with its runtime +0xec payload decoded",
            "a decoded object script command stream proving map-loader/selector-root transition",
            "a target map id/root/coordinate write linked to the copied +0xec script",
            "a source trigger footprint linked to a specific active object descriptor",
        ],
        "nonClaims": [
            "This does not prove map1_01a -> map1_02b or any other route.",
            "This does not prove scene-driven automatic map movement.",
            "Direction latch values remain movement/collision state, not a route selector by themselves.",
        ],
        "references": [
            "out/runtime_movement.md",
            "out/map1_01a_edge_trigger_gap.md",
            "out/opening_consumer_trace.md",
            "out/battle_display_vm_static_decode.md",
        ],
    }


def markdown(summary: dict[str, Any]) -> str:
    lines = [
        "# Manual Movement Trigger Bridge",
        "",
        f"- manual movement to object script bridge found: {summary['summary']['manualMovementToObjectScriptBridgeFound']}",
        f"- route proof found: {summary['summary']['routeProofFound']}",
        f"- scene-auto transition supported: {summary['summary']['sceneAutoTransitionSupported']}",
        f"- status: `{summary['summary']['manualMovementConsumerStatus']}`",
        f"- manual script opcode rows: {summary['summary']['manualScriptOpcodeCount']}",
        f"- manual script opcode route proof found: {summary['summary']['manualScriptOpcodeRouteProofFound']}",
        "",
        "## 결론",
        "",
        "수동 이동 중 오브젝트 겹침이 발생하면, EXE는 scripted object의 `object +0xec -> +0x40` 복사를 수행한다.",
        "`+0x40`은 generic runner `0x00402321`이 읽는 object script cursor다.",
        "따라서 수동 이동 트리거가 object script 실행으로 들어가는 다리는 확인됐지만, 그 스크립트가 어떤 맵 전환을 수행하는지는 아직 미확정이다.",
        "",
        "## Bridge Steps",
        "",
    ]
    for step in summary["bridgeSteps"]:
        lines.append(f"{step['step']}. {step['label']}: {step['evidence']}")
    lines += [
        "",
        "## Constants",
        "",
    ]
    for key, value in summary["constants"].items():
        lines.append(f"- {key}: `{value}`")
    lines += [
        "",
        "## Jump Table",
        "",
        f"- table: `{summary['jumpTable']['vaHex']}`",
        f"- matches expected: {summary['jumpTable']['matches']}",
        f"- entries: {', '.join(summary['jumpTable']['entries'])}",
        "",
        "## Instruction Evidence",
        "",
    ]
    for row in summary["instructionEvidence"]:
        lines.append(f"- `{row['vaHex']}` `{row['id']}`: {row['meaning']} / matches={row['matches']}")
    lines += [
        "",
        "## State Pair Evidence",
        "",
    ]
    for row in summary["statePairEvidence"]:
        lines.append(
            f"- `{row['vaHex']}` `{row['id']}`: actor +0x68={row['actorState']}, "
            f"object +0x68={row['objectState']} / matches={row['matches']}"
        )
    lines += [
        "",
        "## Manual Script Opcode Semantics",
        "",
        "| opcode | name | meaning | route impact | trusted/all |",
        "|---|---|---|---|---:|",
    ]
    counts = summary["manualScriptOpcodeReview"]["opcodeCounts"]
    trusted_counts = summary["manualScriptOpcodeReview"]["trustedOpcodeCounts"]
    for row in summary["manualScriptOpcodeReview"]["opcodeSemantics"]:
        lines.append(
            f"| `{row['opcodeHex']}` | {row['name']} | {row['meaning']} | "
            f"{row['routeImpact']} | {trusted_counts.get(row['opcodeHex'], 0)}/{counts.get(row['opcodeHex'], 0)} |"
        )
    lines += [
        "",
        "## Manual Script Opcode Samples",
        "",
        "| script | class | preview | commands |",
        "|---|---|---|---|",
    ]
    for row in summary["manualScriptOpcodeReview"]["scripts"][:40]:
        commands = "<br>".join(
            f"`{command['opcodeHex']}` {command['summary']} ({command['alignmentConfidence']})"
            for command in row["commands"][:8]
        )
        preview = "<br>".join((row.get("textPreview") or "").splitlines()[:3])
        lines.append(f"| `{row['scriptVaHex']}` | {row['classification']} | {preview} | {commands} |")
    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:
    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>"
        f"<td><code>{html.escape(row['actualHex'])}</code></td>"
        "</tr>"
        for row in summary["instructionEvidence"]
    )
    state_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['vaHex'])}</code></td>"
        f"<td><code>{html.escape(row['id'])}</code></td>"
        f"<td>{row['actorState']}</td>"
        f"<td>{row['objectState']}</td>"
        f"<td>{row['matches']}</td>"
        "</tr>"
        for row in summary["statePairEvidence"]
    )
    counts = summary["manualScriptOpcodeReview"]["opcodeCounts"]
    trusted_counts = summary["manualScriptOpcodeReview"]["trustedOpcodeCounts"]
    opcode_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['opcodeHex'])}</code></td>"
        f"<td>{html.escape(row['name'])}</td>"
        f"<td>{html.escape(row['meaning'])}</td>"
        f"<td>{html.escape(row['routeImpact'])}</td>"
        f"<td>{trusted_counts.get(row['opcodeHex'], 0)} / {counts.get(row['opcodeHex'], 0)}</td>"
        "</tr>"
        for row in summary["manualScriptOpcodeReview"]["opcodeSemantics"]
    )
    script_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['scriptVaHex'])}</code></td>"
        f"<td>{html.escape(row['classification'])}</td>"
        f"<td><small>{html.escape((row.get('textPreview') or '')[:240])}</small></td>"
        f"<td>{'<br>'.join(html.escape((cmd.get('opcodeHex') or '') + ' ' + (cmd.get('summary') or '') + ' (' + (cmd.get('alignmentConfidence') or '') + ')') for cmd in row['commands'][:8])}</td>"
        f"<td>{row['mapLoaderRefFound']}</td>"
        "</tr>"
        for row in summary["manualScriptOpcodeReview"]["scripts"][:80]
    )
    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"])
    bridge_steps = "\n".join(
        f"<li><strong>{html.escape(step['label'])}</strong>: {html.escape(step['evidence'])}</li>"
        for step in summary["bridgeSteps"]
    )
    return "\n".join([
        "<!doctype html>",
        '<html lang="ko">',
        "<head>",
        '  <meta charset="utf-8" />',
        "  <title>Manual Movement Trigger Bridge</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}",
        "    .ok{color:#047857;font-weight:700}.no{color:#b91c1c;font-weight:700}",
        "    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>Manual Movement Trigger Bridge</h1>",
        f"  <p class=\"marker\">manual movement to object script bridge found: {summary['summary']['manualMovementToObjectScriptBridgeFound']}</p>",
        f"  <p class=\"marker\">route proof found: {summary['summary']['routeProofFound']}</p>",
        f"  <p class=\"marker\">scene-auto transition supported: {summary['summary']['sceneAutoTransitionSupported']}</p>",
        f"  <p class=\"marker\">manual script opcode rows: {summary['summary']['manualScriptOpcodeCount']}</p>",
        f"  <p class=\"marker\">manual script opcode route proof found: {summary['summary']['manualScriptOpcodeRouteProofFound']}</p>",
        f"  <p>status: <code>{html.escape(summary['summary']['manualMovementConsumerStatus'])}</code></p>",
        "  <p>수동 이동 중 오브젝트 겹침이 발생하면 <code>object +0xec -> +0x40</code> 복사가 수행되고, <code>0x00402321</code> generic runner가 <code>+0x40</code>을 소비한다. 단, 구체적인 맵 전환 대상은 아직 증명하지 않는다.</p>",
        "  <script>",
        "    window.HWANSE_MANUAL_MOVEMENT_TRIGGER_BRIDGE = {",
        f"      manualMovementToObjectScriptBridgeFound: {str(summary['summary']['manualMovementToObjectScriptBridgeFound']).lower()},",
        f"      routeProofFound: {str(summary['summary']['routeProofFound']).lower()},",
        f"      sceneAutoTransitionSupported: {str(summary['summary']['sceneAutoTransitionSupported']).lower()},",
        "      objectPayloadToCursor: 'object +0xec -> +0x40',",
        "      genericRunner: '0x00402321'",
        "    };",
        "  </script>",
        "  <h2>Bridge Steps</h2>",
        f"  <ol>{bridge_steps}</ol>",
        "  <h2>Instruction Evidence</h2>",
        "  <table><thead><tr><th>VA</th><th>ID</th><th>meaning</th><th>match</th><th>actual bytes</th></tr></thead><tbody>",
        rows,
        "  </tbody></table>",
        "  <h2>State Pair Evidence</h2>",
        "  <table><thead><tr><th>VA</th><th>ID</th><th>actor +0x68</th><th>object +0x68</th><th>match</th></tr></thead><tbody>",
        state_rows,
        "  </tbody></table>",
        "  <h2>Manual Script Opcode Semantics</h2>",
        "  <table><thead><tr><th>opcode</th><th>name</th><th>meaning</th><th>route impact</th><th>trusted/all</th></tr></thead><tbody>",
        opcode_rows,
        "  </tbody></table>",
        "  <h2>Manual Script Opcode Samples</h2>",
        "  <table><thead><tr><th>script</th><th>class</th><th>preview</th><th>commands</th><th>map loader</th></tr></thead><tbody>",
        script_rows,
        "  </tbody></table>",
        "  <h2>Jump Table</h2>",
        f"  <p><code>{html.escape(summary['jumpTable']['vaHex'])}</code> matches={summary['jumpTable']['matches']} entries={html.escape(', '.join(summary['jumpTable']['entries']))}</p>",
        "  <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 / "manual_movement_trigger_bridge.json").write_text(
        json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + "\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 manual movement trigger bridge -> {args.out_dir / 'manual_movement_trigger_bridge.json'}")


if __name__ == "__main__":
    main()
