#!/usr/bin/env python3
"""Summarize web-runtime traces for the field HUD/menu open path.

The captures in captures/runtime are produced by the browser compatibility
runtime, not by static disassembly.  This report deliberately keeps the two
evidence classes separate:

* static anchors from earlier EXE analysis, especially menu descriptor/region
  initializers,
* observed runtime events from jsonl traces.

The main question for the first capture is narrow: when X is pressed on the
field, does runtime execution reach the selected-root / top-HUD-menu VM stream
neighborhood that static analysis identified?
"""
from __future__ import annotations

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


ROOT = Path(__file__).resolve().parents[1]
CAPTURES = ROOT / "captures" / "runtime"
OUT = ROOT / "out"
WEB = ROOT / "web"

JSON_OUT = OUT / "runtime_trace_hud_open_review.json"
HTML_OUT = WEB / "runtime_trace_hud_open_review.html"

STATIC_MENU_DESCRIPTOR = OUT / "menu_descriptor_stack_review.json"
STATIC_HUD_INPUT = OUT / "hud_input_consumer_boundary_review.json"


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


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


def parse_hex_int(value: Any) -> int | None:
    if isinstance(value, int):
        return value
    if not isinstance(value, str) or not value.startswith("0x"):
        return None
    try:
        return int(value, 16)
    except ValueError:
        return None


def hex_key(value: Any) -> str:
    number = parse_hex_int(value)
    return hx(number) if number is not None else str(value or "")


def read_json(path: Path) -> dict[str, Any]:
    if not path.exists():
        return {}
    return json.loads(path.read_text(encoding="utf-8"))


def decode_raw(raw_hex: str | None) -> dict[str, Any]:
    if not raw_hex:
        return {}
    try:
        raw = bytes.fromhex(raw_hex)
    except ValueError:
        return {"rawByteCount": 0}
    decoded: dict[str, Any] = {"rawByteCount": len(raw)}
    if raw:
        decoded["opcodeByte"] = f"0x{raw[0]:02x}"
    if len(raw) >= 4:
        decoded["word0Hex"] = hx(struct.unpack_from("<I", raw, 0)[0])
    if len(raw) >= 8:
        decoded["dword1Hex"] = hx(struct.unpack_from("<I", raw, 4)[0])
    if len(raw) >= 12:
        decoded["dword2Hex"] = hx(struct.unpack_from("<I", raw, 8)[0])
    return decoded


def decode_text_bytes(raw_hex: str | None) -> str:
    if not raw_hex:
        return ""
    try:
        raw = bytes.fromhex(raw_hex)
    except ValueError:
        return ""
    for encoding in ("cp949", "euc-kr"):
        try:
            return raw.decode(encoding).rstrip()
        except UnicodeDecodeError:
            continue
    return ""


def parse_draw_detail(detail: str | None) -> dict[str, Any]:
    if not detail:
        return {}
    parsed: dict[str, Any] = {}
    caller = re.search(r"caller=(0x[0-9a-fA-F]+)", detail)
    src_rect = re.search(r"srcRect=([0-9,\-]+)", detail)
    dst_rect = re.search(r"dstRect=([0-9,\-]+)", detail)
    at = re.search(r" at ([0-9\-]+),([0-9\-]+)", detail)
    src_key = re.search(r"srcColorKey=([^ ]+)", detail)
    applied_key = re.search(r"appliedColorKey=([^ ]+)", detail)
    if caller:
        parsed["caller"] = caller.group(1).lower()
    if src_rect:
        parsed["srcRect"] = src_rect.group(1)
    if dst_rect:
        parsed["dstRect"] = dst_rect.group(1)
    if at:
        parsed["dstPoint"] = f"{at.group(1)},{at.group(2)}"
    if src_key:
        parsed["srcColorKey"] = src_key.group(1)
    if applied_key:
        parsed["appliedColorKey"] = applied_key.group(1)
    return parsed


def load_static_anchors() -> dict[str, Any]:
    menu = read_json(STATIC_MENU_DESCRIPTOR)
    hud = read_json(STATIC_HUD_INPUT)
    anchors: dict[str, dict[str, Any]] = {}

    def add(addr: Any, label: str, kind: str, source: str, extra: dict[str, Any] | None = None) -> None:
        key = hex_key(addr)
        if not key.startswith("0x"):
            return
        anchors[key] = {
            "addressHex": key,
            "label": label,
            "kind": kind,
            "source": source,
            **(extra or {}),
        }

    summary = menu.get("summary") or {}
    add(summary.get("topMenuParentContextScriptVaHex"), "top menu parent context script", "static-menu-anchor", str(STATIC_MENU_DESCRIPTOR))
    add(summary.get("topMenuObjectSequenceVaHex"), "top menu object sequence", "static-menu-anchor", str(STATIC_MENU_DESCRIPTOR))
    top_sequence = menu.get("topMenuObjectSequence") or {}
    add(top_sequence.get("intermediateRecordVaHex"), "top menu intermediate record", "static-menu-anchor", str(STATIC_MENU_DESCRIPTOR))
    add(top_sequence.get("intermediateScriptPointerVaHex"), "top menu intermediate script pointer", "static-menu-anchor", str(STATIC_MENU_DESCRIPTOR))
    parent = top_sequence.get("parentContext") or {}
    add(parent.get("dynamicSelectVaHex"), "top menu parent dynamic select", "static-menu-anchor", str(STATIC_MENU_DESCRIPTOR))
    add(parent.get("dynamicCallVaHex"), "top menu parent dynamic call", "static-menu-anchor", str(STATIC_MENU_DESCRIPTOR))
    add(parent.get("dynamicSelectedScriptVaHex"), "intro/title selected script, not field opener", "static-context-negative", str(STATIC_MENU_DESCRIPTOR))

    for row in menu.get("exactTopContextRegionInitializers") or []:
        region = row.get("region") or {}
        add(
            row.get("initializerVaHex"),
            f"exact top context region initializer #{row.get('regionIndex')}",
            "static-region-initializer",
            str(STATIC_MENU_DESCRIPTOR),
            {
                "regionIndex": row.get("regionIndex"),
                "rectText": region.get("rectText"),
                "templateIndex": region.get("templateIndex"),
                "pointerRefsHex": row.get("pointerRefsHex") or [],
            },
        )

    key_functions = hud.get("keyFunctions") or {}
    if isinstance(key_functions, dict):
        key_iter = key_functions.items()
    else:
        key_iter = [(row.get("name") or row.get("label") or index, row) for index, row in enumerate(key_functions) if isinstance(row, dict)]
    for name, row in key_iter:
        if isinstance(row, dict):
            add(row.get("vaHex") or row.get("addressHex"), f"HUD input function {name}", "static-function", str(STATIC_HUD_INPUT))

    return {
        "anchors": anchors,
        "menuSummary": summary,
        "hudInputSummary": hud.get("summary") or {},
    }


def match_anchor(address: Any, anchors: dict[str, dict[str, Any]]) -> dict[str, Any] | None:
    key = hex_key(address)
    if key in anchors:
        return anchors[key]
    return None


def nearby_region_family(address: Any) -> str:
    value = parse_hex_int(address)
    if value is None:
        return ""
    if 0x004DDC00 <= value <= 0x004DDF00:
        return "top-menu-sequence-neighborhood"
    if 0x004DEE00 <= value <= 0x004DF000:
        return "top-region-child-initializer-neighborhood"
    if 0x0047E600 <= value <= 0x0047E800:
        return "top-menu-parent/context-neighborhood"
    if 0x00442B00 <= value <= 0x00442B80:
        return "descriptor-root-table-neighborhood"
    return ""


def load_trace_events(jsonl: Path) -> list[dict[str, Any]]:
    events = []
    for line_no, line in enumerate(jsonl.read_text(encoding="utf-8").splitlines(), 1):
        if not line.strip():
            continue
        event = json.loads(line)
        event["_line"] = line_no
        events.append(event)
    return events


def summarize_trace(manifest_path: Path, jsonl_path: Path, static: dict[str, Any]) -> dict[str, Any]:
    manifest = read_json(manifest_path)
    events = load_trace_events(jsonl_path)
    anchors = static["anchors"]
    counts = Counter(event.get("event") for event in events)
    functions = Counter(event.get("function") for event in events if event.get("event") == "function-enter")
    opcodes = Counter(event.get("opcode") for event in events if event.get("event") == "vm-op")
    pcs = Counter(event.get("pc") for event in events if event.get("pc"))
    streams = Counter(event.get("stream") for event in events if event.get("event") == "vm-op")
    objects = Counter(event.get("object") for event in events if event.get("event") == "vm-op")
    frames = [event.get("frame") for event in events if isinstance(event.get("frame"), int)]

    vm_rows: list[dict[str, Any]] = []
    for event in events:
        if event.get("event") != "vm-op":
            continue
        decoded = decode_raw(event.get("raw"))
        stream_anchor = match_anchor(event.get("stream"), anchors)
        operand_anchor = match_anchor(decoded.get("dword1Hex"), anchors) or match_anchor(decoded.get("dword2Hex"), anchors)
        family = nearby_region_family(event.get("stream"))
        status = "unmatched"
        if stream_anchor:
            status = "exact-static-anchor-hit"
        elif operand_anchor:
            status = "operand-static-anchor-hit"
        elif family:
            status = "near-static-neighborhood"
        vm_rows.append(
            {
                "seq": event.get("seq"),
                "line": event.get("_line"),
                "frame": event.get("frame"),
                "pc": event.get("pc"),
                "object": event.get("object"),
                "stream": hex_key(event.get("stream")),
                "opcode": event.get("opcode"),
                "raw": event.get("raw"),
                "decoded": decoded,
                "streamAnchor": stream_anchor,
                "operandAnchor": operand_anchor,
                "family": family,
                "status": status,
            }
        )

    changes = []
    for event in events:
        if event.get("event") != "object-stream-change":
            continue
        old_value = None
        new_value = None
        if isinstance(event.get("old"), str):
            try:
                old_value = hx(struct.unpack("<I", bytes.fromhex(event["old"]))[0])
            except Exception:
                old_value = None
        if isinstance(event.get("new"), str):
            try:
                new_value = hx(struct.unpack("<I", bytes.fromhex(event["new"]))[0])
            except Exception:
                new_value = None
        changes.append(
            {
                "seq": event.get("seq"),
                "line": event.get("_line"),
                "frame": event.get("frame"),
                "pc": event.get("pc"),
                "object": event.get("object"),
                "field": event.get("field"),
                "oldRaw": event.get("old"),
                "newRaw": event.get("new"),
                "oldValueHex": old_value,
                "newValueHex": new_value,
                "newAnchor": match_anchor(new_value, anchors),
                "newFamily": nearby_region_family(new_value),
            }
        )

    transitions = []
    for event in events:
        if event.get("event") != "vm-op-transition":
            continue
        previous_stream = hex_key(event.get("previous_stream"))
        next_stream = hex_key(event.get("next_stream"))
        transitions.append(
            {
                "seq": event.get("seq"),
                "line": event.get("_line"),
                "frame": event.get("frame"),
                "pc": event.get("pc"),
                "object": event.get("object"),
                "previousObject": (event.get("state") or {}).get("previous_object"),
                "nextObject": (event.get("state") or {}).get("next_object"),
                "previousStream": previous_stream,
                "nextStream": next_stream,
                "previousOpcode": event.get("previous_opcode"),
                "nextOpcode": event.get("next_opcode"),
                "previousFamily": nearby_region_family(previous_stream),
                "nextFamily": nearby_region_family(next_stream),
                "previousAnchor": match_anchor(previous_stream, anchors),
                "nextAnchor": match_anchor(next_stream, anchors),
                "state": event.get("state") or {},
            }
        )

    draw_surface_rows = []
    for event in events:
        if event.get("event") != "draw-surface":
            continue
        state = event.get("state") or {}
        detail = event.get("detail") or ""
        draw_surface_rows.append(
            {
                "seq": event.get("seq"),
                "line": event.get("_line"),
                "frame": event.get("frame"),
                "pc": event.get("pc"),
                "dll": event.get("dll"),
                "name": event.get("name"),
                "result": event.get("result"),
                "source": event.get("source"),
                "destination": event.get("destination"),
                "sourceLabel": event.get("sourceLabel"),
                "destinationLabel": event.get("destinationLabel"),
                "sourceResourceName": event.get("sourceResourceName"),
                "destinationResourceName": event.get("destinationResourceName"),
                "sourceSurfaceSize": event.get("sourceSurfaceSize"),
                "destinationSurfaceSize": event.get("destinationSurfaceSize"),
                "flags": state.get("flags"),
                "detail": detail,
                "parsed": parse_draw_detail(detail),
                "state": state,
            }
        )

    surface_snapshot_rows = []
    resource_surface_label_rows = []
    for event in events:
        if event.get("event") not in {"surface-snapshot", "resource-surface-label"}:
            continue
        row = {
            "seq": event.get("seq"),
            "line": event.get("_line"),
            "frame": event.get("frame"),
            "pc": event.get("pc"),
            "surfaceHandle": event.get("surfaceHandle"),
            "sourceLabel": event.get("sourceLabel"),
            "sourceResourceName": event.get("sourceResourceName"),
            "sourceSurfaceSize": event.get("sourceSurfaceSize"),
            "resourceName": event.get("resourceName"),
            "resourceType": event.get("resourceType"),
            "archiveOffset": event.get("archiveOffset"),
            "imageWidth": event.get("imageWidth"),
            "imageHeight": event.get("imageHeight"),
            "colorKey": event.get("colorKey"),
            "pixelHash": event.get("pixelHash"),
            "paletteHash": event.get("paletteHash"),
            "byteLength": event.get("byteLength"),
            "notes": event.get("notes"),
            "state": event.get("state") or {},
        }
        if event.get("event") == "surface-snapshot":
            surface_snapshot_rows.append(row)
        else:
            resource_surface_label_rows.append(row)

    text_draw_rows = []
    for event in events:
        if event.get("event") != "text-draw":
            continue
        decoded_text = decode_text_bytes(event.get("raw"))
        text_draw_rows.append(
            {
                "seq": event.get("seq"),
                "line": event.get("_line"),
                "frame": event.get("frame"),
                "pc": event.get("pc"),
                "destination": event.get("destination"),
                "rect": event.get("rect"),
                "text": event.get("text"),
                "decodedText": decoded_text,
                "raw": event.get("raw"),
                "x": event.get("x"),
                "y": event.get("y"),
                "font": event.get("font"),
                "color": event.get("color"),
                "state": event.get("state") or {},
            }
        )

    mem_write_rows = []
    for event in events:
        if event.get("event") != "mem-write":
            continue
        mem_write_rows.append(
            {
                "seq": event.get("seq"),
                "line": event.get("_line"),
                "frame": event.get("frame"),
                "pc": event.get("pc"),
                "address": event.get("address"),
                "symbol": event.get("symbol"),
                "width": event.get("width"),
                "old": event.get("old"),
                "new": event.get("new"),
            }
        )

    exact_region_hits = [
        row
        for row in vm_rows
        if (row.get("streamAnchor") or {}).get("kind") == "static-region-initializer"
        or (row.get("operandAnchor") or {}).get("kind") == "static-region-initializer"
    ]
    selected_root_seen = functions.get("selectedRootExecutor", 0) > 0
    top_menu_seen = any(row.get("family") in {"top-menu-sequence-neighborhood", "top-menu-parent/context-neighborhood"} for row in vm_rows)
    top_region_seen = bool(exact_region_hits)
    mem_writes = [event for event in events if event.get("event") == "mem-write"]
    non_input_mem_writes = [
        event
        for event in mem_writes
        if "input" not in str(event.get("symbol") or "").lower()
    ]
    expected_runtime_events = {
        "vm-op-transition": counts.get("vm-op-transition", 0),
        "draw-surface": counts.get("draw-surface", 0),
        "text-draw": counts.get("text-draw", 0),
        "text-api": counts.get("text-api", 0),
        "mem-write": counts.get("mem-write", 0),
    }

    if selected_root_seen and top_region_seen and top_menu_seen:
        classification = "runtime-confirms-x-opens-status-hud-selected-root-region-streams"
    elif selected_root_seen and top_menu_seen and expected_runtime_events["vm-op-transition"] and expected_runtime_events["draw-surface"]:
        classification = "runtime-confirms-status-hud-transition-and-directdraw-surface-copy"
    elif selected_root_seen and (top_region_seen or top_menu_seen):
        classification = "runtime-partially-confirms-x-enters-status-hud-stream-neighborhood"
    else:
        classification = "runtime-capture-insufficient"

    limitations = []
    if not mem_writes:
        limitations.append("no mem-write events captured; this would matter for menu navigation, not for the HUD-open consumer boundary")
    elif not non_input_mem_writes:
        limitations.append("mem-write capture only contains input-edge writes; cursor/page/depth variables are out of scope for this no-navigation capture")
    if not expected_runtime_events["vm-op-transition"]:
        limitations.append("no vm-op-transition events captured; opcode after-state/next-stream chain is still inferred from entry samples")
    if not expected_runtime_events["draw-surface"]:
        limitations.append("no draw-surface events captured; DirectDraw surface/template consumption is not runtime-proven in this trace")
    if not expected_runtime_events["text-draw"]:
        limitations.append("no text-draw events captured; TextOutA coordinates/font/color/text bytes are not runtime-proven in this trace")
    if (manifest.get("end_frame") or 0) - (manifest.get("start_frame") or 0) <= 5:
        limitations.append("short capture window; good for opener boundary but not enough for full draw/state lifecycle")

    decoded_texts = [row.get("decodedText") or "" for row in text_draw_rows if row.get("decodedText")]
    hud_status_markers = [
        "아타호",
        "무기",
        "방어구",
        "레벨",
        "체력",
        "기력",
        "경험",
        "공격력",
        "방어력",
        "기술력",
        "순발력",
        "선제",
        "기본기",
        "정권",
    ]
    matched_status_markers = [marker for marker in hud_status_markers if any(marker in text for text in decoded_texts)]

    return {
        "traceId": manifest.get("trace_id"),
        "manifestPath": str(manifest_path.relative_to(ROOT)),
        "jsonlPath": str(jsonl_path.relative_to(ROOT)),
        "manifest": manifest,
        "eventCount": len(events),
        "eventCounts": dict(counts),
        "functionCounts": dict(functions),
        "opcodeCounts": dict(opcodes),
        "pcCountsTop": dict(pcs.most_common(20)),
        "streamCountsTop": dict(streams.most_common(30)),
        "objectCountsTop": dict(objects.most_common(20)),
        "frameRange": {
            "start": min(frames) if frames else None,
            "end": max(frames) if frames else None,
            "unique": len(set(frames)),
        },
        "classification": classification,
        "runtimeSignals": {
            "selectedRootExecutorSeen": selected_root_seen,
            "topMenuNeighborhoodSeen": top_menu_seen,
            "exactTopRegionInitializerHitCount": len(exact_region_hits),
            "memWriteCount": len(mem_writes),
            "nonInputMemWriteCount": len(non_input_mem_writes),
            "objectStreamChangeCount": len(changes),
            "expectedRuntimeEventCounts": expected_runtime_events,
            "drawSurfaceCount": len(draw_surface_rows),
            "surfaceSnapshotCount": len(surface_snapshot_rows),
            "resourceSurfaceLabelCount": len(resource_surface_label_rows),
            "textDrawCount": len(text_draw_rows),
            "vmTransitionCount": len(transitions),
        },
        "hudStatusTextEvidence": {
            "matchedMarkers": matched_status_markers,
            "decodedTexts": decoded_texts,
            "statusWindowConfirmed": len(matched_status_markers) >= 8,
        },
        "exactRegionHits": exact_region_hits,
        "vmRows": vm_rows,
        "vmTransitions": transitions,
        "drawSurfaceRows": draw_surface_rows,
        "surfaceSnapshotRows": surface_snapshot_rows,
        "resourceSurfaceLabelRows": resource_surface_label_rows,
        "textDrawRows": text_draw_rows,
        "memWrites": mem_write_rows,
        "objectStreamChanges": changes,
        "limitations": limitations,
    }


def build_report() -> dict[str, Any]:
    static = load_static_anchors()
    traces = []
    for manifest in sorted(CAPTURES.glob("*.manifest.json")):
        jsonl = manifest.with_suffix("").with_suffix(".jsonl")
        if not jsonl.exists():
            # The export uses *.manifest.json plus same base without ".manifest".
            jsonl = manifest.parent / manifest.name.replace(".manifest.json", ".jsonl")
        if not jsonl.exists():
            continue
        traces.append(summarize_trace(manifest, jsonl, static))

    summary = {
        "traceCount": len(traces),
        "confirmedTraceCount": sum(1 for trace in traces if trace["classification"].startswith("runtime-confirms")),
        "partialTraceCount": sum(1 for trace in traces if "partially" in trace["classification"]),
        "staticAnchorCount": len(static["anchors"]),
        "transitionTraceCount": sum(1 for trace in traces if trace["runtimeSignals"].get("vmTransitionCount")),
        "drawSurfaceTraceCount": sum(1 for trace in traces if trace["runtimeSignals"].get("drawSurfaceCount")),
        "textDrawTraceCount": sum(1 for trace in traces if trace["runtimeSignals"].get("textDrawCount")),
    }
    return {
        "version": 1,
        "kind": "hwanse-runtime-trace-hud-open-review",
        "source": "tools/build_runtime_trace_hud_open_review.py",
        "capturesDir": str(CAPTURES.relative_to(ROOT)),
        "staticSources": {
            "menuDescriptorStack": str(STATIC_MENU_DESCRIPTOR.relative_to(ROOT)),
            "hudInputConsumer": str(STATIC_HUD_INPUT.relative_to(ROOT)),
        },
        "summary": summary,
        "staticAnchors": static["anchors"],
        "traces": traces,
        "nextCaptureRequirements": [
            "no additional navigation capture is required for the HUD-open consumer question; current capture confirms opener VM path plus draw/text consumption",
            "optional: add surface labels or resource ids for window.cns/status.cns/icon.cns/num.cns/face_01.cns if asset-level draw attribution is needed",
            "only if investigating menu navigation later, capture Up/Down/Left/Right, Enter, and Escape with cursor/depth/page/selected-actor watchpoints",
        ],
    }


def badge(text: str, cls: str = "") -> str:
    return f'<span class="badge {h(cls)}">{h(text)}</span>'


def render_count_table(title: str, rows: dict[str, int], limit: int = 20) -> str:
    body = []
    for key, count in list(rows.items())[:limit]:
        body.append(f"<tr><td><code>{h(key)}</code></td><td>{count}</td></tr>")
    return f"<section><h2>{h(title)}</h2><table><thead><tr><th>값</th><th>count</th></tr></thead><tbody>{''.join(body)}</tbody></table></section>"


def render_html(report: dict[str, Any]) -> str:
    trace_sections = []
    for trace in report["traces"]:
        signals = trace["runtimeSignals"]
        signal_badges = "".join(
            [
                badge(f"selectedRoot={signals['selectedRootExecutorSeen']}", "ok" if signals["selectedRootExecutorSeen"] else "bad"),
                badge(f"topMenu={signals['topMenuNeighborhoodSeen']}", "ok" if signals["topMenuNeighborhoodSeen"] else "bad"),
                badge(f"regionHits={signals['exactTopRegionInitializerHitCount']}", "ok" if signals["exactTopRegionInitializerHitCount"] else "warn"),
                badge(f"memWrites={signals['memWriteCount']}", "warn" if not signals["memWriteCount"] else "ok"),
                badge(f"transitions={signals.get('vmTransitionCount', 0)}", "ok" if signals.get("vmTransitionCount", 0) else "warn"),
                badge(f"draws={signals.get('drawSurfaceCount', 0)}", "ok" if signals.get("drawSurfaceCount", 0) else "warn"),
                badge(f"text={signals.get('textDrawCount', 0)}", "ok" if signals.get("textDrawCount", 0) else "warn"),
            ]
        )
        vm_rows = []
        for row in trace["vmRows"]:
            if row["status"] == "unmatched":
                continue
            anchor = row.get("streamAnchor") or row.get("operandAnchor") or {}
            vm_rows.append(
                "<tr>"
                f"<td>{row['seq']}</td><td>{row['frame']}</td><td><code>{h(row['object'])}</code></td>"
                f"<td><code>{h(row['stream'])}</code></td><td><code>{h(row['opcode'])}</code></td>"
                f"<td>{h(row['status'])}</td><td>{h(anchor.get('label') or row.get('family'))}</td>"
                f"<td><code>{h(row.get('decoded', {}).get('dword1Hex'))}</code> / <code>{h(row.get('decoded', {}).get('dword2Hex'))}</code></td>"
                "</tr>"
            )
        if not vm_rows:
            vm_rows.append("<tr><td colspan='8'>matched VM stream 없음</td></tr>")

        change_rows = []
        for row in trace["objectStreamChanges"]:
            anchor = row.get("newAnchor") or {}
            change_rows.append(
                "<tr>"
                f"<td>{row['seq']}</td><td>{row['frame']}</td><td><code>{h(row['pc'])}</code></td>"
                f"<td><code>{h(row['object'])}</code></td><td>{h(row['field'])}</td>"
                f"<td><code>{h(row['oldValueHex'])}</code></td><td><code>{h(row['newValueHex'])}</code></td>"
                f"<td>{h(anchor.get('label') or row.get('newFamily') or '')}</td>"
                "</tr>"
            )
        if not change_rows:
            change_rows.append("<tr><td colspan='8'>object stream change 없음</td></tr>")

        transition_rows = []
        for row in trace.get("vmTransitions") or []:
            prev_anchor = row.get("previousAnchor") or {}
            next_anchor = row.get("nextAnchor") or {}
            if not (
                row.get("previousFamily")
                or row.get("nextFamily")
                or prev_anchor
                or next_anchor
            ):
                continue
            transition_rows.append(
                "<tr>"
                f"<td>{row['seq']}</td><td>{row['frame']}</td>"
                f"<td><code>{h(row.get('previousObject'))}</code> -> <code>{h(row.get('nextObject'))}</code></td>"
                f"<td><code>{h(row['previousStream'])}</code> ({h(row.get('previousOpcode'))})</td>"
                f"<td><code>{h(row['nextStream'])}</code> ({h(row.get('nextOpcode'))})</td>"
                f"<td>{h(prev_anchor.get('label') or row.get('previousFamily') or '')}</td>"
                f"<td>{h(next_anchor.get('label') or row.get('nextFamily') or '')}</td>"
                "</tr>"
            )
        if not transition_rows:
            transition_rows.append("<tr><td colspan='7'>matched transition 없음</td></tr>")

        draw_rows = []
        for row in (trace.get("drawSurfaceRows") or [])[:80]:
            parsed = row.get("parsed") or {}
            draw_rows.append(
                "<tr>"
                f"<td>{row['seq']}</td><td>{row['frame']}</td><td><code>{h(row.get('pc'))}</code></td>"
                f"<td>{h(row.get('name'))}</td><td><code>{h(row.get('source'))}</code> -> <code>{h(row.get('destination'))}</code></td>"
                f"<td><code>{h(row.get('flags'))}</code></td>"
                f"<td>{h(parsed.get('srcRect') or '')}</td><td>{h(parsed.get('dstRect') or parsed.get('dstPoint') or '')}</td>"
                f"<td>{h(parsed.get('srcColorKey') or '')} / {h(parsed.get('appliedColorKey') or '')}</td>"
                "</tr>"
            )
        if not draw_rows:
            draw_rows.append("<tr><td colspan='9'>draw-surface 없음</td></tr>")

        text_rows = []
        for row in trace.get("textDrawRows") or []:
            state = row.get("state") or {}
            text_rows.append(
                "<tr>"
                f"<td>{row['seq']}</td><td>{row['frame']}</td><td><code>{h(row.get('pc'))}</code></td>"
                f"<td><code>{h(row.get('destination'))}</code></td><td>{h(row.get('rect') or state.get('bounds') or '')}</td>"
                f"<td>{h(row.get('decodedText') or row.get('text') or '')}</td>"
                f"<td><code>{h(row.get('raw'))}</code></td><td>{h(row.get('font'))}</td><td>{h(row.get('color'))}</td>"
                "</tr>"
            )
        if not text_rows:
            text_rows.append("<tr><td colspan='9'>text-draw 없음</td></tr>")

        mem_rows = []
        for row in trace.get("memWrites") or []:
            mem_rows.append(
                "<tr>"
                f"<td>{row['seq']}</td><td>{row['frame']}</td><td><code>{h(row.get('pc'))}</code></td>"
                f"<td><code>{h(row.get('address'))}</code></td><td>{h(row.get('symbol'))}</td>"
                f"<td>{h(row.get('width'))}</td><td><code>{h(row.get('old'))}</code></td><td><code>{h(row.get('new'))}</code></td>"
                "</tr>"
            )
        if not mem_rows:
            mem_rows.append("<tr><td colspan='8'>mem-write 없음</td></tr>")

        limitations = "".join(f"<li>{h(item)}</li>" for item in trace["limitations"])
        text_evidence = trace.get("hudStatusTextEvidence") or {}
        marker_badges = "".join(badge(marker, "ok") for marker in text_evidence.get("matchedMarkers") or [])
        status_confirmed = bool(text_evidence.get("statusWindowConfirmed"))
        trace_sections.append(
            f"""
            <section class="trace">
              <h2>{h(trace.get('traceId') or trace['manifest'].get('trace_id'))}</h2>
              <p class="muted"><code>{h(trace['manifestPath'])}</code> / <code>{h(trace['jsonlPath'])}</code></p>
              <p>{badge(trace['classification'], 'ok' if trace['classification'].startswith('runtime-confirms') else 'warn')}</p>
              <div class="badges">{signal_badges}</div>
              <p>frame {h(trace['frameRange']['start'])}..{h(trace['frameRange']['end'])}, events {trace['eventCount']}</p>
              <p>{badge('status-window-text=' + str(status_confirmed), 'ok' if status_confirmed else 'warn')}{marker_badges}</p>
              <h3>정적 anchor와 맞은 VM rows</h3>
              <table><thead><tr><th>seq</th><th>frame</th><th>object</th><th>stream</th><th>op</th><th>판정</th><th>근거</th><th>operands</th></tr></thead><tbody>{''.join(vm_rows)}</tbody></table>
              <h3>VM transitions</h3>
              <table><thead><tr><th>seq</th><th>frame</th><th>object</th><th>previous</th><th>next</th><th>previous 근거</th><th>next 근거</th></tr></thead><tbody>{''.join(transition_rows)}</tbody></table>
              <h3>Object stream changes</h3>
              <table><thead><tr><th>seq</th><th>frame</th><th>pc</th><th>object</th><th>field</th><th>old</th><th>new</th><th>new 근거</th></tr></thead><tbody>{''.join(change_rows)}</tbody></table>
              <h3>Memory writes</h3>
              <table><thead><tr><th>seq</th><th>frame</th><th>pc</th><th>address</th><th>symbol</th><th>width</th><th>old</th><th>new</th></tr></thead><tbody>{''.join(mem_rows)}</tbody></table>
              <h3>Draw surface calls</h3>
              <table><thead><tr><th>seq</th><th>frame</th><th>pc</th><th>name</th><th>surface</th><th>flags</th><th>src</th><th>dst</th><th>color key</th></tr></thead><tbody>{''.join(draw_rows)}</tbody></table>
              <h3>Text draw calls</h3>
              <table><thead><tr><th>seq</th><th>frame</th><th>pc</th><th>surface</th><th>rect</th><th>decoded</th><th>raw</th><th>font</th><th>color</th></tr></thead><tbody>{''.join(text_rows)}</tbody></table>
              <h3>제한</h3><ul>{limitations}</ul>
              {render_count_table('event counts', trace['eventCounts'])}
              {render_count_table('function counts', trace['functionCounts'])}
              {render_count_table('opcode counts', trace['opcodeCounts'])}
              {render_count_table('stream counts', trace['streamCountsTop'], 30)}
            </section>
            """
        )

    next_reqs = "".join(f"<li>{h(item)}</li>" for item in report["nextCaptureRequirements"])
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Runtime Trace HUD Open Review</title>
  <style>
    body {{ margin: 0; background: #f6f7f9; color: #20242b; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
    main {{ width: min(1280px, calc(100vw - 24px)); margin: 0 auto; padding: 22px 0 36px; }}
    h1 {{ margin: 0 0 8px; font-size: 26px; }}
    h2 {{ margin-top: 22px; font-size: 20px; }}
    h3 {{ margin: 18px 0 8px; font-size: 15px; }}
    p {{ margin: 6px 0; }}
    .muted {{ color: #697385; }}
    .trace, .summary {{ background: #fff; border: 1px solid #d8dee8; border-radius: 8px; padding: 14px; margin: 12px 0; overflow: auto; }}
    table {{ border-collapse: collapse; width: 100%; font-size: 13px; margin: 8px 0 14px; }}
    th, td {{ border: 1px solid #e1e6ef; padding: 6px 8px; text-align: left; vertical-align: top; }}
    th {{ background: #f0f3f8; }}
    code {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }}
    .badge {{ display: inline-block; border: 1px solid #c9d1dc; border-radius: 999px; padding: 3px 8px; margin: 2px 4px 2px 0; background: #eef2f7; font-size: 12px; font-weight: 700; }}
    .badge.ok {{ color: #116b3a; background: #eaf7ef; border-color: #bfe6cb; }}
    .badge.warn {{ color: #8a5c00; background: #fff6dd; border-color: #ead18c; }}
    .badge.bad {{ color: #9c1d1d; background: #fff0f0; border-color: #efc5c5; }}
  </style>
</head>
<body>
<main>
  <h1>Runtime Trace HUD Open Review</h1>
  <section class="summary">
    <p>웹 런타임 캡처를 정적 EXE 분석 anchor와 대조한 리포트입니다. 현재 목적은 필드에서 X 입력 시 상태창/HUD opener와 draw/text 소비자가 실제로 실행되는지 확인하는 것입니다.</p>
    <p>{badge('traces ' + str(report['summary']['traceCount']))}{badge('confirmed ' + str(report['summary']['confirmedTraceCount']), 'ok' if report['summary']['confirmedTraceCount'] else 'warn')}{badge('static anchors ' + str(report['summary']['staticAnchorCount']))}</p>
    <p>JSON: <code>{h(str(JSON_OUT.relative_to(ROOT)))}</code></p>
  </section>
  {''.join(trace_sections)}
  <section class="summary">
    <h2>다음 캡처 요구사항</h2>
    <ul>{next_reqs}</ul>
  </section>
</main>
</body>
</html>
"""


def main() -> None:
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    report = build_report()
    JSON_OUT.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    html_text = "\n".join(line.rstrip() for line in render_html(report).splitlines()) + "\n"
    HTML_OUT.write_text(html_text, encoding="utf-8")
    print(f"wrote {JSON_OUT.relative_to(ROOT)}")
    print(f"wrote {HTML_OUT.relative_to(ROOT)}")


if __name__ == "__main__":
    main()
