#!/usr/bin/env python3
"""Build the focused scene/event VM goal review artifacts.

This intentionally summarizes already-grounded evidence instead of trying to
finish the whole event VM.  The goal is to keep confirmed/grounded opcode
semantics separate from selector/proximity candidates such as the first
map1_01a text flow.
"""
from __future__ import annotations

import argparse
import html
import json
from collections import Counter
from pathlib import Path
from typing import Any


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


def load_json(path: Path, default: Any) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return default


def write_json(path: Path, payload: Any) -> None:
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def write_text(path: Path, text: str) -> None:
    path.write_text(text, encoding="utf-8")


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


def short_text(value: Any, limit: int = 120) -> str:
    if isinstance(value, (dict, list)):
        value = json.dumps(value, ensure_ascii=False, sort_keys=True)
    value = " ".join((str(value) if value is not None else "").split())
    return value if len(value) <= limit else value[: limit - 1] + "…"


def semantic_level(row: dict[str, Any]) -> str:
    status = row.get("semanticStatus") or ""
    if status == "literal-storage-marker":
        return "partial"
    if "grounded" in status or status == "text-source-opcode-grounded":
        return "grounded"
    return "partial"


def dictionary_rows(
    script_handler_table: dict[str, Any],
    event_vm_opcode_semantics: dict[str, Any],
    event_dialogue_blocks: dict[str, Any],
    event_text_source_flow: dict[str, Any],
) -> list[dict[str, Any]]:
    semantic_by_opcode = {
        row.get("opcodeHex"): row
        for row in event_vm_opcode_semantics.get("opcodeSemantics", [])
        if row.get("opcodeHex")
    }
    dialogue_counts = event_dialogue_blocks.get("commandOpcodeCounts") or {}
    text_counts = event_text_source_flow.get("commandCountsByOpcode") or {}
    rows: list[dict[str, Any]] = []
    for entry in script_handler_table.get("entries", []):
        opcode_hex = entry.get("opcodeHex")
        semantic = semantic_by_opcode.get(opcode_hex)
        command_count = (
            (semantic or {}).get("commandCount")
            or dialogue_counts.get(opcode_hex)
            or text_counts.get(opcode_hex)
            or 0
        )
        if semantic:
            level = semantic_level(semantic)
            role = semantic.get("semanticStatus")
            length = semantic.get("decodedLengthHex") or "-"
            operand = semantic.get("operandLayout") or "-"
            effect = semantic.get("effect") or "-"
            source = semantic.get("source") or "out/event_vm_opcode_semantics.json"
            next_step = semantic.get("promotionImpact") or "keep as opcode-level evidence"
        elif entry.get("isDefaultHandler"):
            level = "candidate"
            role = "default-handler-or-operand-byte"
            length = "-"
            operand = "unresolved; default handler rows often represent data/operand low bytes"
            effect = "-"
            source = "out/script_handler_table.json"
            next_step = "do not promote without command-start proof"
        elif not entry.get("isCodeHandler"):
            level = "candidate"
            role = "non-code-handler-target"
            length = "-"
            operand = "handler target is outside .text or points into data"
            effect = "-"
            source = "out/script_handler_table.json"
            next_step = "likely table/data byte; keep out of default VM surface"
        else:
            level = "partial"
            role = "handler-grounded-effect-unknown"
            length = "-"
            operand = "handler exists but operand layout/effect is not summarized"
            effect = entry.get("streamEffect") or "-"
            source = "out/script_handler_table.json"
            next_step = "decode handler reads/writes only if this opcode appears in a target scene trace"
        rows.append(
            {
                "opcodeHex": opcode_hex,
                "handlerEntryVaHex": entry.get("entryVaHex"),
                "handlerVaHex": (semantic or {}).get("handlerVaHex") or entry.get("handlerVaHex"),
                "handlerSection": entry.get("handlerSection"),
                "referenceCount": entry.get("referenceCount", 0),
                "commandCount": command_count,
                "evidenceLevel": level,
                "role": role,
                "decodedLength": length,
                "operandLayout": operand,
                "effect": effect,
                "source": source,
                "sourceFamily": "event-object-vm" if semantic else "save-selector-leaf-stream",
                "nextStep": next_step,
                "browserReplayEnabled": bool((semantic or {}).get("browserReplayEnabled")),
                "isDefaultHandler": bool(entry.get("isDefaultHandler")),
                "isCodeHandler": bool(entry.get("isCodeHandler")),
            }
        )
    # Include every event/object VM semantic row that is not present in the
    # save-selector leaf-stream table.  These are separate evidence families:
    # the handler table is useful inventory data, while the semantic table
    # contains the currently grounded event/object VM opcodes.
    present_opcodes = {row["opcodeHex"] for row in rows}
    for opcode_hex, semantic in semantic_by_opcode.items():
        if opcode_hex in present_opcodes:
            continue
        rows.append(
            {
                "opcodeHex": opcode_hex,
                "handlerEntryVaHex": None,
                "handlerVaHex": semantic.get("handlerVaHex"),
                "handlerSection": semantic.get("handlerSection"),
                "referenceCount": 0,
                "commandCount": semantic.get("commandCount", 0),
                "evidenceLevel": semantic_level(semantic),
                "role": semantic.get("semanticStatus"),
                "decodedLength": semantic.get("decodedLengthHex") or "-",
                "operandLayout": semantic.get("operandLayout") or "-",
                "effect": semantic.get("effect") or "-",
                "source": semantic.get("source") or "out/event_vm_opcode_semantics.json",
                "sourceFamily": "event-object-vm",
                "nextStep": semantic.get("promotionImpact") or "storage evidence only",
                "browserReplayEnabled": bool(semantic.get("browserReplayEnabled")),
                "isDefaultHandler": False,
                "isCodeHandler": True,
            }
        )
    return sorted(rows, key=lambda row: int((row.get("opcodeHex") or "0x100")[2:], 16))


def build_story_row_index(story_flow_review: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
    index: dict[str, list[dict[str, Any]]] = {}
    for row in story_flow_review.get("rows", []):
        keys = {
            row.get("displayText") or "",
            f"start:{row.get('startVaHex')}",
            f"render:{row.get('renderVaHex')}",
        }
        for key in keys:
            if key and key != "start:None" and key != "render:None":
                index.setdefault(key, []).append(row)
    return index


def matching_story_row(prompt: dict[str, Any], index: dict[str, list[dict[str, Any]]]) -> dict[str, Any] | None:
    display = prompt.get("displayText") or ""
    start = prompt.get("startVaHex")
    for row in index.get(display, []):
        return row
    if start:
        candidates = index.get(f"start:{start}", [])
        if len(candidates) == 1:
            return candidates[0]
        for row in candidates:
            row_text = row.get("displayText") or ""
            if display and (display in row_text or row_text in display):
                return row
    return None


def line_kinds(text: str, status: str) -> dict[str, int]:
    counts: Counter[str] = Counter()
    for line in (text or "").splitlines():
        stripped = line.strip().lstrip("\u3000")
        if not stripped:
            continue
        if status == "choice-marker-delimited":
            counts["choice"] += 1
        elif stripped in {"아타호", "린샹", "스마슈", "백호권 사범", "암각권 총통"}:
            counts["speaker"] += 1
        elif stripped.startswith("「"):
            counts["dialogue"] += 1
        else:
            counts["text"] += 1
    return dict(counts)


def build_map1_trace(
    scene_manifest: list[dict[str, Any]],
    scene_script_player_data: dict[str, Any],
    story_flow_review: dict[str, Any],
    cluster_context: dict[str, Any],
    opcode_levels: dict[str, str] | None = None,
) -> dict[str, Any]:
    map_rows = [row for row in scene_manifest if row.get("map") == "map1_01a"]
    map_index = scene_script_player_data.get("mapIndex", {}).get("map1_01a", [])
    groups = {group.get("id"): group for group in scene_script_player_data.get("groups", [])}
    chosen_index = None
    for item in map_index:
        sample = item.get("sample") or ""
        if item.get("contextLabel") == "selector 0:0" and "여긴 호랑이권법가" in sample:
            chosen_index = item
            break
    if chosen_index is None and map_index:
        chosen_index = map_index[0]
    chosen_group = groups.get((chosen_index or {}).get("groupId")) or {}
    chosen_sequence = None
    for seq in chosen_group.get("sequences", []):
        if seq.get("id") == (chosen_index or {}).get("sequenceId"):
            chosen_sequence = seq
            break
    if chosen_sequence is None and chosen_group.get("sequences"):
        chosen_sequence = chosen_group["sequences"][0]
    story_index = build_story_row_index(story_flow_review)
    prompt_rows = []
    command_trace_rows = []
    for order, prompt in enumerate((chosen_sequence or {}).get("prompts", [])[:32], start=1):
        matched = matching_story_row(prompt, story_index)
        status = prompt.get("status") or ""
        text = prompt.get("displayText") or ""
        trace = (matched or {}).get("trace") or []
        for command_order, command in enumerate(trace, start=1):
            if len(command_trace_rows) >= 160:
                break
            opcode_hex = command.get("opcodeHex")
            command_trace_rows.append(
                {
                    "promptOrder": order,
                    "promptId": prompt.get("id"),
                    "commandOrderInPrompt": command_order,
                    "vaHex": command.get("vaHex"),
                    "opcodeHex": opcode_hex,
                    "opcodeEvidenceLevel": (opcode_levels or {}).get(opcode_hex, "candidate"),
                    "label": command.get("label") or "-",
                    "decodedLength": command.get("decodedLength"),
                    "rawBytes": command.get("rawBytes"),
                    "roles": command.get("roles") or [],
                    "routeEvidenceLevel": "partial" if status == "choice-marker-delimited" else "candidate",
                }
            )
        prompt_rows.append(
            {
                "order": order,
                "promptId": prompt.get("id"),
                "matchedStoryPromptId": (matched or {}).get("id"),
                "status": status,
                "evidenceLevel": "partial" if status == "choice-marker-delimited" else "candidate",
                "startVaHex": prompt.get("startVaHex") or (matched or {}).get("startVaHex"),
                "renderVaHex": prompt.get("renderVaHex") or (matched or {}).get("renderVaHex"),
                "waitVaHex": prompt.get("waitVaHex") or (matched or {}).get("waitVaHex"),
                "displayText": text,
                "lineKindCounts": line_kinds(text, status),
                "trace": trace[:8],
                "traceOpcodeSet": sorted({row.get("opcodeHex") for row in trace if row.get("opcodeHex")}),
                "note": (
                    "choice split from scene text sequence data; branch result remains candidate"
                    if status == "choice-marker-delimited"
                    else "text sequence candidate tied to selector-root range, not direct scene execution proof"
                ),
            }
        )
    scene_records = []
    source_records = {
        row.get("recordVaHex"): row for row in cluster_context.get("sourceSceneRecords", [])
    }
    for row in map_rows:
        cluster = source_records.get(row.get("recordVaHex"), {})
        scene_records.append(
            {
                "recordVaHex": row.get("recordVaHex"),
                "sceneIdHex": row.get("sceneIdHex"),
                "tilesets": row.get("tilesets") or [],
                "sprites": row.get("sprites") or [],
                "resourceSource": row.get("resourceSource"),
                "clusterClassification": cluster.get("classification") or "unclassified",
                "role": cluster.get("role") or "-",
                "hasStrictEventRecord": bool(cluster.get("hasStrictEventRecord")),
                "saveSelectorRefCount": cluster.get("saveSelectorRefCount"),
                "currentFrontierPairCount": cluster.get("currentFrontierPairCount"),
            }
        )
    return {
        "map": "map1_01a",
        "goalStatus": "partial",
        "evidenceLevel": "partial",
        "selectedGroupId": (chosen_group or {}).get("id"),
        "selectedSequenceId": (chosen_sequence or {}).get("id"),
        "selectedContextLabel": (chosen_group or {}).get("contextLabel"),
        "selectedRootVaHex": (chosen_group or {}).get("rootVaHex"),
        "selectedRootEndVaHex": (chosen_group or {}).get("rootEndVaHex"),
        "selectedEvidenceStatus": (chosen_group or {}).get("evidenceStatus"),
        "fieldMaps": (chosen_group or {}).get("fieldMaps") or [],
        "mapRefs": (chosen_group or {}).get("mapRefs") or [],
        "sceneRecords": scene_records,
        "promptsShown": len(prompt_rows),
        "commandTraceRowsShown": len(command_trace_rows),
        "promptCountInSequence": (chosen_sequence or {}).get("promptCount"),
        "choiceCountInSequence": (chosen_sequence or {}).get("choiceCount"),
        "prompts": prompt_rows,
        "commandTraceRows": command_trace_rows,
        "conclusion": (
            "The early Ataho cave text is available as selector 0:0 / scene-seq-029-01 and starts with "
            "'여긴 호랑이권법가 아타호가 사는 곳!'.  This is useful for map1_01a review, but remains "
            "selector-root-range evidence rather than direct scene execution proof."
        ),
    }


def gap_rows(dictionary: list[dict[str, Any]], trace: dict[str, Any], event_vm_opcode_semantics: dict[str, Any], scene_prompt_probe: dict[str, Any]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for row in dictionary:
        if row["evidenceLevel"] in {"confirmed", "grounded"}:
            continue
        rows.append(
            {
                "id": f"opcode-{row['opcodeHex']}",
                "kind": "opcode",
                "status": row["evidenceLevel"],
                "subject": row["opcodeHex"],
                "reason": row["role"],
                "nextEvidenceNeeded": row["nextStep"],
            }
        )
    coverage = event_vm_opcode_semantics.get("coverage") or {}
    if not coverage.get("routeLinkedEventVmExecution"):
        rows.append(
            {
                "id": "route-linked-event-vm-execution",
                "kind": "execution-proof",
                "status": "gap",
                "subject": "route-linked event VM execution",
                "reason": "opcode semantics are grounded, but no normal map interaction proves the executed route path",
                "nextEvidenceNeeded": "runtime trace or strict executed command root for a normal map interaction",
            }
        )
    if not coverage.get("fullInstructionLengthFullyDecoded"):
        rows.append(
            {
                "id": "full-instruction-layout",
                "kind": "decoder",
                "status": "gap",
                "subject": "full instruction length/operand layout",
                "reason": "known text/control subset is decoded, but full VM instruction set is not",
                "nextEvidenceNeeded": "decode only opcodes that appear in the current target trace",
            }
        )
    if trace.get("selectedEvidenceStatus") != "confirmed":
        rows.append(
            {
                "id": "map1-01a-trace-binding",
                "kind": "map1_01a",
                "status": "partial",
                "subject": "map1_01a first scene trace",
                "reason": trace.get("selectedEvidenceStatus") or "selector/root candidate",
                "nextEvidenceNeeded": "direct scene dispatch root or runtime trace tying this selector sequence to map1_01a startup",
            }
        )
    if (scene_prompt_probe.get("promptTargetAddressCount") or 0) and "No direct" in (scene_prompt_probe.get("conclusion") or ""):
        rows.append(
            {
                "id": "scene-prompt-direct-reference",
                "kind": "prompt-binding",
                "status": "gap",
                "subject": "direct scene -> prompt pointer refs",
                "reason": scene_prompt_probe.get("conclusion"),
                "nextEvidenceNeeded": "consumer-side VM execution or a narrower scene command root",
            }
        )
    return rows


def build_summary(args: argparse.Namespace) -> dict[str, Any]:
    script_handler_table = load_json(args.script_handler_table, {})
    event_vm_opcode_semantics = load_json(args.event_vm_opcode_semantics, {})
    event_dialogue_blocks = load_json(args.event_dialogue_blocks, {})
    event_text_source_flow = load_json(args.event_text_source_flow, {})
    scene_manifest = load_json(args.scene_manifest, [])
    scene_script_player_data = load_json(args.scene_script_player_data, {})
    story_flow_review = load_json(args.story_flow_review, {})
    cluster_context = load_json(args.map1_cluster_context, {})
    scene_prompt_probe = load_json(args.scene_prompt_probe, {})
    rows = dictionary_rows(
        script_handler_table,
        event_vm_opcode_semantics,
        event_dialogue_blocks,
        event_text_source_flow,
    )
    opcode_levels = {row["opcodeHex"]: row["evidenceLevel"] for row in rows}
    trace = build_map1_trace(scene_manifest, scene_script_player_data, story_flow_review, cluster_context, opcode_levels)
    gaps = gap_rows(rows, trace, event_vm_opcode_semantics, scene_prompt_probe)
    counts = Counter(row["evidenceLevel"] for row in rows)
    return {
        "scope": "scene/event VM goal review grounded by docs/SCENE_EVENT_VM_GOAL.md",
        "promotionStatus": "partial-first-slice-complete-route-execution-missing",
        "doneCriteria": {
            "opcodeDictionaryGenerated": True,
            "groundedOpcodeCount": counts.get("grounded", 0),
            "groundedOpcodeMinimumMet": counts.get("grounded", 0) >= 10,
            "textWaitChoiceSeparated": True,
            "map1_01aTraceGenerated": bool(trace.get("prompts")),
            "confirmedAndCandidateSeparated": True,
            "gapListGenerated": bool(gaps),
            "webReviewExpected": True,
        },
        "sources": {
            "goalDoc": "docs/SCENE_EVENT_VM_GOAL.md",
            "scriptHandlerTable": str(args.script_handler_table),
            "eventVmOpcodeSemantics": str(args.event_vm_opcode_semantics),
            "sceneScriptPlayerData": str(args.scene_script_player_data),
            "storyFlowReview": str(args.story_flow_review),
        },
        "summary": {
            "opcodeRowCount": len(rows),
            "groundedOpcodeCount": counts.get("grounded", 0),
            "partialOpcodeCount": counts.get("partial", 0),
            "candidateOpcodeCount": counts.get("candidate", 0),
            "confirmedOpcodeCount": counts.get("confirmed", 0),
            "gapCount": len(gaps),
            "map1PromptRowsShown": trace.get("promptsShown", 0),
            "map1CommandRowsShown": trace.get("commandTraceRowsShown", 0),
            "map1ChoiceCountInSequence": trace.get("choiceCountInSequence", 0),
        },
        "opcodeDictionary": rows,
        "map1_01aTrace": trace,
        "gaps": gaps,
    }


def html_page(summary: dict[str, Any]) -> str:
    def chip(level: str) -> str:
        cls = {
            "confirmed": "good",
            "grounded": "good",
            "partial": "warn",
            "candidate": "muted",
            "gap": "bad",
        }.get(level, "muted")
        return f'<span class="tag {cls}">{h(level)}</span>'

    grounded_rows = [r for r in summary["opcodeDictionary"] if r["evidenceLevel"] in {"confirmed", "grounded"}]
    candidate_rows = [r for r in summary["opcodeDictionary"] if r["evidenceLevel"] not in {"confirmed", "grounded"}]
    opcode_rows = "\n".join(
        f"<tr><td><code>{h(r['opcodeHex'])}</code></td><td>{chip(r['evidenceLevel'])}</td><td><code>{h(r.get('handlerVaHex') or '-')}</code></td><td>{h(r.get('role'))}</td><td>{h(r.get('decodedLength'))}</td><td>{h(r.get('commandCount'))}</td><td>{h(r.get('effect'))}</td></tr>"
        for r in grounded_rows
    )
    candidate_html = "\n".join(
        f"<tr><td><code>{h(r['opcodeHex'])}</code></td><td>{chip(r['evidenceLevel'])}</td><td><code>{h(r.get('handlerVaHex') or '-')}</code></td><td>{h(r.get('role'))}</td><td>{h(r.get('nextStep'))}</td></tr>"
        for r in candidate_rows
    )
    prompt_rows = "\n".join(
        f"<tr><td>{p['order']}</td><td>{chip(p['evidenceLevel'])}</td><td>{h(p.get('status'))}</td><td><code>{h(p.get('startVaHex') or '-')}</code></td><td><code>{h(p.get('promptId') or '-')}</code></td><td><pre>{h(p.get('displayText') or '')}</pre><small>{h(p.get('note'))}</small></td><td>{', '.join(f'<code>{h(op)}</code>' for op in p.get('traceOpcodeSet', [])) or '-'}</td></tr>"
        for p in summary["map1_01aTrace"].get("prompts", [])
    )
    command_rows = "\n".join(
        f"<tr><td>{h(c.get('promptOrder'))}</td><td>{h(c.get('commandOrderInPrompt'))}</td><td>{chip(c.get('routeEvidenceLevel'))}</td><td>{chip(c.get('opcodeEvidenceLevel'))}</td><td><code>{h(c.get('vaHex') or '-')}</code></td><td><code>{h(c.get('opcodeHex') or '-')}</code></td><td>{h(c.get('label') or '-')}</td><td><code>{h(c.get('rawBytes') or '-')}</code></td><td>{h(', '.join(c.get('roles') or []))}</td></tr>"
        for c in summary["map1_01aTrace"].get("commandTraceRows", [])
    )
    gap_rows_html = "\n".join(
        f"<tr><td><code>{h(g['id'])}</code></td><td>{h(g['kind'])}</td><td>{chip(g['status'])}</td><td>{h(g.get('reason'))}</td><td>{h(g.get('nextEvidenceNeeded'))}</td></tr>"
        for g in summary["gaps"]
    )
    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>Scene/Event VM Goal Review</title>
  <style>
    body {{ margin:0; background:#f6f7f9; color:#17202a; font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; line-height:1.45; }}
    main {{ max-width:1500px; margin:0 auto; padding:18px; }}
    nav {{ display:flex; flex-wrap:wrap; gap:10px; margin:8px 0 16px; }}
    a {{ color:#185abc; text-decoration:none; font-weight:700; }}
    a:hover {{ text-decoration:underline; }}
    section, details {{ background:#fff; border:1px solid #d8dee6; border-radius:8px; margin:14px 0; overflow:hidden; }}
    h1 {{ margin:0; font-size:24px; }}
    h2 {{ margin:0; font-size:17px; }}
    .head {{ display:flex; justify-content:space-between; gap:12px; padding:12px 14px; background:#eef2f6; border-bottom:1px solid #d8dee6; }}
    .body {{ padding:14px; }}
    table {{ width:100%; border-collapse:collapse; }}
    th,td {{ padding:8px 10px; border-bottom:1px solid #d8dee6; vertical-align:top; text-align:left; font-size:13px; }}
    th {{ background:#f8fafc; color:#344050; }}
    pre {{ margin:0; white-space:pre-wrap; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }}
    summary {{ cursor:pointer; padding:12px 14px; background:#eef2f6; font-weight:800; }}
    .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; }}
    .tag.muted {{ color:#607080; background:#edf2f7; }}
    .metrics {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:10px; }}
    .metric {{ border:1px solid #d8dee6; border-radius:6px; background:#f8fafc; padding:10px; }}
    .metric strong {{ display:block; font-size:22px; }}
    .muted {{ color:#607080; }}
  </style>
</head>
<body>
<main data-page="scene-event-vm-review">
  <h1>Scene/Event VM Goal Review</h1>
  <nav>
    <a href="../web/index.html">관리 홈</a>
    <a href="../docs/SCENE_EVENT_VM_GOAL.md">goal 문서</a>
    <a href="scene_script_player.html">script player</a>
    <a href="scene_text_sequence_review.html">sequence evidence</a>
    <a href="event_vm_opcode_semantics.html">기존 opcode semantics</a>
    <a href="scene_event_vm_opcode_dictionary.json">JSON</a>
  </nav>
  <section>
    <div class="head"><h2>요약</h2><span>{h(summary['promotionStatus'])}</span></div>
    <div class="body metrics">
      <div class="metric"><strong>{summary['summary']['groundedOpcodeCount']}</strong><span>grounded opcodes</span></div>
      <div class="metric"><strong>{summary['summary']['opcodeRowCount']}</strong><span>dictionary rows</span></div>
      <div class="metric"><strong>{summary['summary']['map1PromptRowsShown']}</strong><span>map1_01a prompt rows shown</span></div>
      <div class="metric"><strong>{summary['summary']['map1CommandRowsShown']}</strong><span>map1_01a command rows shown</span></div>
      <div class="metric"><strong>{summary['summary']['gapCount']}</strong><span>gap rows</span></div>
    </div>
  </section>
  <section>
    <div class="head"><h2>Grounded Opcode Dictionary</h2><span>candidate hidden below</span></div>
    <table><thead><tr><th>opcode</th><th>level</th><th>handler</th><th>role</th><th>length</th><th>commands</th><th>effect</th></tr></thead><tbody>{opcode_rows}</tbody></table>
  </section>
  <details>
    <summary>후보/부분 opcode {len(candidate_rows)}개 보기</summary>
    <table><thead><tr><th>opcode</th><th>level</th><th>handler</th><th>role</th><th>next</th></tr></thead><tbody>{candidate_html}</tbody></table>
  </details>
  <section>
    <div class="head"><h2>map1_01a First Scene Trace Candidate</h2><span>{h(summary['map1_01aTrace'].get('selectedEvidenceStatus'))}</span></div>
    <div class="body">
      <p class="muted">{h(summary['map1_01aTrace'].get('conclusion'))}</p>
      <p>group <code>{h(summary['map1_01aTrace'].get('selectedGroupId'))}</code> · sequence <code>{h(summary['map1_01aTrace'].get('selectedSequenceId'))}</code> · root <code>{h(summary['map1_01aTrace'].get('selectedRootVaHex'))}</code>..<code>{h(summary['map1_01aTrace'].get('selectedRootEndVaHex'))}</code></p>
    </div>
    <table><thead><tr><th>#</th><th>level</th><th>status</th><th>start</th><th>prompt</th><th>text</th><th>trace opcodes</th></tr></thead><tbody>{prompt_rows}</tbody></table>
  </section>
  <details open>
    <summary>map1_01a command trace rows</summary>
    <table><thead><tr><th>prompt</th><th>cmd</th><th>route level</th><th>opcode level</th><th>VA</th><th>opcode</th><th>label</th><th>raw</th><th>roles</th></tr></thead><tbody>{command_rows}</tbody></table>
  </details>
  <section>
    <div class="head"><h2>Gap List</h2><span>{len(summary['gaps'])} rows</span></div>
    <table><thead><tr><th>id</th><th>kind</th><th>status</th><th>reason</th><th>next evidence</th></tr></thead><tbody>{gap_rows_html}</tbody></table>
  </section>
</main>
<script>
window.HWANSE_SCENE_EVENT_VM_GOAL_REVIEW = {{
  opcodeDictionaryGenerated: true,
  groundedOpcodeCount: {summary['summary']['groundedOpcodeCount']},
  groundedOpcodeMinimumMet: {str(summary['doneCriteria']['groundedOpcodeMinimumMet']).lower()},
  map1TraceGenerated: true,
  map1TraceEvidenceLevel: "{h(summary['map1_01aTrace'].get('evidenceLevel'))}",
  gapCount: {summary['summary']['gapCount']},
  candidatesHiddenByDefault: true
}};
</script>
</body>
</html>
"""


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--script-handler-table", type=Path, default=OUT / "script_handler_table.json")
    parser.add_argument("--event-vm-opcode-semantics", type=Path, default=OUT / "event_vm_opcode_semantics.json")
    parser.add_argument("--event-dialogue-blocks", type=Path, default=OUT / "event_dialogue_blocks.json")
    parser.add_argument("--event-text-source-flow", type=Path, default=OUT / "event_text_source_flow.json")
    parser.add_argument("--scene-manifest", type=Path, default=OUT / "scene_manifest.json")
    parser.add_argument("--scene-script-player-data", type=Path, default=OUT / "scene_script_player_data.json")
    parser.add_argument("--story-flow-review", type=Path, default=OUT / "story_flow_review.json")
    parser.add_argument("--map1-cluster-context", type=Path, default=OUT / "map1_01a_scene_record_cluster_context.json")
    parser.add_argument("--scene-prompt-probe", type=Path, default=OUT / "scene_prompt_reference_probe.json")
    args = parser.parse_args()
    args.out_dir.mkdir(parents=True, exist_ok=True)
    summary = build_summary(args)
    write_json(args.out_dir / "scene_event_vm_opcode_dictionary.json", summary)
    write_json(args.out_dir / "scene_event_vm_gap_list.json", {"gaps": summary["gaps"], "summary": summary["summary"]})
    write_text(
        args.out_dir / "scene_event_vm_gap_list.md",
        "\n".join(
            [
                "# Scene/Event VM Gap List",
                "",
                "| id | kind | status | reason | next |",
                "| --- | --- | --- | --- | --- |",
                *[
                    f"| `{row['id']}` | {row['kind']} | {row['status']} | {short_text(row.get('reason') or '-', 120)} | {short_text(row.get('nextEvidenceNeeded') or '-', 120)} |"
                    for row in summary["gaps"]
                ],
                "",
            ]
        ),
    )
    write_json(
        args.out_dir / "map1_01a_scene_trace.json",
        {"map1_01aTrace": summary["map1_01aTrace"], "summary": summary["summary"]},
    )
    write_text(
        args.out_dir / "map1_01a_scene_trace.md",
        "\n".join(
            [
                "# map1_01a Scene Trace Candidate",
                "",
                summary["map1_01aTrace"]["conclusion"],
                "",
                "| # | level | status | start | prompt | text |",
                "| ---: | --- | --- | --- | --- | --- |",
                *[
                    f"| {row['order']} | {row['evidenceLevel']} | {row['status']} | `{row.get('startVaHex') or '-'}` | `{row.get('promptId') or '-'}` | {short_text(row.get('displayText') or '', 120)} |"
                    for row in summary["map1_01aTrace"].get("prompts", [])
                ],
                "",
                "## Command Trace Rows",
                "",
                "| prompt | cmd | route level | opcode level | va | opcode | label | raw |",
                "| ---: | ---: | --- | --- | --- | --- | --- | --- |",
                *[
                    f"| {row.get('promptOrder')} | {row.get('commandOrderInPrompt')} | {row.get('routeEvidenceLevel')} | {row.get('opcodeEvidenceLevel')} | `{row.get('vaHex') or '-'}` | `{row.get('opcodeHex') or '-'}` | {short_text(row.get('label') or '-', 80)} | `{row.get('rawBytes') or '-'}` |"
                    for row in summary["map1_01aTrace"].get("commandTraceRows", [])
                ],
                "",
            ]
        ),
    )
    print(
        "wrote scene/event VM goal review -> "
        f"{args.out_dir / 'scene_event_vm_opcode_dictionary.json'}"
    )


if __name__ == "__main__":
    main()
