#!/usr/bin/env python3
"""Build a focused review for cancel/menu input to window-region consumers.

This report is intentionally narrow.  It records what is currently grounded:

* keyboard action packing produces current/edge action masks,
* action 9 / bit 0x0200 is the cancel/back action,
* opcode 0x9f consumes that edge bit and mutates the menu stack,
* window regions are drawn by opcode 0x45 through the region renderer.

The still-missing proof is also kept explicit: the exact normal-field ESC/X
script that opens the status/menu screen has not been isolated yet.
"""
from __future__ import annotations

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]
EXE = ROOT / "Hwanse2.exe"
OUT = ROOT / "out"
WEB = ROOT / "web"

JSON_OUT = OUT / "menu_cancel_window_consumer_review.json"
HTML_OUT = WEB / "menu_cancel_window_consumer_review.html"
WEB_HTML_OUT = WEB / "menu_cancel_window_consumer_review.html"

IMAGE_BASE = 0x400000

VM_HANDLER_TABLE_VA = 0x00440538
INPUT_PACKER_VA = 0x0042F6D2
INPUT_POLL_DRIVER_VA = 0x00422D74
CURRENT_ACTION_MASK_VA = 0x0059E310
EDGE_ACTION_MASK_VA = 0x0059E312
PREVIOUS_ACTION_LATCH_VA = 0x0055B2AC

REGION_RECT_TABLE_VA = 0x004548B0
REGION_RESOURCE_TABLE_VA = 0x00454B60
REGION_DISPATCH_TABLE_VA = 0x00454C10
REGION_DRAW_FUNCTION_VA = 0x0041B579
TEMPLATE_BLIT_FUNCTION_VA = 0x004175D3

MENU_STACK_CURSOR_VA = 0x0059E33E
MENU_STACK_POINTERS_VA = 0x0059DB30
MENU_ACTIVE_COUNT_VA = 0x004576E8
MENU_ACTIVE_IDS_VA = 0x004576E9
MENU_ENTRY_TABLE_VA = 0x00457750


SELECTED_OPCODES = {
    0x40: "bind current action mask as VM input source",
    0x44: "conditional draw region 0",
    0x45: "draw region id from command byte",
    0x80: "bind menu stack slot to entry table row",
    0x88: "direction/list selector movement",
    0x99: "rebuild menu stack from active ids",
    0x9F: "cancel/back menu stack controller",
}


KNOWN_FUNCTIONS = [
    ("vm-dispatch-loop", 0x00402321, 0x00402390),
    ("opcode-0x44-region0", 0x004064F4, 0x00406528),
    ("opcode-0x45-region-id", 0x00406528, 0x00406558),
    ("opcode-0x80-stack-bind", 0x0040AD5E, 0x0040ADC9),
    ("opcode-0x88-list-direction", 0x0040B13B, 0x0040B27A),
    ("opcode-0x99-stack-rebuild", 0x0040C2FC, 0x0040C3F6),
    ("opcode-0x9f-cancel-stack", 0x0040C6CC, 0x0040C93D),
    ("menu/list-template-blitter", 0x0040F78A, 0x0040FA74),
    ("region-draw-function", 0x0041B579, 0x0041B6A0),
    ("region-object-draw-bridge", 0x0041BB4C, 0x0041BC05),
    ("choice/list-cancel-handler", 0x0041D61B, 0x0041D6B6),
    ("input-poll-driver", 0x00422D74, 0x00422DDA),
    ("input-action-packer", 0x0042F6D2, 0x0042F793),
    ("field-controller-direction", 0x0043022D, 0x00430323),
    ("menu-stack-add-entry", 0x00431FE8, 0x004324B0),
    ("menu-stack-remove-entry", 0x00432541, 0x00432812),
]


def hx(value: int | None, width: int = 8) -> str:
    return "-" if value is None else f"0x{value:0{width}x}"


def h(value: Any) -> str:
    return html.escape("" if value is None else str(value))


def u32(exe: bytes, sections: list[dict[str, Any]], va: int) -> int:
    off = va_to_offset(sections, va)
    if off is None:
        raise ValueError(f"VA outside file-backed sections: {hx(va)}")
    return struct.unpack_from("<I", exe, off)[0]


def read_bytes(exe: bytes, sections: list[dict[str, Any]], va: int, size: int) -> bytes:
    off = va_to_offset(sections, va)
    if off is None:
        return b""
    return exe[off : off + size]


def byte_hex(data: bytes, max_len: int = 32) -> str:
    if len(data) > max_len:
        data = data[:max_len]
        return " ".join(f"{b:02x}" for b in data) + " ..."
    return " ".join(f"{b:02x}" for b in data)


def text_sections(sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return [s for s in sections if s.get("name") == ".text"]


def script_data_sections(sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return [s for s in sections if s.get("name") in {".data", ".rdata"}]


def find_call_sites(exe: bytes, sections: list[dict[str, Any]], target_va: int) -> list[int]:
    hits: list[int] = []
    for section in text_sections(sections):
        start = section["raw"]
        end = start + section["raw_size"]
        off = start
        while off < end - 5:
            if exe[off] == 0xE8:
                rel = struct.unpack_from("<i", exe, off + 1)[0]
                call_va = offset_to_va(sections, off)
                if call_va is not None and call_va + 5 + rel == target_va:
                    hits.append(call_va)
            off += 1
    return hits


def find_dword_refs(exe: bytes, sections: list[dict[str, Any]], value: int) -> list[int]:
    needle = struct.pack("<I", value)
    refs: list[int] = []
    for section in text_sections(sections):
        start = section["raw"]
        end = start + section["raw_size"]
        pos = exe.find(needle, start, end)
        while pos != -1:
            va = offset_to_va(sections, pos)
            if va is not None:
                refs.append(va)
            pos = exe.find(needle, pos + 1, end)
    return refs


def find_byte_pattern(exe: bytes, sections: list[dict[str, Any]], pattern: bytes) -> list[int]:
    return find_byte_pattern_in_sections(exe, text_sections(sections), pattern)


def find_byte_pattern_in_sections(
    exe: bytes,
    sections_to_scan: list[dict[str, Any]],
    pattern: bytes,
) -> list[int]:
    hits: list[int] = []
    for section in sections_to_scan:
        start = section["raw"]
        end = start + section["raw_size"]
        pos = exe.find(pattern, start, end)
        while pos != -1:
            hits.append(int(section["va"]) + (pos - start))
            pos = exe.find(pattern, pos + 1, end)
    return hits


def containing_function(va: int) -> dict[str, Any]:
    for name, start, end in KNOWN_FUNCTIONS:
        if start <= va < end:
            return {"name": name, "startVa": start, "startVaHex": hx(start), "endVaHex": hx(end)}
    starts = [row for row in KNOWN_FUNCTIONS if row[1] <= va]
    if starts:
        name, start, end = starts[-1]
        return {"name": f"near {name}", "startVa": start, "startVaHex": hx(start), "endVaHex": hx(end)}
    return {"name": "unknown", "startVa": None, "startVaHex": "-", "endVaHex": "-"}


def read_handler_table(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows = []
    for opcode, role in SELECTED_OPCODES.items():
        handler = u32(exe, sections, VM_HANDLER_TABLE_VA + opcode * 4)
        rows.append(
            {
                "opcode": opcode,
                "opcodeHex": hx(opcode, 2),
                "handlerVa": handler,
                "handlerVaHex": hx(handler),
                "role": role,
                "handlerBytes": byte_hex(read_bytes(exe, sections, handler, 24)),
            }
        )
    return rows


def read_region_bindings(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    count = (REGION_RESOURCE_TABLE_VA - REGION_RECT_TABLE_VA) // 16
    rows = []
    interesting = {1, 2, 3, 4, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 39, 40, 42}
    for index in range(count):
        rect_va = REGION_RECT_TABLE_VA + index * 16
        off = va_to_offset(sections, rect_va)
        if off is None:
            continue
        x0, y0, x1, y1 = struct.unpack_from("<4I", exe, off)
        resource_id = u32(exe, sections, REGION_RESOURCE_TABLE_VA + index * 4)
        template_index = resource_id & 0xFFFF
        group = resource_id >> 16
        coords = (x0, y0, x1, y1)
        if coords == (0, 352, 416, 480):
            role = "normal HUD left panel"
            status = "grounded"
        elif coords == (416, 352, 640, 480):
            role = "normal HUD right panel"
            status = "grounded"
        elif index == 6 and coords == (0, 0, 416, 352):
            role = "top menu large left window candidate"
            status = "grounded-region / consumer pending"
        elif index == 3 and coords == (416, 0, 640, 96):
            role = "top menu upper-right window candidate"
            status = "grounded-region / consumer pending"
        elif index == 4 and coords == (416, 96, 640, 352):
            role = "top menu lower-right window candidate"
            status = "grounded-region / consumer pending"
        elif group == 0x0009:
            role = f"window.cns template #{template_index}"
            status = "window-region"
        else:
            role = "non-window or support region"
            status = "support"
        if index in interesting or status.startswith("grounded"):
            rows.append(
                {
                    "index": index,
                    "rectVaHex": hx(rect_va),
                    "resourceVaHex": hx(REGION_RESOURCE_TABLE_VA + index * 4),
                    "x": x0,
                    "y": y0,
                    "w": x1 - x0,
                    "h": y1 - y0,
                    "x1": x1,
                    "y1": y1,
                    "resourceIdHex": hx(resource_id),
                    "resourceGroupHex": hx(group, 4),
                    "templateIndex": template_index,
                    "role": role,
                    "status": status,
                }
            )
    return rows


def build_payload() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)

    handler_rows = read_handler_table(exe, sections)
    region_rows = read_region_bindings(exe, sections)
    top_regions = [row for row in region_rows if row["index"] in {6, 3, 4}]
    normal_regions = [row for row in region_rows if row["index"] in {1, 2}]

    action_tables = json.loads((OUT / "input_keymap.json").read_text(encoding="utf-8"))
    primary_action9 = next(
        row for row in action_tables["tables"][0]["actions"] if row["index"] == 9
    )

    call_targets = [
        ("input action packer", INPUT_PACKER_VA),
        ("region draw function", REGION_DRAW_FUNCTION_VA),
        ("template blit function", TEMPLATE_BLIT_FUNCTION_VA),
    ]
    call_site_rows = []
    for label, target in call_targets:
        call_sites = find_call_sites(exe, sections, target)
        call_site_rows.append(
            {
                "label": label,
                "targetVaHex": hx(target),
                "callCount": len(call_sites),
                "callSites": [
                    {
                        "callVaHex": hx(va),
                        "function": containing_function(va)["name"],
                    }
                    for va in call_sites[:24]
                ],
            }
        )

    global_refs = []
    for label, va in [
        ("current action mask", CURRENT_ACTION_MASK_VA),
        ("edge/new-press action mask", EDGE_ACTION_MASK_VA),
        ("previous action latch", PREVIOUS_ACTION_LATCH_VA),
        ("menu stack cursor", MENU_STACK_CURSOR_VA),
        ("menu stack pointer array", MENU_STACK_POINTERS_VA),
        ("active menu entry count", MENU_ACTIVE_COUNT_VA),
        ("active menu entry ids", MENU_ACTIVE_IDS_VA),
        ("menu entry table", MENU_ENTRY_TABLE_VA),
    ]:
        refs = find_dword_refs(exe, sections, va)
        global_refs.append(
            {
                "label": label,
                "vaHex": hx(va),
                "textRefCount": len(refs),
                "sampleRefs": [
                    {
                        "refVaHex": hx(ref),
                        "function": containing_function(ref)["name"],
                    }
                    for ref in refs[:24]
                ],
            }
        )

    cancel_tests = []
    for va in find_byte_pattern(exe, sections, b"\xf6\xc4\x02"):
        fn = containing_function(va)
        cancel_tests.append(
            {
                "testVaHex": hx(va),
                "function": fn["name"],
                "functionStartHex": fn["startVaHex"],
                "meaning": "tests AH bit 0x02, i.e. action bit 0x0200 when AX is the 16-bit input mask",
                "bytes": byte_hex(read_bytes(exe, sections, va - 6, 16)),
            }
        )

    stack_mutations = []
    mutation_patterns = [
        ("inc byte ptr [0x0059e33e]", b"\xfe\x05" + struct.pack("<I", MENU_STACK_CURSOR_VA)),
        ("dec byte ptr [0x0059e33e]", b"\xfe\x0d" + struct.pack("<I", MENU_STACK_CURSOR_VA)),
        ("mov byte ptr [0x0059e33e], 0", b"\xc6\x05" + struct.pack("<I", MENU_STACK_CURSOR_VA) + b"\x00"),
    ]
    for label, pattern in mutation_patterns:
        hits = find_byte_pattern(exe, sections, pattern)
        stack_mutations.append(
            {
                "mutation": label,
                "hitCount": len(hits),
                "hits": [
                    {
                        "vaHex": hx(hit),
                        "function": containing_function(hit)["name"],
                    }
                    for hit in hits
                ],
            }
        )

    stream_probe_patterns = [
        {"label": "script-data opcode draw top-left candidate 45 06", "pattern": "45 06", "hits": 0},
        {"label": "script-data opcode draw right-top candidate 45 03", "pattern": "45 03", "hits": 0},
        {"label": "script-data opcode draw right-bottom candidate 45 04", "pattern": "45 04", "hits": 0},
    ]
    for row in stream_probe_patterns:
        pattern = bytes.fromhex(row["pattern"])
        hits = find_byte_pattern_in_sections(exe, script_data_sections(sections), pattern)
        row["hits"] = len(hits)
        row["sampleHitsHex"] = [hx(hit) for hit in hits[:16]]

    payload = {
        "status": "cancel/menu window consumer static review",
        "summary": {
            "inputPackerVaHex": hx(INPUT_PACKER_VA),
            "inputPollDriverVaHex": hx(INPUT_POLL_DRIVER_VA),
            "currentActionMaskVaHex": hx(CURRENT_ACTION_MASK_VA),
            "edgeActionMaskVaHex": hx(EDGE_ACTION_MASK_VA),
            "cancelAction": {
                "index": primary_action9["index"],
                "bitHex": primary_action9["bitHex"],
                "keys": [key["name"] for key in primary_action9["keys"]],
            },
            "cancelConsumerOpcode": "0x9f",
            "cancelConsumerHandlerVaHex": next(row["handlerVaHex"] for row in handler_rows if row["opcode"] == 0x9F),
            "regionDrawOpcode": "0x45",
            "regionDrawHandlerVaHex": next(row["handlerVaHex"] for row in handler_rows if row["opcode"] == 0x45),
            "normalHudRegionIndexes": [row["index"] for row in normal_regions],
            "topMenuRegionCandidateIndexes": [row["index"] for row in top_regions],
            "promotion": "grounded stack path; direct normal-field menu-open script still pending",
        },
        "inputTables": {
            "action9": primary_action9,
            "notes": [
                "0x00422d74 calls the action packer, stores current mask at 0x0059e310, and stores edge/new-press mask at 0x0059e312.",
                "Movement/controller code uses current direction bits; menu cancel/back checks the edge mask so holding the key does not repeatedly trigger the same menu transition.",
            ],
        },
        "opcodeHandlers": handler_rows,
        "regionBindings": region_rows,
        "callSites": call_site_rows,
        "globalRefs": global_refs,
        "cancelBitTests": cancel_tests,
        "stackMutations": stack_mutations,
        "streamProbePatterns": stream_probe_patterns,
        "findings": [
            {
                "item": "cancel/back action",
                "status": "grounded",
                "evidence": "primary action 9 maps ESCAPE, NUMPAD_INSERT, X and state offsets to bit 0x0200.",
            },
            {
                "item": "cancel/back consumer",
                "status": "grounded",
                "evidence": "opcode 0x9f handler 0x0040c6cc tests AH bit 0x02 from edge mask 0x0059e312 and mutates menu stack cursor 0x0059e33e.",
            },
            {
                "item": "menu stack globals",
                "status": "grounded",
                "evidence": "0x4576e8/0x4576e9 hold active menu entry ids; 0x59db30 holds entry pointers; 0x59e33e is the current cursor. Add/remove helpers at 0x00431fe8/0x00432541 update them.",
            },
            {
                "item": "top menu window regions",
                "status": "grounded-region / consumer pending",
                "evidence": "region #6, #3, #4 form the 640x352 top menu candidate layout from window.cns templates, but the exact opener stream was not isolated.",
            },
            {
                "item": "raw region command sequence",
                "status": "blocked",
                "evidence": "direct script-data byte sequences 45 06 / 45 03 / 45 04 were not found; the top menu layout is likely selected through descriptor/object state rather than a simple contiguous region-opener stream.",
            },
        ],
        "nextTargets": [
            "Decode menu entry table rows at 0x00457750 so stack id -> text/window/content can be named.",
            "Trace opcode 0x80/0x99/0x9f roots together with region opcode 0x45 to find the normal-field status-menu opener.",
            "Use the known UI text strings around 0x004e8128..0x004e8872 as labels once the stack entry content table is isolated.",
        ],
    }
    return payload


def tag(status: str) -> str:
    cls = "good" if "grounded" in status and "pending" not in status else "warn"
    if "blocked" in status:
        cls = "bad"
    return f'<span class="tag {cls}">{h(status)}</span>'


def table(headers: list[str], rows: list[list[Any]]) -> str:
    head = "".join(f"<th>{h(col)}</th>" for col in headers)
    body = []
    for row in rows:
        body.append("<tr>" + "".join(f"<td>{cell}</td>" for cell in row) + "</tr>")
    return f"<table><thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody></table>"


def render_html(payload: dict[str, Any]) -> str:
    summary = payload["summary"]
    metrics = [
        ("cancel bit", summary["cancelAction"]["bitHex"]),
        ("consumer opcode", f"{summary['cancelConsumerOpcode']} @ {summary['cancelConsumerHandlerVaHex']}"),
        ("region opcode", f"{summary['regionDrawOpcode']} @ {summary['regionDrawHandlerVaHex']}"),
        ("top regions", ", ".join(str(i) for i in summary["topMenuRegionCandidateIndexes"])),
    ]

    handler_rows = table(
        ["opcode", "handler", "역할", "bytes"],
        [
            [
                f"<code>{h(row['opcodeHex'])}</code>",
                f"<code>{h(row['handlerVaHex'])}</code>",
                h(row["role"]),
                f"<code>{h(row['handlerBytes'])}</code>",
            ]
            for row in payload["opcodeHandlers"]
        ],
    )
    region_rows = table(
        ["#", "rect", "resource", "template", "해석", "상태"],
        [
            [
                str(row["index"]),
                f"<code>{row['x']},{row['y']} {row['w']}x{row['h']}</code>",
                f"<code>{h(row['resourceIdHex'])}</code>",
                str(row["templateIndex"]),
                h(row["role"]),
                tag(row["status"]),
            ]
            for row in payload["regionBindings"]
        ],
    )
    global_rows = table(
        ["global", "VA", "refs", "sample"],
        [
            [
                h(row["label"]),
                f"<code>{h(row['vaHex'])}</code>",
                str(row["textRefCount"]),
                "<br>".join(
                    f"<code>{h(ref['refVaHex'])}</code> {h(ref['function'])}"
                    for ref in row["sampleRefs"][:8]
                ),
            ]
            for row in payload["globalRefs"]
        ],
    )
    cancel_rows = table(
        ["test", "function", "meaning", "bytes"],
        [
            [
                f"<code>{h(row['testVaHex'])}</code>",
                h(row["function"]),
                h(row["meaning"]),
                f"<code>{h(row['bytes'])}</code>",
            ]
            for row in payload["cancelBitTests"]
        ],
    )
    mutation_rows = table(
        ["mutation", "hits", "where"],
        [
            [
                h(row["mutation"]),
                str(row["hitCount"]),
                "<br>".join(
                    f"<code>{h(hit['vaHex'])}</code> {h(hit['function'])}"
                    for hit in row["hits"]
                ),
            ]
            for row in payload["stackMutations"]
        ],
    )
    call_rows = table(
        ["target", "call count", "sample call sites"],
        [
            [
                f"{h(row['label'])}<br><code>{h(row['targetVaHex'])}</code>",
                str(row["callCount"]),
                "<br>".join(
                    f"<code>{h(site['callVaHex'])}</code> {h(site['function'])}"
                    for site in row["callSites"][:12]
                ),
            ]
            for row in payload["callSites"]
        ],
    )
    finding_rows = table(
        ["항목", "상태", "근거"],
        [
            [h(row["item"]), tag(row["status"]), h(row["evidence"])]
            for row in payload["findings"]
        ],
    )

    metric_html = "".join(
        f"<div class=\"metric\"><span>{h(label)}</span><strong>{h(value)}</strong></div>"
        for label, value in metrics
    )
    next_items = "".join(f"<li>{h(item)}</li>" for item in payload["nextTargets"])
    action_keys = ", ".join(summary["cancelAction"]["keys"])
    stream_probe = ", ".join(
        f"{row['pattern']}={row['hits']}" for row in payload["streamProbePatterns"]
    )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link rel="icon" href="../favicon.ico" />
  <title>메뉴 취소 입력/창 소비자 검토</title>
  <style>
    :root {{ color-scheme: light; --border:#d8dee6; --ink:#17202a; --muted:#607080; --panel:#fff; --head:#eef2f6; --bg:#f6f7f9; }}
    * {{ box-sizing:border-box; }}
    body {{ margin:0; background:var(--bg); color:var(--ink); font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; line-height:1.45; }}
    main {{ max-width:1500px; margin:0 auto; padding:18px; }}
    header {{ display:flex; justify-content:space-between; align-items:flex-start; gap:16px; margin-bottom:14px; }}
    h1 {{ margin:0; font-size:24px; }}
    h2 {{ margin:0; font-size:17px; }}
    a {{ color:#185abc; font-weight:700; text-decoration:none; }}
    a:hover {{ text-decoration:underline; }}
    nav {{ display:flex; flex-wrap:wrap; gap:10px; justify-content:flex-end; }}
    section {{ background:var(--panel); border:1px solid var(--border); border-radius:8px; margin:14px 0; overflow:hidden; }}
    .head {{ display:flex; justify-content:space-between; gap:12px; padding:12px 14px; background:var(--head); border-bottom:1px solid var(--border); }}
    .body {{ padding:14px; overflow:auto; }}
    .metrics {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(210px,1fr)); gap:10px; }}
    .metric {{ border:1px solid var(--border); border-radius:6px; background:#f8fafc; padding:10px; }}
    .metric span {{ display:block; color:var(--muted); font-size:12px; }}
    .metric strong {{ display:block; font-size:18px; margin-top:4px; }}
    table {{ width:100%; border-collapse:collapse; min-width:760px; }}
    th,td {{ padding:8px 10px; border-bottom:1px solid var(--border); vertical-align:top; text-align:left; font-size:13px; }}
    th {{ background:#f8fafc; color:#344050; }}
    code {{ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }}
    .tag {{ display:inline-block; padding:2px 7px; border-radius:999px; background:#edf2f7; color:#334155; font-size:12px; white-space:nowrap; }}
    .tag.good {{ color:#0f766e; background:#e6f4f1; }}
    .tag.warn {{ color:#a15c00; background:#fff4df; }}
    .tag.bad {{ color:#b42318; background:#fdebea; }}
    .muted {{ color:var(--muted); }}
    @media (max-width:760px) {{ header {{ display:block; }} nav {{ justify-content:flex-start; margin-top:10px; }} }}
  </style>
</head>
<body>
<main data-page="menu-cancel-window-consumer-review">
  <header>
    <div>
      <h1>메뉴 취소 입력/창 소비자 검토</h1>
      <p class="muted">ESC/X/Num0 입력이 상태창/메뉴 window region으로 이어지는 정적 근거를 분리한다.</p>
    </div>
    <nav aria-label="review links">
      <a href="index.html">관리 홈</a>
      <a href="input_handler_review.html">입력 검토</a>
      <a href="ui_window_review.html">창/프레임</a>
      <a href="menu_descriptor_stack_review.html">Descriptor stack</a>
      <a href="scene_event_vm_review.html">Scene/Event VM</a>
      <a href="../out/menu_cancel_window_consumer_review.json">JSON</a>
    </nav>
  </header>

  <section>
    <div class="head"><h2>요약</h2><span class="muted">{h(summary['promotion'])}</span></div>
    <div class="body">
      <div class="metrics">{metric_html}</div>
      <p class="muted">취소 액션 키: {h(action_keys)}. script-data region opcode probe: {h(stream_probe)}.</p>
    </div>
  </section>

  <section><div class="head"><h2>판정</h2></div><div class="body">{finding_rows}</div></section>
  <section><div class="head"><h2>VM Opcode</h2></div><div class="body">{handler_rows}</div></section>
  <section><div class="head"><h2>Window Regions</h2><span class="muted">top menu 후보 #6/#3/#4 포함</span></div><div class="body">{region_rows}</div></section>
  <section><div class="head"><h2>Input/Stack Globals</h2></div><div class="body">{global_rows}</div></section>
  <section><div class="head"><h2>Cancel Bit Tests</h2></div><div class="body">{cancel_rows}</div></section>
  <section><div class="head"><h2>Stack Cursor Mutations</h2></div><div class="body">{mutation_rows}</div></section>
  <section><div class="head"><h2>Call Sites</h2></div><div class="body">{call_rows}</div></section>
  <section><div class="head"><h2>다음 분석 타겟</h2></div><div class="body"><ul>{next_items}</ul></div></section>

  <script>
    window.HWANSE_MENU_CANCEL_WINDOW_CONSUMER_REVIEW_READY = {json.dumps(summary, ensure_ascii=False)};
  </script>
</main>
</body>
</html>
"""


def main() -> None:
    payload = build_payload()
    OUT.mkdir(parents=True, exist_ok=True)
    WEB.mkdir(parents=True, exist_ok=True)
    JSON_OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    html_text = render_html(payload)
    HTML_OUT.write_text(html_text, encoding="utf-8")
    WEB_HTML_OUT.write_text(html_text, encoding="utf-8")
    print(f"wrote {JSON_OUT.relative_to(ROOT)}")
    print(f"wrote {WEB_HTML_OUT.relative_to(ROOT)}")


if __name__ == "__main__":
    main()
