#!/usr/bin/env python3
"""Summarize the original EXE opening/title start context."""
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 offset_to_va, read_sections, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
EXE = ROOT / "Hwanse2.exe"
OPENING_SCRIPT_START = 0x004A2D38
OPENING_SCRIPT_END = 0x004A3DEC


OPENING_SEQUENCE = [
    {
        "order": 1,
        "phase": "fade-compile",
        "observed": "black screen, compile.cns appears first, then fades out",
        "evidence": ["compile.cns in opening resource tail", "user-confirmed first visual before aaa.cns"],
        "status": "resource-grounded-observed",
    },
    {
        "order": 2,
        "phase": "fade-logo-a",
        "observed": "black screen, aaa.cns fades in and fades out",
        "evidence": ["aaa.cns in opening resource tail", "selected pointer 0x004a2d38 is near this tail"],
        "status": "resource-grounded",
    },
    {
        "order": 3,
        "phase": "opening-battle-at",
        "observed": "btl_k2 battle, MLK id 10 continues, Ataho uses 맹호스페셜 신기",
        "evidence": ["btl_k2.cns in the same opening resource tail", "middata.mlk in the adjacent cluster"],
        "status": "resource-grounded-action-pending",
    },
    {
        "order": 4,
        "phase": "logo-e0-hit",
        "observed": "black screen, logo_00.cns E0 appears with WLK id 35 sound; first of two confirmed logo impact sounds",
        "evidence": ["logo_00.cns in opening resource tail"],
        "status": "resource-grounded-audio-pending",
    },
    {
        "order": 5,
        "phase": "opening-battle-rs",
        "observed": "battle resumes, Rinshan uses 선렬각 신기",
        "evidence": ["same btl_k2 opening battle context"],
        "status": "action-pending",
    },
    {
        "order": 6,
        "phase": "logo-e1-hit",
        "observed": "black screen, logo_00.cns E1 appears near the bottom with WLK id 35 sound; second of two confirmed logo impact sounds",
        "evidence": ["logo_00.cns in opening resource tail"],
        "status": "resource-grounded-audio-pending",
    },
    {
        "order": 7,
        "phase": "opening-battle-sm",
        "observed": "battle resumes, Smashu uses 쾌진격 신기",
        "evidence": ["same btl_k2 opening battle context"],
        "status": "action-pending",
    },
    {
        "order": 8,
        "phase": "title-build",
        "observed": "logo_00.cns E0/E1 merge, title.cns fades in, title menu becomes selectable",
        "evidence": ["logo_00.cns and title.cns in opening resource tail"],
        "status": "resource-grounded",
    },
]


STATIC_EVIDENCE = {
    "peEntryPointVaHex": "0x00437410",
    "peEntryPointFileOffsetHex": "0x00036810",
    "openingScriptPointerVaHex": "0x004a2d38",
    "openingSelector": "8:0",
    "openingSelectorRootHex": "0x00494c34",
    "openingSelectorRangeHex": "0x00494c34..0x004a3dec",
    "resourceClusterFileOffsetHex": "0x000a0cb0",
    "resourceClusterVaHex": "0x004a2cb0",
    "resourceTailSpanHex": "0x004a2cc0..0x004a2d0b",
    "resourceClusterHeadPointerHex": "0x004a2d38",
    "clusterPointerRefVaHex": "0x0047e668",
    "clusterPointerRefFileOffsetHex": "0x0007c668",
}


RESOURCE_ROWS = [
    {"name": "middata.mlk", "vaHex": "0x004a2cb4", "fileOffsetHex": "0x000a0cb4", "role": "MIDI archive used by opening BGM candidate"},
    {"name": "compile.cns", "vaHex": "0x004a2cc0", "fileOffsetHex": "0x000a0cc0", "role": "first fade-in/fade-out image"},
    {"name": "aaa.cns", "vaHex": "0x004a2ccc", "fileOffsetHex": "0x000a0ccc", "role": "second fade-in/fade-out image before opening battle"},
    {"name": "logo_00.cns", "vaHex": "0x004a2cd4", "fileOffsetHex": "0x000a0cd4", "role": "E0/E1 logo impact frames"},
    {"name": "title.cns", "vaHex": "0x004a2ce0", "fileOffsetHex": "0x000a0ce0", "role": "final title screen"},
    {"name": "map_k1.cns", "vaHex": "0x004a2cea", "fileOffsetHex": "0x000a0cea", "role": "same opening/title resource cluster"},
    {"name": "map_k2.cns", "vaHex": "0x004a2cf5", "fileOffsetHex": "0x000a0cf5", "role": "same opening/title resource cluster"},
    {"name": "btl_k1.cns", "vaHex": "0x004a2d00", "fileOffsetHex": "0x000a0d00", "role": "same opening/title resource cluster"},
    {"name": "btl_k2.cns", "vaHex": "0x004a2d0b", "fileOffsetHex": "0x000a0d0b", "role": "opening battle background candidate"},
]


PENDING_ITEMS = [
    "MLK id 10 and WLK id 35 are observed in-game, and WLK id 35 is confirmed to occur exactly twice during the split logo impact. The exact sound opcode consumers are not decoded yet.",
    "The opening monster/formation is not grounded. Existing btl_k2 nearest-sprite candidates are proximity only and must not be promoted.",
    "Ataho/Rinshan/Smashu skill animation calls are observed from gameplay, but the exact opening action script payload is not decoded yet.",
    "0x00437410 is the PE entry point. It is not the useful content start by itself; the useful opening/title script pointer is 0x004a2d38.",
]


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


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


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


def find_opening_hits(exe: bytes, sections: list[dict], targets: dict[str, int]) -> dict[str, dict[str, Any]]:
    start = va_to_offset(sections, OPENING_SCRIPT_START)
    end = va_to_offset(sections, OPENING_SCRIPT_END)
    if start is None or end is None:
        return {}
    result: dict[str, dict[str, Any]] = {}
    for label, target in targets.items():
        needle = struct.pack("<I", target)
        hits = []
        search = start
        while True:
            hit = exe.find(needle, search, end)
            if hit < 0:
                break
            hits.append({"vaHex": hex32(offset_to_va(sections, hit)), "fileOffsetHex": file_hex(hit)})
            search = hit + 1
        result[label] = {"targetVaHex": hex32(target), "openingHitCount": len(hits), "openingHits": hits}
    return result


def scan_opening_opcode_like_rows(exe: bytes, sections: list[dict]) -> dict[str, Any]:
    start = va_to_offset(sections, OPENING_SCRIPT_START)
    end = va_to_offset(sections, OPENING_SCRIPT_END)
    if start is None or end is None:
        return {"available": False}
    counts: dict[int, int] = {}
    interesting_rows = []
    interesting_opcodes = {0x24, 0x26, 0x36, 0x38, 0x3B, 0x46}
    for off in range(start, end, 4):
        value = struct.unpack_from("<I", exe, off)[0]
        low = value & 0xFF
        counts[low] = counts.get(low, 0) + 1
        if low not in interesting_opcodes:
            continue
        interesting_rows.append(
            {
                "vaHex": hex32(offset_to_va(sections, off)),
                "fileOffsetHex": file_hex(off),
                "valueHex": hex32(value),
                "lowByteHex": f"0x{low:02x}",
                "byte1Hex": f"0x{(value >> 8) & 0xff:02x}",
                "byte2Hex": f"0x{(value >> 16) & 0xff:02x}",
                "byte3Hex": f"0x{(value >> 24) & 0xff:02x}",
            }
        )
    top_counts = [
        {"lowByteHex": f"0x{low:02x}", "count": count}
        for low, count in sorted(counts.items(), key=lambda item: (-item[1], item[0]))[:20]
    ]
    return {
        "available": True,
        "rangeVaHex": f"{hex32(OPENING_SCRIPT_START)}..{hex32(OPENING_SCRIPT_END)}",
        "rangeFileOffsetHex": f"{file_hex(start)}..{file_hex(end)}",
        "lowByteCountsTop20": top_counts,
        "interestingRows": interesting_rows,
    }


def collect_battle_action_targets() -> tuple[list[dict[str, Any]], dict[str, int]]:
    battle = load_json(OUT / "battle_skill_records.json", {})
    rows = battle.get("rows") or []
    target_rows = []
    targets: dict[str, int] = {}
    for index in (81, 120, 124):
        if index >= len(rows):
            continue
        row = rows[index]
        label = f"{row.get('name') or 'unknown'} row {index}"
        target_rows.append(
            {
                "label": label,
                "name": row.get("name"),
                "index": index,
                "recordVaHex": row.get("recordVaHex"),
                "payloadVaHex": row.get("payloadVaHex"),
                "sequenceCount": row.get("sequenceCount"),
                "status": "shared battle action payload grounded",
            }
        )
        if row.get("recordVa"):
            targets[f"{label} record"] = int(row["recordVa"])
        if row.get("payloadVa"):
            targets[f"{label} payload"] = int(row["payloadVa"])

    ui = load_json(OUT / "ui_cns_grid_mappings.json", {})
    opening_player_skills = {
        "맹호스페셜": {"character": "아타호", "observedLevel": "신기"},
        "선렬각": {"character": "린샹", "observedLevel": "신기"},
        "쾌진격": {"character": "스마슈", "observedLevel": "신기"},
    }
    for skill in ui.get("skillReferences") or []:
        if skill.get("name") not in opening_player_skills:
            continue
        observed = opening_player_skills[skill["name"]]
        level_payloads = []
        observed_payload = None
        for payload in skill.get("exeLevelPayloads") or []:
            file_offset_text = payload.get("fileOffsetHex")
            if not file_offset_text:
                continue
            file_offset = int(file_offset_text, 16)
            # Use the PE section table when available in the caller; keep the raw file
            # offset here and add a stable known VA from existing generated evidence.
            known_va = 0x00402000 + file_offset
            label = f"{skill['name']} {payload.get('label')} payload"
            targets[label] = known_va
            item = {
                "label": payload.get("label"),
                "fileOffsetHex": file_offset_text,
                "payloadVaHex": hex32(known_va),
                "mpCost": payload.get("mpCost"),
                "attackCount": payload.get("attackCount"),
                "summary": payload.get("summary"),
                "openingObserved": payload.get("label") == observed["observedLevel"],
            }
            if item["openingObserved"]:
                observed_payload = item
            level_payloads.append(item)
        target_rows.append(
            {
                "label": f"{skill['name']} level payloads",
                "name": skill["name"],
                "character": observed["character"],
                "observedLevel": observed["observedLevel"],
                "observedPayload": observed_payload,
                "status": "player skill level payload grounded; opening observation uses 신기",
                "levelPayloads": level_payloads,
            }
        )
    return target_rows, targets


def build_decode_probe() -> dict[str, Any]:
    action_rows, action_targets = collect_battle_action_targets()
    if not EXE.exists():
        return {
            "available": False,
            "reason": "Hwanse2.exe not found",
            "actionRows": action_rows,
        }
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    opcode_scan = scan_opening_opcode_like_rows(exe, sections)
    opening_action_hits = find_opening_hits(exe, sections, action_targets)
    direct_action_hit_count = sum(item["openingHitCount"] for item in opening_action_hits.values())
    resource_refs = find_opening_hits(
        exe,
        sections,
        {
            "middata.mlk string pointer": 0x004A2CB4,
            "logo_00.cns descriptor pointer": 0x004A3CF8,
            "title.cns descriptor pointer": 0x004A3D20,
            "btl_k2.cns descriptor pointer": 0x004A3CA4,
        },
    )
    return {
        "available": True,
        "dataModelCaution": (
            "The opening range is mixed descriptor/script data. Low-byte opcode scans are useful "
            "for narrowing candidates, but pointer/operand collisions must not be promoted as executed opcodes."
        ),
        "opcodeScan": opcode_scan,
        "midiCandidate": {
            "status": "strong-candidate-unpromoted",
            "siteVaHex": "0x004a2df4",
            "fileOffsetHex": "0x0a0df4",
            "valueHex": "0x0a003026",
            "opcodeLikeLowByteHex": "0x26",
            "highByteHex": "0x0a",
            "observedMeaning": "0x0a is EXE MLK track id 10.",
            "adjacentEvidence": "The next dword at 0x004a2df8 points to 0x004a2cb4 / middata.mlk.",
            "notYetProof": "The 0x26 handler path seen so far refreshes runtime object lists and does not by itself prove MIDI playback.",
        },
        "wlkCandidate": {
            "status": "observed-twice-confirmed-exe-site-strong-candidate-unpromoted",
            "sites": [
                {"siteVaHex": "0x004a3060", "fileOffsetHex": "0x0a1060", "valueHex": "0x23000124"},
                {"siteVaHex": "0x004a308c", "fileOffsetHex": "0x0a108c", "valueHex": "0x23000124"},
            ],
            "opcodeLikeLowByteHex": "0x24",
            "modeByteHex": "0x01",
            "highByteHex": "0x23",
            "observedMeaning": "0x23 is EXE WLK id 35.",
            "adjacentEvidence": "The two identical rows sit in two repeated logo_00 impact sub-blocks, matching the user-confirmed two logo hit sounds.",
            "notYetProof": "The 0x24 mode-1 handler reads stream+1 and object/global state; high-byte consumption is not proven in that handler alone.",
        },
        "resourceRefsInsideOpening": resource_refs,
        "battleActionPayloadProbe": {
            "status": "direct-pointers-not-found-in-opening-stream",
            "directActionPointerOpeningHitCount": direct_action_hit_count,
            "interpretation": (
                "선렬각/쾌진격/맹호스페셜 신기 payloads are grounded elsewhere, but the opening stream does not "
                "directly contain those payload VAs. The opening likely selects actions indirectly through "
                "indices, actor state, or another event object payload."
            ),
            "checkedRows": action_rows,
            "checkedPointerHits": opening_action_hits,
        },
        "handlerNotes": [
            {
                "opcode": "0x24",
                "handlerVaHex": "0x0040c513",
                "knownMeaning": "mode 1 writes byte(0x0059e348)+3 into current object+0x61 and advances +4",
                "openingUse": "two 0x23000124 rows are good WLK id 35 candidates but not promoted until object+0x61/global producer-consumer is decoded",
            },
            {
                "opcode": "0x26",
                "handlerVaHex": "0x0040c948",
                "knownMeaning": "rebuilds/iterates runtime object ordering and calls object setup/update helpers",
                "openingUse": "0x0a003026 beside middata.mlk is a good MLK id 10 candidate but not promoted as a playback call yet",
            },
            {
                "opcode": "0x36",
                "handlerVaHex": "0x0041ff44..0x00420028",
                "knownMeaning": "battle-action text handler grounded in the separate event/object VM context",
                "openingUse": "no direct 0x36 action row was found in this opening range scan",
            },
        ],
        "nextProofNeeded": [
            "Decode the consumer of object+0x61 around the two 0x23000124 rows to prove WLK id 35.",
            "Decode the 0x0a003026 + middata.mlk cluster or the subsequent audio function call to prove MLK id 10.",
            "Find the actor/action index producer that maps the opening Ataho/Rinshan/Smashu sequence to the grounded skill payloads.",
            "Specifically prove selection of 맹호스페셜/선렬각/쾌진격 신기 payloads, since the opening observation fixes all three skills at 신기.",
        ],
    }


def build_summary() -> dict[str, Any]:
    runtime = load_json(OUT / "runtime_title_start_context.json", {})
    selected_pointer = runtime.get("selectedPointerStaticHex") or STATIC_EVIDENCE["openingScriptPointerVaHex"]
    selector = runtime.get("selectedPointerSelector") or STATIC_EVIDENCE["openingSelector"]
    selector_root = runtime.get("selectedPointerRootHex") or STATIC_EVIDENCE["openingSelectorRootHex"]
    selector_range = runtime.get("selectedPointerRangeHex") or STATIC_EVIDENCE["openingSelectorRangeHex"]
    resource_tail_span = runtime.get("titleResourceTailSpanHex") or STATIC_EVIDENCE["resourceTailSpanHex"]
    nearby = runtime.get("nearbyCnsStrings") or [row["name"] for row in RESOURCE_ROWS if row["name"] != "middata.mlk"]
    has_required = all(name in nearby or name == "middata.mlk" for name in ["compile.cns", "aaa.cns", "logo_00.cns", "title.cns", "btl_k2.cns", "middata.mlk"])
    confidence = "high-resource-start-candidate" if selected_pointer == "0x004a2d38" and has_required else "partial"
    decode_probe = build_decode_probe()
    return {
        "objective": "identify the original EXE opening/title start point before normal title-menu control",
        "classification": confidence,
        "peEntryPointVaHex": STATIC_EVIDENCE["peEntryPointVaHex"],
        "peEntryPointMeaning": "Windows/CRT process entry; useful for code tracing, not the content script itself",
        "openingScriptPointerVaHex": selected_pointer,
        "openingSelector": selector,
        "openingSelectorRootHex": selector_root,
        "openingSelectorRangeHex": selector_range,
        "resourceTailSpanHex": resource_tail_span,
        "staticEvidence": STATIC_EVIDENCE,
        "resourceRows": RESOURCE_ROWS,
        "observedOpeningSequence": OPENING_SEQUENCE,
        "decodeProbe": decode_probe,
        "pendingDecode": PENDING_ITEMS,
        "promotedConclusions": [
            "0x004a2d38 should be treated as the current best opening/title script start candidate.",
            "The exact resource tail matches the observed compile.cns -> aaa.cns -> btl_k2.cns -> logo_00.cns/title.cns opening flow.",
            "The opening stream does not directly reference the grounded battle action payload VAs for 선렬각, 쾌진격, or 맹호스페셜.",
            "The observed opening uses 맹호스페셜/선렬각/쾌진격 신기; those player-side 신기 payloads are grounded, but their opening selector is not decoded yet.",
            "The MLK id 10 and WLK id 35 sites now have strong opcode-like candidates. WLK id 35 is observed exactly twice, matching the split logo impact, but the EXE consumer remains unpromoted.",
            "This is separate from the web runtime's field start map and should not be mixed with map transition proof.",
        ],
        "doNotPromoteYet": [
            "opening monster identity",
            "MLK id 10 call site",
            "WLK id 35 call site",
            "exact skill/action sequence payloads",
        ],
    }


def markdown(summary: dict[str, Any]) -> str:
    decode = summary.get("decodeProbe") or {}
    lines = [
        "# Opening Start Context",
        "",
        f"- PE entry point: `{summary['peEntryPointVaHex']}`",
        f"- opening/title script pointer candidate: `{summary['openingScriptPointerVaHex']}`",
        f"- selector: `{summary['openingSelector']}`",
        f"- selector root/range: `{summary['openingSelectorRootHex']}` / `{summary['openingSelectorRangeHex']}`",
        f"- resource tail span: `{summary['resourceTailSpanHex']}`",
        f"- classification: `{summary['classification']}`",
        "- detailed row review: [`opening_opcode_review.html`](opening_opcode_review.html)",
        "",
        "## Conclusion",
        "",
    ]
    lines.extend(f"- {item}" for item in summary["promotedConclusions"])
    lines.extend([
        "",
        "## Observed Opening Sequence",
        "",
        "| # | phase | observed | evidence | status |",
        "| ---: | --- | --- | --- | --- |",
    ])
    for row in summary["observedOpeningSequence"]:
        lines.append(
            f"| {row['order']} | `{row['phase']}` | {row['observed']} | "
            f"{'; '.join(row['evidence'])} | `{row['status']}` |"
        )
    lines.extend([
        "",
        "## Resource Cluster",
        "",
        "| resource | VA | file offset | role |",
        "| --- | --- | --- | --- |",
    ])
    for row in summary["resourceRows"]:
        lines.append(f"| `{row['name']}` | `{row['vaHex']}` | `{row['fileOffsetHex']}` | {row['role']} |")
    if decode.get("available"):
        midi = decode["midiCandidate"]
        wlk = decode["wlkCandidate"]
        battle_probe = decode["battleActionPayloadProbe"]
        lines.extend([
            "",
            "## Decode Probe",
            "",
            f"- data model caution: {decode['dataModelCaution']}",
            f"- battle action direct pointer hits in opening stream: `{battle_probe['directActionPointerOpeningHitCount']}`",
            f"- action dispatch interpretation: {battle_probe['interpretation']}",
            "",
            "### Audio Candidates",
            "",
            "| kind | status | site | value | candidate meaning | not yet proof |",
            "| --- | --- | --- | --- | --- | --- |",
            (
                f"| MIDI/BGM | `{midi['status']}` | `{midi['siteVaHex']}` / `{midi['fileOffsetHex']}` | "
                f"`{midi['valueHex']}` | {midi['observedMeaning']} {midi['adjacentEvidence']} | {midi['notYetProof']} |"
            ),
        ])
        for site in wlk["sites"]:
            lines.append(
                f"| WLK/SFX | `{wlk['status']}` | `{site['siteVaHex']}` / `{site['fileOffsetHex']}` | "
                f"`{site['valueHex']}` | {wlk['observedMeaning']} {wlk['adjacentEvidence']} | {wlk['notYetProof']} |"
            )
        lines.extend([
            "",
            "### Checked Action Payloads",
            "",
            "| action | record/payload | opening stream direct refs | status |",
            "| --- | --- | ---: | --- |",
        ])
        hits = battle_probe.get("checkedPointerHits") or {}
        for row in battle_probe.get("checkedRows") or []:
            if row.get("levelPayloads"):
                for payload in row["levelPayloads"]:
                    label = f"{row['name']} {payload['label']}"
                    if payload.get("openingObserved"):
                        label += " (오프닝 관측)"
                    hit = hits.get(f"{label} payload") or {}
                    hit_key = f"{row['name']} {payload['label']} payload"
                    hit = hits.get(hit_key) or hit
                    lines.append(
                        f"| {label} | `{payload['payloadVaHex']}` / {payload['summary']} | "
                        f"{hit.get('openingHitCount', 0)} | `{row['status']}` |"
                    )
                continue
            record_hit = hits.get(f"{row['label']} record") or {}
            payload_hit = hits.get(f"{row['label']} payload") or {}
            lines.append(
                f"| {row['label']} | `{row.get('recordVaHex')}` -> `{row.get('payloadVaHex')}` / "
                f"{row.get('sequenceCount')} step(s) | "
                f"{record_hit.get('openingHitCount', 0)} record, {payload_hit.get('openingHitCount', 0)} payload | "
                f"`{row['status']}` |"
            )
        lines.extend([
            "",
            "### Handler Notes",
            "",
        ])
        lines.extend(
            f"- `{row['opcode']}` / `{row['handlerVaHex']}`: {row['knownMeaning']}; opening use: {row['openingUse']}"
            for row in decode.get("handlerNotes") or []
        )
        lines.extend([
            "",
            "### Next Proof Needed",
            "",
        ])
        lines.extend(f"- {item}" for item in decode.get("nextProofNeeded") or [])
    lines.extend([
        "",
        "## Pending Decode",
        "",
    ])
    lines.extend(f"- {item}" for item in summary["pendingDecode"])
    lines.extend([
        "",
        "## Do Not Promote Yet",
        "",
    ])
    lines.extend(f"- {item}" for item in summary["doNotPromoteYet"])
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict[str, Any]) -> str:
    decode = summary.get("decodeProbe") or {}
    sequence_rows = "".join(
        "<tr>"
        f"<td>{html.escape(str(row['order']))}</td>"
        f"<td><code>{html.escape(row['phase'])}</code></td>"
        f"<td>{html.escape(row['observed'])}</td>"
        f"<td>{html.escape('; '.join(row['evidence']))}</td>"
        f"<td><code>{html.escape(row['status'])}</code></td>"
        "</tr>"
        for row in summary["observedOpeningSequence"]
    )
    resource_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(row['name'])}</code></td>"
        f"<td><code>{html.escape(row['vaHex'])}</code></td>"
        f"<td><code>{html.escape(row['fileOffsetHex'])}</code></td>"
        f"<td>{html.escape(row['role'])}</td>"
        "</tr>"
        for row in summary["resourceRows"]
    )
    pending_items = "".join(f"<li>{html.escape(item)}</li>" for item in summary["pendingDecode"])
    conclusions = "".join(f"<li>{html.escape(item)}</li>" for item in summary["promotedConclusions"])
    decode_html = ""
    if decode.get("available"):
        midi = decode["midiCandidate"]
        wlk = decode["wlkCandidate"]
        battle_probe = decode["battleActionPayloadProbe"]
        audio_rows = [
            "<tr>"
            "<td>MIDI/BGM</td>"
            f"<td><code>{html.escape(midi['status'])}</code></td>"
            f"<td><code>{html.escape(midi['siteVaHex'])}</code><br><code>{html.escape(midi['fileOffsetHex'])}</code></td>"
            f"<td><code>{html.escape(midi['valueHex'])}</code></td>"
            f"<td>{html.escape(midi['observedMeaning'])}<br>{html.escape(midi['adjacentEvidence'])}</td>"
            f"<td>{html.escape(midi['notYetProof'])}</td>"
            "</tr>"
        ]
        for site in wlk["sites"]:
            audio_rows.append(
                "<tr>"
                "<td>WLK/SFX</td>"
                f"<td><code>{html.escape(wlk['status'])}</code></td>"
                f"<td><code>{html.escape(site['siteVaHex'])}</code><br><code>{html.escape(site['fileOffsetHex'])}</code></td>"
                f"<td><code>{html.escape(site['valueHex'])}</code></td>"
                f"<td>{html.escape(wlk['observedMeaning'])}<br>{html.escape(wlk['adjacentEvidence'])}</td>"
                f"<td>{html.escape(wlk['notYetProof'])}</td>"
                "</tr>"
            )
        hits = battle_probe.get("checkedPointerHits") or {}
        action_rows = []
        for row in battle_probe.get("checkedRows") or []:
            if row.get("levelPayloads"):
                for payload in row["levelPayloads"]:
                    label = f"{row['name']} {payload['label']}"
                    hit = hits.get(f"{label} payload") or {}
                    if payload.get("openingObserved"):
                        label += " (오프닝 관측)"
                    action_rows.append(
                        "<tr>"
                        f"<td>{html.escape(label)}</td>"
                        f"<td><code>{html.escape(payload['payloadVaHex'])}</code><br>{html.escape(payload['summary'] or '')}</td>"
                        f"<td>{html.escape(str(hit.get('openingHitCount', 0)))}</td>"
                        f"<td><code>{html.escape(row['status'])}</code></td>"
                        "</tr>"
                    )
                continue
            record_hit = hits.get(f"{row['label']} record") or {}
            payload_hit = hits.get(f"{row['label']} payload") or {}
            action_rows.append(
                "<tr>"
                f"<td>{html.escape(row['label'])}</td>"
                f"<td><code>{html.escape(row.get('recordVaHex') or '-')}</code> -> "
                f"<code>{html.escape(row.get('payloadVaHex') or '-')}</code><br>"
                f"{html.escape(str(row.get('sequenceCount')))} step(s)</td>"
                f"<td>{record_hit.get('openingHitCount', 0)} record / {payload_hit.get('openingHitCount', 0)} payload</td>"
                f"<td><code>{html.escape(row['status'])}</code></td>"
                "</tr>"
            )
        handler_items = "".join(
            "<li>"
            f"<code>{html.escape(row['opcode'])}</code> / <code>{html.escape(row['handlerVaHex'])}</code>: "
            f"{html.escape(row['knownMeaning'])}; opening use: {html.escape(row['openingUse'])}"
            "</li>"
            for row in decode.get("handlerNotes") or []
        )
        proof_items = "".join(f"<li>{html.escape(item)}</li>" for item in decode.get("nextProofNeeded") or [])
        decode_html = "\n".join(
            [
                "<h2>Decode Probe</h2>",
                f"<p>{html.escape(decode['dataModelCaution'])}</p>",
                "<ul>",
                f"<li>battle action direct pointer hits in opening stream: <code>{battle_probe['directActionPointerOpeningHitCount']}</code></li>",
                f"<li>action dispatch interpretation: {html.escape(battle_probe['interpretation'])}</li>",
                "</ul>",
                "<h3>Audio Candidates</h3>",
                "<table><thead><tr><th>kind</th><th>status</th><th>site</th><th>value</th><th>candidate meaning</th><th>not yet proof</th></tr></thead><tbody>",
                "".join(audio_rows),
                "</tbody></table>",
                "<h3>Checked Action Payloads</h3>",
                "<table><thead><tr><th>action</th><th>record/payload</th><th>opening refs</th><th>status</th></tr></thead><tbody>",
                "".join(action_rows),
                "</tbody></table>",
                "<h3>Handler Notes</h3>",
                f"<ul>{handler_items}</ul>",
                "<h3>Next Proof Needed</h3>",
                f"<ul>{proof_items}</ul>",
            ]
        )
    return "\n".join([
        "<!doctype html>",
        "<html lang=\"ko\"><head><meta charset=\"utf-8\" />",
        "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />",
        "<title>Opening Start Context</title>",
        "<style>body{font-family:system-ui,sans-serif;background:#f6f7f9;color:#17202a;max-width:1180px;margin:24px auto;padding:0 16px;line-height:1.5}table{border-collapse:collapse;width:100%;background:white}td,th{border:1px solid #d8dee6;padding:7px 9px;text-align:left;vertical-align:top}th{background:#eef2f6}code{color:#185abc}.tag{display:inline-block;padding:2px 7px;border-radius:999px;background:#e6f4f1;color:#0f766e;font-size:12px}</style>",
        "</head><body>",
        "<main data-opening-start-context data-page=\"opening-start-context\">",
        "<h1>Opening Start Context</h1>",
        "<p><span class=\"tag\">resource-grounded start candidate</span></p>",
        "<ul>",
        f"<li>PE entry point: <code>{html.escape(summary['peEntryPointVaHex'])}</code></li>",
        f"<li>opening/title script pointer candidate: <code>{html.escape(summary['openingScriptPointerVaHex'])}</code></li>",
        f"<li>selector: <code>{html.escape(summary['openingSelector'])}</code></li>",
        f"<li>selector root/range: <code>{html.escape(summary['openingSelectorRootHex'])}</code> / <code>{html.escape(summary['openingSelectorRangeHex'])}</code></li>",
        f"<li>resource tail span: <code>{html.escape(summary['resourceTailSpanHex'])}</code></li>",
        f"<li>classification: <code>{html.escape(summary['classification'])}</code></li>",
        "<li>detailed row review: <a href=\"opening_opcode_review.html\">opening_opcode_review.html</a></li>",
        "</ul>",
        "<h2>Conclusion</h2>",
        f"<ul>{conclusions}</ul>",
        "<h2>Observed Opening Sequence</h2>",
        "<table><thead><tr><th>#</th><th>phase</th><th>observed</th><th>evidence</th><th>status</th></tr></thead><tbody>",
        sequence_rows,
        "</tbody></table>",
        "<h2>Resource Cluster</h2>",
        "<table><thead><tr><th>resource</th><th>VA</th><th>file offset</th><th>role</th></tr></thead><tbody>",
        resource_rows,
        "</tbody></table>",
        decode_html,
        "<h2>Pending Decode</h2>",
        f"<ul>{pending_items}</ul>",
        "<script>window.HWANSE_OPENING_START_CONTEXT_READY = true; window.HWANSE_LAST_OPENING_START_CONTEXT = { openingScriptPointerVaHex: \"0x004a2d38\", peEntryPointVaHex: \"0x00437410\", resourceGrounded: true, audioOpcodeDecoded: false, actionOpcodeDecoded: false, openingAudioCandidatesFound: true, openingActionDirectPointersFound: false, openingOpcodeReviewHref: \"opening_opcode_review.html\" };</script>",
        "</main></body></html>",
    ])


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


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--html-out", type=Path)
    args = parser.parse_args()
    summary = build_summary()
    md_out = write_outputs(summary, args.out_dir, args.html_out)
    print(f"wrote opening start context -> {md_out}")


if __name__ == "__main__":
    main()
