#!/usr/bin/env python3
"""Build a row-level review of the original EXE opening/title stream."""
from __future__ import annotations

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

from probe_exe_scene_tables import offset_to_va, read_sections, va_to_offset
from summarize_opening_start_context import (
    OPENING_SCRIPT_END,
    OPENING_SCRIPT_START,
    RESOURCE_ROWS,
    build_summary as build_opening_summary,
    file_hex,
    hex32,
)


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


DESCRIPTOR_POINTERS = {
    0x004A3CA4: "btl_k2.cns descriptor pointer candidate",
    0x004A3CF8: "logo_00.cns descriptor pointer candidate",
    0x004A3D20: "title.cns descriptor pointer candidate",
}


EXACT_AUDIO_SITES = {
    0x004A2DF4: {
        "category": "audio-midi-candidate",
        "label": "MLK id 10 candidate",
        "note": "value 0x0a003026; high byte 0x0a is EXE MIDI track id 10; consumer not promoted",
    },
    0x004A3060: {
        "category": "audio-wlk-candidate",
        "label": "WLK id 35 candidate 1",
        "note": "value 0x23000124; high byte 0x23 is EXE WLK id 35; first confirmed logo impact sound",
    },
    0x004A308C: {
        "category": "audio-wlk-candidate",
        "label": "WLK id 35 candidate 2",
        "note": "value 0x23000124; high byte 0x23 is EXE WLK id 35; second confirmed logo impact sound",
    },
}


BLOCK_ANCHORS = [
    {
        "key": "stream-start",
        "label": "Opening stream start",
        "va": OPENING_SCRIPT_START,
        "note": "current selected pointer 8:0; resource cluster immediately precedes this stream",
    },
    {
        "key": "midi11",
        "label": "MLK id 10 candidate",
        "va": 0x004A2DF4,
        "note": "strong BGM row near middata.mlk pointer evidence",
    },
    {
        "key": "logo-hit-1",
        "label": "Logo impact WLK row 1",
        "va": 0x004A3060,
        "note": "first of two WLK id 35 logo sounds",
    },
    {
        "key": "logo-hit-2",
        "label": "Logo impact WLK row 2",
        "va": 0x004A308C,
        "note": "second of two WLK id 35 logo sounds",
    },
    {
        "key": "btl-k2-descriptor",
        "label": "btl_k2 descriptor area",
        "va": 0x004A3CA4,
        "note": "battle background descriptor pointer candidate area",
    },
    {
        "key": "logo-descriptor",
        "label": "logo_00 descriptor area",
        "va": 0x004A3CF8,
        "note": "split logo descriptor pointer candidate area",
    },
    {
        "key": "title-descriptor",
        "label": "title descriptor area",
        "va": 0x004A3D20,
        "note": "final title screen descriptor pointer candidate area",
    },
]


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 c_string(exe: bytes, offset: int) -> str:
    end = exe.find(b"\0", offset)
    if end < 0:
        end = len(exe)
    return exe[offset:end].decode("ascii", "replace")


def find_resource_strings(exe: bytes, sections: list[dict]) -> dict[int, str]:
    strings: dict[int, str] = {}
    for match in re.finditer(rb"[a-z0-9_]{2,20}\.(?:cns|mlk|wlk)\0", exe):
        va = offset_to_va(sections, match.start())
        if va is not None:
            strings[va] = c_string(exe, match.start())
    return strings


def load_handler_map() -> dict[int, dict[str, Any]]:
    table = load_json(OUT / "script_handler_table.json", {})
    result: dict[int, dict[str, Any]] = {}
    for row in table.get("entries") or []:
        opcode = row.get("opcode")
        if isinstance(opcode, int):
            result[opcode] = row
    return result


def classify_pointer(value: int, sections: list[dict], resource_strings: dict[int, str]) -> tuple[str, list[str]]:
    notes: list[str] = []
    category = ""
    if value in resource_strings:
        category = "resource-pointer"
        notes.append(f"points to resource string `{resource_strings[value]}`")
    elif value in DESCRIPTOR_POINTERS:
        category = "descriptor-pointer"
        notes.append(DESCRIPTOR_POINTERS[value])
    elif OPENING_SCRIPT_START <= value < OPENING_SCRIPT_END:
        category = "local-pointer"
        notes.append("points inside opening selected-pointer range")
    elif va_to_offset(sections, value) is not None and 0x00400000 <= value < 0x00600000:
        category = "exe-pointer"
        notes.append("points inside mapped EXE image")
    return category, notes


def summarize_handler(low: int, handler_map: dict[int, dict[str, Any]]) -> str:
    row = handler_map.get(low)
    if not row:
        return "-"
    default = "default" if row.get("isDefaultHandler") else "handler"
    return f"{row.get('handlerVaHex', '-')} ({default})"


def annotate_row(
    exe: bytes,
    sections: list[dict],
    resource_strings: dict[int, str],
    handler_map: dict[int, dict[str, Any]],
    off: int,
    index: int,
) -> dict[str, Any]:
    value = struct.unpack_from("<I", exe, off)[0]
    va = offset_to_va(sections, off)
    if va is None:
        raise ValueError(f"opening stream offset 0x{off:x} is not mapped")
    b0 = value & 0xFF
    b1 = (value >> 8) & 0xFF
    b2 = (value >> 16) & 0xFF
    b3 = (value >> 24) & 0xFF
    categories: list[str] = []
    notes: list[str] = []
    status = "raw"
    label = ""

    exact = EXACT_AUDIO_SITES.get(va)
    if exact:
        categories.append(exact["category"])
        notes.append(exact["note"])
        status = "strong-candidate-unpromoted"
        label = exact["label"]

    pointer_category, pointer_notes = classify_pointer(value, sections, resource_strings)
    if pointer_category:
        categories.append(pointer_category)
        notes.extend(pointer_notes)
        if status == "raw":
            status = "pointer-evidence"

    if b0 in {0x24, 0x26, 0x36, 0x38, 0x3B, 0x44, 0x45, 0x46, 0x52, 0x53, 0x58, 0x60, 0x61, 0x62, 0x63}:
        categories.append("opcode-like-low-byte")
        notes.append("low byte matches a known or interesting script-handler index; this can still be operand/pointer collision")
        if status == "raw":
            status = "opcode-like"

    if value <= 0xFF:
        categories.append("small-scalar")
    elif value <= 0xFFFF:
        categories.append("word-scalar")
    elif not categories:
        categories.append("packed-or-scalar")

    if not label and notes:
        label = notes[0]

    return {
        "index": index,
        "va": va,
        "vaHex": hex32(va),
        "fileOffset": off,
        "fileOffsetHex": file_hex(off),
        "value": value,
        "valueHex": hex32(value),
        "bytesHex": [f"0x{b:02x}" for b in (b0, b1, b2, b3)],
        "lowByteHex": f"0x{b0:02x}",
        "byte1Hex": f"0x{b1:02x}",
        "byte2Hex": f"0x{b2:02x}",
        "byte3Hex": f"0x{b3:02x}",
        "handlerCandidate": summarize_handler(b0, handler_map),
        "categories": sorted(set(categories)),
        "primaryCategory": categories[0] if categories else "raw",
        "status": status,
        "label": label,
        "notes": notes,
    }


def collect_action_payload_direct_hits(opening_summary: dict[str, Any]) -> list[dict[str, Any]]:
    probe = opening_summary.get("decodeProbe") or {}
    battle = (probe.get("battleActionPayloadProbe") or {})
    hits = battle.get("checkedPointerHits") or {}
    rows = []
    for label, item in sorted(hits.items()):
        rows.append(
            {
                "label": label,
                "targetVaHex": item.get("targetVaHex"),
                "openingHitCount": item.get("openingHitCount", 0),
                "status": "not-directly-referenced" if item.get("openingHitCount", 0) == 0 else "direct-reference-found",
            }
        )
    return rows


def build_focus_windows(rows: list[dict[str, Any]], radius: int = 8) -> list[dict[str, Any]]:
    by_va = {row["va"]: row for row in rows}
    result = []
    for anchor in BLOCK_ANCHORS:
        va = anchor["va"]
        start = va - radius * 4
        end = va + radius * 4
        window_rows = [row for row in rows if start <= row["va"] <= end]
        result.append(
            {
                **anchor,
                "vaHex": hex32(va),
                "present": va in by_va,
                "rows": window_rows,
            }
        )
    return result


def build_summary() -> dict[str, Any]:
    opening_summary = build_opening_summary()
    if not EXE.exists():
        return {
            "available": False,
            "reason": "Hwanse2.exe not found",
            "openingSummary": opening_summary,
        }
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    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,
            "reason": "opening range is not mapped in PE sections",
            "openingSummary": opening_summary,
        }
    resource_strings = find_resource_strings(exe, sections)
    handler_map = load_handler_map()
    rows = [
        annotate_row(exe, sections, resource_strings, handler_map, off, index)
        for index, off in enumerate(range(start, end, 4))
    ]
    low_counts: dict[str, int] = {}
    category_counts: dict[str, int] = {}
    status_counts: dict[str, int] = {}
    for row in rows:
        low_counts[row["lowByteHex"]] = low_counts.get(row["lowByteHex"], 0) + 1
        status_counts[row["status"]] = status_counts.get(row["status"], 0) + 1
        for category in row["categories"]:
            category_counts[category] = category_counts.get(category, 0) + 1
    interesting_rows = [
        row
        for row in rows
        if row["status"] != "raw"
        or any(category in row["categories"] for category in ("resource-pointer", "descriptor-pointer", "local-pointer"))
    ]
    return {
        "available": True,
        "objective": "row-level opening/title selected-pointer stream review",
        "rangeVaHex": f"{hex32(OPENING_SCRIPT_START)}..{hex32(OPENING_SCRIPT_END)}",
        "rangeFileOffsetHex": f"{file_hex(start)}..{file_hex(end)}",
        "rowCount": len(rows),
        "classification": "review-table-candidate-not-execution-proof",
        "dataModelCaution": (
            "This range is mixed descriptor/script data. Rows are dword-aligned review units; "
            "low-byte handler labels are candidates only until a consumer path is decoded."
        ),
        "openingStartContextHref": "opening_start_context.md",
        "resourceRows": RESOURCE_ROWS,
        "observedOpeningSequence": opening_summary.get("observedOpeningSequence", []),
        "promotedConclusions": [
            "0x004a2d38 remains the current opening/title selected-pointer candidate.",
            "The page promotes resource adjacency and observed sequence alignment, not exact opcode execution.",
            "MLK id 10 and the two WLK id 35 rows are strong candidates, still unpromoted as decoded audio calls.",
            "The opening does not directly contain the grounded 신기 skill payload VAs; action selection is still indirect.",
        ],
        "strongCandidates": [
            {
                "kind": "MIDI/BGM",
                "siteVaHex": "0x004a2df4",
                "fileOffsetHex": "0x0a0df4",
                "valueHex": "0x0a003026",
                "candidateMeaning": "MLK id 10 if high byte is EXE track id 10",
                "promotion": "unpromoted",
            },
            {
                "kind": "WLK/SFX",
                "siteVaHex": "0x004a3060",
                "fileOffsetHex": "0x0a1060",
                "valueHex": "0x23000124",
                "candidateMeaning": "first WLK id 35 split-logo impact",
                "promotion": "unpromoted",
            },
            {
                "kind": "WLK/SFX",
                "siteVaHex": "0x004a308c",
                "fileOffsetHex": "0x0a108c",
                "valueHex": "0x23000124",
                "candidateMeaning": "second WLK id 35 split-logo impact",
                "promotion": "unpromoted",
            },
        ],
        "actionPayloadDirectHits": collect_action_payload_direct_hits(opening_summary),
        "counts": {
            "lowByteTop20": [
                {"lowByteHex": key, "count": value}
                for key, value in sorted(low_counts.items(), key=lambda item: (-item[1], item[0]))[:20]
            ],
            "categoryCounts": [
                {"category": key, "count": value}
                for key, value in sorted(category_counts.items(), key=lambda item: (-item[1], item[0]))
            ],
            "statusCounts": [
                {"status": key, "count": value}
                for key, value in sorted(status_counts.items(), key=lambda item: (-item[1], item[0]))
            ],
        },
        "focusWindows": build_focus_windows(rows),
        "interestingRows": interesting_rows,
        "streamRows": rows,
        "nextProofNeeded": [
            "Trace the consumer of the two 0x23000124 rows to prove or reject WLK id 35 playback.",
            "Trace the 0x0a003026 row and adjacent middata.mlk pointer to prove or reject MLK id 10 playback.",
            "Find the indirect actor/action selector that maps the opening actions to 맹호스페셜/선렬각/쾌진격 신기 payloads.",
        ],
    }


def render_status_chips(rows: list[dict[str, Any]], key: str, label_key: str) -> str:
    chips = []
    for row in rows:
        chips.append(
            f"<span class=\"chip\"><code>{html.escape(str(row[label_key]))}</code> {html.escape(str(row['count']))}</span>"
        )
    return "".join(chips)


def row_tr(row: dict[str, Any]) -> str:
    notes = "<br>".join(html.escape(note) for note in row.get("notes") or [])
    categories = " ".join(row.get("categories") or [])
    class_name = " ".join(f"cat-{category}" for category in row.get("categories") or [])
    return (
        f"<tr class=\"stream-row {html.escape(class_name)}\" "
        f"data-status=\"{html.escape(row['status'])}\" "
        f"data-category=\"{html.escape(categories)}\" "
        f"data-text=\"{html.escape(' '.join([row['vaHex'], row['fileOffsetHex'], row['valueHex'], categories, ' '.join(row.get('notes') or [])]).lower())}\">"
        f"<td>{row['index']}</td>"
        f"<td><code>{html.escape(row['vaHex'])}</code><br><code>{html.escape(row['fileOffsetHex'])}</code></td>"
        f"<td><code>{html.escape(row['valueHex'])}</code></td>"
        f"<td><code>{html.escape(' '.join(row['bytesHex']))}</code></td>"
        f"<td><code>{html.escape(row['lowByteHex'])}</code><br>{html.escape(row['handlerCandidate'])}</td>"
        f"<td><span class=\"status\">{html.escape(row['status'])}</span><br>{html.escape(categories)}</td>"
        f"<td>{notes}</td>"
        "</tr>"
    )


def focus_window_html(windows: list[dict[str, Any]]) -> str:
    sections = []
    for window in windows:
        rows = "".join(row_tr(row) for row in window["rows"])
        sections.append(
            "<details open>"
            f"<summary>{html.escape(window['label'])} · <code>{html.escape(window['vaHex'])}</code></summary>"
            f"<p>{html.escape(window['note'])}</p>"
            "<div class=\"table-wrap\"><table><thead><tr><th>#</th><th>VA/file</th><th>value</th><th>bytes</th><th>low/handler</th><th>status/category</th><th>notes</th></tr></thead><tbody>"
            f"{rows}"
            "</tbody></table></div>"
            "</details>"
        )
    return "".join(sections)


def html_page(summary: dict[str, Any]) -> str:
    if not summary.get("available"):
        reason = html.escape(summary.get("reason", "unavailable"))
        return f"<!doctype html><meta charset=\"utf-8\"><title>Opening Opcode Review</title><p>{reason}</p>"
    conclusions = "".join(f"<li>{html.escape(item)}</li>" for item in summary["promotedConclusions"])
    strong_rows = "".join(
        "<tr>"
        f"<td>{html.escape(row['kind'])}</td>"
        f"<td><code>{html.escape(row['siteVaHex'])}</code><br><code>{html.escape(row['fileOffsetHex'])}</code></td>"
        f"<td><code>{html.escape(row['valueHex'])}</code></td>"
        f"<td>{html.escape(row['candidateMeaning'])}</td>"
        f"<td><code>{html.escape(row['promotion'])}</code></td>"
        "</tr>"
        for row in summary["strongCandidates"]
    )
    action_rows = "".join(
        "<tr>"
        f"<td>{html.escape(row['label'])}</td>"
        f"<td><code>{html.escape(str(row.get('targetVaHex') or '-'))}</code></td>"
        f"<td>{html.escape(str(row.get('openingHitCount', 0)))}</td>"
        f"<td><code>{html.escape(row['status'])}</code></td>"
        "</tr>"
        for row in summary["actionPayloadDirectHits"]
    )
    low_chips = render_status_chips(summary["counts"]["lowByteTop20"], "lowByteHex", "lowByteHex")
    category_chips = render_status_chips(summary["counts"]["categoryCounts"], "category", "category")
    status_chips = render_status_chips(summary["counts"]["statusCounts"], "status", "status")
    interesting_rows = "".join(row_tr(row) for row in summary["interestingRows"])
    stream_rows = "".join(row_tr(row) for row in summary["streamRows"])
    proof_items = "".join(f"<li>{html.escape(item)}</li>" for item in summary["nextProofNeeded"])
    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 Opcode Review</title>",
            "<style>",
            ":root{--bg:#f6f7f9;--fg:#17202a;--muted:#607080;--line:#d8dee6;--head:#eef2f6;--link:#185abc;--warn:#a15c00;--good:#0f766e;--bad:#b42318}",
            "*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;line-height:1.45}main{max-width:1480px;margin:0 auto;padding:18px}h1{margin:0 0 6px;font-size:24px;letter-spacing:0}h2{margin:20px 0 8px;font-size:18px}h3{margin:14px 0 8px;font-size:15px}a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}.sub{color:var(--muted)}.panel,details{margin:14px 0;background:#fff;border:1px solid var(--line);border-radius:8px;overflow:hidden}.panel-body{padding:12px 14px}.section-head,summary{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:12px 14px;background:var(--head);border-bottom:1px solid var(--line);font-weight:700}summary{cursor:pointer}.table-wrap{overflow:auto;max-height:72vh}table{width:100%;border-collapse:collapse;background:#fff}th,td{border-bottom:1px solid var(--line);padding:7px 9px;text-align:left;vertical-align:top;font-size:13px}th{position:sticky;top:0;background:#f8fafc;color:#344050;z-index:1}code{color:#185abc}.chip{display:inline-block;margin:2px;padding:3px 7px;border:1px solid var(--line);border-radius:999px;background:#f8fafc;font-size:12px}.status{display:inline-block;padding:2px 7px;border-radius:999px;background:#edf2f7;color:#334155;font-size:12px}.cat-audio-midi-candidate td,.cat-audio-wlk-candidate td{background:#fff7e6}.cat-resource-pointer td,.cat-descriptor-pointer td{background:#edf7ff}.cat-local-pointer td{background:#f3edff}.toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:10px 0}.toolbar input{width:min(520px,100%);padding:8px 10px;border:1px solid var(--line);border-radius:6px;font:inherit}.toolbar button{padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:#fff;cursor:pointer}.toolbar button.active{border-color:var(--link);color:var(--link);font-weight:700}.hidden-row{display:none}",
            "</style>",
            "</head><body>",
            "<main data-opening-opcode-review data-page=\"opening-opcode-review\">",
            "<header>",
            "<h1>Opening Opcode Review</h1>",
            f"<p class=\"sub\">range <code>{html.escape(summary['rangeVaHex'])}</code> / file <code>{html.escape(summary['rangeFileOffsetHex'])}</code> · {summary['rowCount']} dword rows · <a href=\"opening_start_context.md\">opening summary</a></p>",
            "</header>",
            "<section class=\"panel\"><div class=\"section-head\"><h2>Boundary</h2><span>candidate review, not execution proof</span></div><div class=\"panel-body\">",
            f"<p>{html.escape(summary['dataModelCaution'])}</p>",
            f"<ul>{conclusions}</ul>",
            f"<p><strong>status counts</strong> {status_chips}</p>",
            f"<p><strong>category counts</strong> {category_chips}</p>",
            f"<p><strong>low byte top20</strong> {low_chips}</p>",
            "</div></section>",
            "<section class=\"panel\"><div class=\"section-head\"><h2>Strong Candidate Rows</h2><span>still unpromoted</span></div><div class=\"table-wrap\"><table><thead><tr><th>kind</th><th>site</th><th>value</th><th>candidate meaning</th><th>promotion</th></tr></thead><tbody>",
            strong_rows,
            "</tbody></table></div></section>",
            "<section class=\"panel\"><div class=\"section-head\"><h2>Action Payload Direct Reference Probe</h2><span>expected: 0 direct hits</span></div><div class=\"table-wrap\"><table><thead><tr><th>target</th><th>VA</th><th>opening direct hits</th><th>status</th></tr></thead><tbody>",
            action_rows,
            "</tbody></table></div></section>",
            "<section><h2>Focus Windows</h2>",
            focus_window_html(summary["focusWindows"]),
            "</section>",
            "<section class=\"panel\"><div class=\"section-head\"><h2>Interesting Rows</h2><span>audio/resource/local/opcode-like rows</span></div><div class=\"table-wrap\"><table><thead><tr><th>#</th><th>VA/file</th><th>value</th><th>bytes</th><th>low/handler</th><th>status/category</th><th>notes</th></tr></thead><tbody>",
            interesting_rows,
            "</tbody></table></div></section>",
            "<section class=\"panel\"><div class=\"section-head\"><h2>Full Dword Stream</h2><span id=\"visibleCount\"></span></div><div class=\"panel-body\"><div class=\"toolbar\"><input id=\"streamSearch\" type=\"search\" placeholder=\"검색: VA, value, category, note\" /><button data-filter=\"all\" class=\"active\">전체</button><button data-filter=\"strong\">강한 후보</button><button data-filter=\"resource\">리소스/포인터</button><button data-filter=\"opcode\">opcode-like</button></div></div><div class=\"table-wrap\"><table><thead><tr><th>#</th><th>VA/file</th><th>value</th><th>bytes</th><th>low/handler</th><th>status/category</th><th>notes</th></tr></thead><tbody id=\"streamRows\">",
            stream_rows,
            "</tbody></table></div></section>",
            "<section class=\"panel\"><div class=\"section-head\"><h2>Next Proof Needed</h2></div><div class=\"panel-body\"><ul>",
            proof_items,
            "</ul></div></section>",
            "<script>",
            "const rows=[...document.querySelectorAll('#streamRows .stream-row')];",
            "const search=document.getElementById('streamSearch');",
            "const buttons=[...document.querySelectorAll('[data-filter]')];",
            "let filter='all';",
            "function matchesFilter(row){const cat=row.dataset.category||'';const status=row.dataset.status||'';if(filter==='all')return true;if(filter==='strong')return status.includes('strong')||cat.includes('audio-');if(filter==='resource')return cat.includes('pointer');if(filter==='opcode')return cat.includes('opcode-like-low-byte');return true;}",
            "function renderFilter(){const q=(search.value||'').trim().toLowerCase();let visible=0;rows.forEach(row=>{const ok=matchesFilter(row)&&(!q||(row.dataset.text||'').includes(q));row.classList.toggle('hidden-row',!ok);if(ok)visible+=1;});document.getElementById('visibleCount').textContent=`${visible}/${rows.length} rows`;}",
            "search.addEventListener('input',renderFilter);buttons.forEach(btn=>btn.addEventListener('click',()=>{filter=btn.dataset.filter;buttons.forEach(b=>b.classList.toggle('active',b===btn));renderFilter();}));renderFilter();",
            "window.HWANSE_OPENING_OPCODE_REVIEW_READY = true;",
            "window.HWANSE_LAST_OPENING_OPCODE_REVIEW = { openingOpcodeReviewImplemented: true, openingOpcodeReviewRange: '0x004a2d38..0x004a3dec', openingOpcodeReviewRowCount: rows.length, openingMlk10Candidate: '0x0a003026', openingWlk35Candidate: '0x23000124', openingWlk35CandidateCount: 2, openingActionDirectPointersFound: false, openingOpcodeExecutionPromoted: false };",
            "</script>",
            "</main></body></html>",
        ]
    )


def write_outputs(summary: dict[str, Any]) -> None:
    OUT.mkdir(parents=True, exist_ok=True)
    (OUT / "opening_opcode_review.json").write_text(
        json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    (OUT / "opening_opcode_review.html").write_text(html_page(summary), encoding="utf-8")


def main() -> None:
    summary = build_summary()
    write_outputs(summary)
    print(f"wrote opening opcode review -> {OUT / 'opening_opcode_review.html'}")


if __name__ == "__main__":
    main()
