#!/usr/bin/env python3
"""Bridge the HUD-open runtime trace back to static EXE evidence.

The low-level runtime trace is intentionally noisy.  This report keeps the
useful promoted facts in one place:

* X opens the status/menu HUD and reaches the selected-root/top-region streams.
* The status/menu surface is rendered through window regions and then composed
  onto the frame surface.
* Korean HUD/menu text is emitted through two GDI TextOutA callsites with
  different roles.
"""
from __future__ import annotations

import html
import json
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any

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

RUNTIME_REVIEW = OUT / "runtime_trace_hud_open_review.json"
MENU_DESCRIPTOR_REVIEW = OUT / "menu_descriptor_stack_review.json"
HUD_INPUT_REVIEW = OUT / "hud_input_consumer_boundary_review.json"
VM_FRONTIER_REVIEW = OUT / "hud_menu_vm_graph_frontier_review.json"
IMPORTS_REVIEW = OUT / "exe_imports.json"

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

TEXT_SURFACE = "0x100979C0"
FRAME_SURFACE = "0x1004C438"

TEXT_OUT_CALLSITES = {
    "0x0041F293": {
        "functionVaHex": "0x0041f073",
        "role": "streamed HUD/status text renderer",
        "staticNotes": [
            "Reads object +0xb0 as a text stream pointer.",
            "Uses +0xca/+0xcc and globals 0x0055b284/0x0055b288 as stream delay/width state.",
            "Calls GDI32!TextOutA through IAT 0x005a03b8 after SelectObject/SetTextColor/SetBkMode.",
        ],
    },
    "0x0041B41D": {
        "functionVaHex": "0x0041b369",
        "role": "fixed-width immediate HUD/menu slot renderer",
        "staticNotes": [
            "Uses object +0xd6/+0xda as output coordinates and +0xe0/+0xe2/+0xe4 as RGB text color bytes.",
            "The runtime strings are padded with full-width spaces, matching fixed list/equipment/menu slots.",
            "Calls GDI32!TextOutA through IAT 0x005a03b8 after SelectObject/SetTextColor/SetBkMode.",
        ],
    },
}

REGION_BY_RECT = {
    "0,0,416,352": {"regionIndex": 6, "name": "left status window"},
    "416,0,640,96": {"regionIndex": 3, "name": "right top menu window"},
    "416,96,640,352": {"regionIndex": 4, "name": "right submenu window"},
    "0,352,416,480": {"regionIndex": 1, "name": "bottom left HUD window"},
    "416,352,640,480": {"regionIndex": 2, "name": "bottom right info window"},
}


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


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


def import_refs_by_iat() -> dict[str, dict[str, Any]]:
    data = read_json(IMPORTS_REVIEW)
    refs: dict[str, dict[str, Any]] = {}
    for dll in data.get("imports") or []:
        for function in dll.get("functions") or []:
            iat = function.get("iatVa")
            if not isinstance(iat, int):
                continue
            refs[f"0x{iat:08x}"] = {
                "dll": dll.get("dll"),
                "name": function.get("name"),
                "refsHex": [f"0x{int(ref['va']):08x}" for ref in function.get("refs") or [] if "va" in ref],
            }
    return refs


def surface_rows(trace: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
    text_surface_rows = []
    composition_rows = []
    template_tile_rows = []
    for row in trace.get("drawSurfaceRows") or []:
        source = str(row.get("source") or "")
        destination = str(row.get("destination") or "")
        parsed = row.get("parsed") or {}
        src_rect = parsed.get("srcRect") or ""
        dst_rect = parsed.get("dstRect") or ""
        detail = str(row.get("detail") or "")
        if TEXT_SURFACE.lower() in (source + destination + detail).lower():
            text_surface_rows.append(row)
        if source.lower() == TEXT_SURFACE.lower() and destination.lower() == FRAME_SURFACE.lower():
            region = REGION_BY_RECT.get(src_rect)
            composition_rows.append(
                {
                    **row,
                    "region": region,
                    "srcRect": src_rect,
                    "dstRect": dst_rect,
                    "compositionStatus": "region-known" if region else "unclassified-text-surface-copy",
                }
            )
        if destination.lower() == TEXT_SURFACE.lower() and row.get("pc") == "0x00419C25":
            template_tile_rows.append(row)
    return {
        "textSurfaceRows": text_surface_rows,
        "compositionRows": composition_rows,
        "templateTileRows": template_tile_rows,
    }


def summarize_composition(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    grouped: dict[tuple[str, str], dict[str, Any]] = {}
    for row in rows:
        region = row.get("region")
        if not region:
            continue
        key = (str(row.get("srcRect") or ""), str(row.get("dstRect") or ""))
        current = grouped.get(key)
        if current is None:
            grouped[key] = {
                "region": region,
                "srcRect": row.get("srcRect"),
                "dstRect": row.get("dstRect"),
                "flags": row.get("flags"),
                "firstSeq": row.get("seq"),
                "lastSeq": row.get("seq"),
                "firstFrame": row.get("frame"),
                "lastFrame": row.get("frame"),
                "repeatCount": 1,
                "sampleDetail": row.get("detail"),
            }
            continue
        current["lastSeq"] = row.get("seq")
        current["lastFrame"] = row.get("frame")
        current["repeatCount"] += 1
    return sorted(
        grouped.values(),
        key=lambda row: (
            int((row.get("region") or {}).get("regionIndex") or 99),
            int(row.get("firstSeq") or 0),
        ),
    )


def text_groups(trace: dict[str, Any]) -> list[dict[str, Any]]:
    groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for row in trace.get("textDrawRows") or []:
        groups[str(row.get("pc"))].append(row)

    out = []
    for pc, rows in sorted(groups.items()):
        role = TEXT_OUT_CALLSITES.get(pc, {})
        out.append(
            {
                "pc": pc,
                "functionVaHex": role.get("functionVaHex"),
                "role": role.get("role", "unclassified TextOutA callsite"),
                "count": len(rows),
                "texts": [row.get("decodedText") for row in rows],
                "rects": [row.get("rect") for row in rows],
                "fontColors": sorted(
                    {
                        f"{row.get('font') or '-'} / {row.get('color') or '-'}"
                        for row in rows
                    }
                ),
                "staticNotes": role.get("staticNotes") or [],
                "sampleRows": rows[:12],
            }
        )
    return out


def exact_region_rows(trace: dict[str, Any]) -> list[dict[str, Any]]:
    rows = []
    for row in trace.get("exactRegionHits") or []:
        anchor = row.get("streamAnchor") or {}
        rows.append(
            {
                "seq": row.get("seq"),
                "frame": row.get("frame"),
                "stream": row.get("stream"),
                "opcode": row.get("opcode"),
                "regionIndex": anchor.get("regionIndex"),
                "rectText": anchor.get("rectText"),
                "templateIndex": anchor.get("templateIndex"),
                "label": anchor.get("label"),
                "status": row.get("status"),
            }
        )
    return rows


def choose_trace(runtime: dict[str, Any]) -> dict[str, Any]:
    traces = runtime.get("traces") or [{}]
    labeled = [trace for trace in traces if trace.get("runtimeSignals", {}).get("resourceSurfaceLabelCount")]
    if labeled:
        return sorted(labeled, key=lambda trace: str((trace.get("manifest") or {}).get("exported_at") or ""))[-1]
    return traces[-1] if traces else {}


def build() -> dict[str, Any]:
    runtime = read_json(RUNTIME_REVIEW)
    menu = read_json(MENU_DESCRIPTOR_REVIEW)
    hud_input = read_json(HUD_INPUT_REVIEW)
    frontier = read_json(VM_FRONTIER_REVIEW)
    imports = import_refs_by_iat()
    trace = choose_trace(runtime)
    surfaces = surface_rows(trace)
    text = text_groups(trace)
    exact = exact_region_rows(trace)

    text_counter = Counter()
    for group in text:
        for value in group.get("texts") or []:
            text_counter[str(value)] += 1

    composition_known = [row for row in surfaces["compositionRows"] if row.get("region")]
    composition_summary = summarize_composition(composition_known)
    known_regions = sorted(
        {
            int(row["region"]["regionIndex"])
            for row in composition_known
            if row.get("region") and row["region"].get("regionIndex") is not None
        }
    )

    promotions = [
        {
            "area": "normal field X/ESC status HUD opener",
            "previousStatus": (frontier.get("status") or "static-only pending"),
            "newStatus": "runtime-confirmed",
            "evidence": [
                "selectedRootExecutorSeen is true in the runtime trace",
                "top menu/context stream neighborhood appears after the input-edge write",
                "exact top context region initializers #6/#3/#4 execute in frame 277",
                "status-window text markers are emitted in the same HUD-open trace",
            ],
        },
        {
            "area": "HUD region composition",
            "previousStatus": "layout grounded, runtime composition partial",
            "newStatus": "runtime-confirmed for regions #1/#2/#3/#4/#6",
            "evidence": [
                "surface 0x100979C0 is copied to frame surface 0x1004C438 by exact region rectangles",
                "composition copies include 0,0,416,352; 416,0,640,96; 416,96,640,352; 0,352,416,480; 416,352,640,480",
            ],
        },
        {
            "area": "HUD/menu Korean text draw sink",
            "previousStatus": "text tables known, draw sink separated",
            "newStatus": "runtime-confirmed GDI TextOutA sink",
            "evidence": [
                "0x0041F293 and 0x0041B41D are direct calls through GDI32!TextOutA IAT 0x005a03b8",
                "runtime TextOutA rows include character name, equipment names, stat labels, status comment, mode, category, and skill list labels",
            ],
        },
    ]

    remaining = [
        {
            "item": "menu navigation/deeper submenu state writes",
            "reason": "This capture only opened the status HUD; it did not press arrows/confirm/cancel after the HUD was visible.",
            "nextTrace": "Capture Down/Up, Left/Right, Enter, ESC while the menu is open with mem-write/object-stream-change enabled.",
        },
        {
            "item": "asset-level source surface labels",
            "reason": "Draw rows expose surface handles and source rects, but not yet the originating CNS filename for each surface handle.",
            "nextTrace": "Add surface creation/resource-label events or wrapper id labels when CNS assets are loaded.",
        },
        {
            "item": "dynamic status values",
            "reason": "This trace proves label/text positions and surface composition. It does not yet prove the exact producer for HP/MP/EXP numeric CNS glyph values.",
            "nextTrace": "Capture status HUD with numeric glyph draw source labels and watched stat memory values.",
        },
    ]

    return {
        "version": 1,
        "kind": "hwanse-hud-runtime-static-bridge-review",
        "source": str(Path(__file__).relative_to(ROOT)),
        "inputs": {
            "runtime": str(RUNTIME_REVIEW.relative_to(ROOT)),
            "menuDescriptor": str(MENU_DESCRIPTOR_REVIEW.relative_to(ROOT)),
            "hudInput": str(HUD_INPUT_REVIEW.relative_to(ROOT)),
            "vmFrontier": str(VM_FRONTIER_REVIEW.relative_to(ROOT)),
            "imports": str(IMPORTS_REVIEW.relative_to(ROOT)),
        },
        "summary": {
            "status": "hud-open-consumer-runtime-confirmed-static-bridge",
            "traceId": trace.get("traceId"),
            "classification": trace.get("classification"),
            "eventCount": trace.get("eventCount"),
            "frameRange": trace.get("frameRange"),
            "textSurface": TEXT_SURFACE,
            "frameSurface": FRAME_SURFACE,
            "textOutIat": "0x005a03b8",
            "textDrawRows": len(trace.get("textDrawRows") or []),
            "textOutCallsiteCount": len(text),
            "exactTopRegionInitializerHits": len(exact),
            "knownComposedRegions": known_regions,
            "textSurfaceDrawRows": len(surfaces["textSurfaceRows"]),
            "compositionRows": len(surfaces["compositionRows"]),
            "compositionRegionRows": len(composition_summary),
            "templateTileRows": len(surfaces["templateTileRows"]),
            "surfaceSnapshotRows": len(trace.get("surfaceSnapshotRows") or []),
            "resourceSurfaceLabelRows": len(trace.get("resourceSurfaceLabelRows") or []),
            "traceJsonlPath": trace.get("jsonlPath"),
            "traceExportedAt": (trace.get("manifest") or {}).get("exported_at"),
            "oldStaticFrontierStatus": frontier.get("status"),
            "oldHudInputConclusion": hud_input.get("conclusion") or hud_input.get("summary", {}).get("conclusion"),
            "topMenuObjectSequenceVaHex": (menu.get("summary") or {}).get("topMenuObjectSequenceVaHex"),
            "topMenuParentContextScriptVaHex": (menu.get("summary") or {}).get("topMenuParentContextScriptVaHex"),
        },
        "gdiImportRefs": {
            key: imports.get(key)
            for key in ["0x005a03a4", "0x005a03b8", "0x005a03cc", "0x005a03d0"]
            if key in imports
        },
        "promotions": promotions,
        "textOutCallsites": text,
        "textMarkers": {
            "allDecodedTexts": [row.get("decodedText") for row in trace.get("textDrawRows") or []],
            "counts": dict(text_counter),
        },
        "exactRegionInitializerRows": exact,
        "surfaceCompositionRows": composition_summary,
        "surfaceCompositionRawSample": composition_known[:12],
        "textSurfaceRowsSample": surfaces["textSurfaceRows"][:40],
        "remainingRuntimeNeeds": remaining,
    }


def write_html(data: dict[str, Any]) -> None:
    summary = data["summary"]
    rows = []
    rows.append("<!doctype html><meta charset=\"utf-8\">")
    rows.append("<title>HUD Runtime Static Bridge</title>")
    rows.append(
        """
<style>
body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;margin:24px;background:#f7f5ef;color:#202020}
a{color:#135c8a} .top{display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 20px}
.chip{display:inline-block;padding:6px 10px;border:1px solid #bbb;border-radius:999px;background:white;text-decoration:none;color:#222}
.panel{background:white;border:1px solid #d8d0c1;border-radius:8px;padding:16px;margin:14px 0;box-shadow:0 1px 3px #0001}
h1{margin:0 0 8px;font-size:26px} h2{font-size:18px;margin:0 0 12px}
table{border-collapse:collapse;width:100%;font-size:13px} th,td{border:1px solid #ddd;padding:7px;vertical-align:top}
th{background:#eee7d8;text-align:left} code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
.ok{color:#116b29;font-weight:700}.pending{color:#8a5300;font-weight:700}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px}
.small{font-size:12px;color:#555}.nowrap{white-space:nowrap}
</style>
"""
    )
    rows.append("<h1>HUD Runtime Static Bridge</h1>")
    rows.append("<p>웹 런타임 HUD-open trace를 기존 EXE 정적 분석 앵커와 연결해 승격 가능한 항목만 모은 리포트입니다.</p>")
    rows.append(
        '<div class="top"><a class="chip" href="index.html">index</a>'
        '<a class="chip" href="runtime_trace_hud_open_review.html">runtime trace source</a>'
        '<a class="chip" href="../out/hud_runtime_static_bridge_review.json">json</a></div>'
    )

    rows.append('<section class="panel"><h2>Summary</h2><div class="grid">')
    for key in [
        "status",
        "traceId",
        "classification",
        "eventCount",
        "textSurface",
        "frameSurface",
        "textOutIat",
        "textDrawRows",
        "textOutCallsiteCount",
        "exactTopRegionInitializerHits",
        "knownComposedRegions",
        "oldStaticFrontierStatus",
    ]:
        rows.append(f"<div><b>{h(key)}</b><br><code>{h(summary.get(key))}</code></div>")
    rows.append("</div></section>")

    rows.append('<section class="panel"><h2>Promotions</h2><table><tr><th>area</th><th>previous</th><th>new</th><th>evidence</th></tr>')
    for row in data["promotions"]:
        rows.append(
            "<tr>"
            f"<td>{h(row['area'])}</td>"
            f"<td>{h(row['previousStatus'])}</td>"
            f"<td><span class=\"ok\">{h(row['newStatus'])}</span></td>"
            f"<td>{'<br>'.join(h(item) for item in row['evidence'])}</td>"
            "</tr>"
        )
    rows.append("</table></section>")

    rows.append('<section class="panel"><h2>TextOutA Callsites</h2><table><tr><th>pc</th><th>function</th><th>role</th><th>count</th><th>texts</th><th>static notes</th></tr>')
    for row in data["textOutCallsites"]:
        rows.append(
            "<tr>"
            f"<td><code>{h(row['pc'])}</code></td>"
            f"<td><code>{h(row.get('functionVaHex'))}</code></td>"
            f"<td>{h(row.get('role'))}</td>"
            f"<td>{h(row.get('count'))}</td>"
            f"<td>{'<br>'.join(h(text) for text in row.get('texts') or [])}</td>"
            f"<td>{'<br>'.join(h(note) for note in row.get('staticNotes') or [])}</td>"
            "</tr>"
        )
    rows.append("</table></section>")

    rows.append('<section class="panel"><h2>Region Initializers</h2><table><tr><th>seq</th><th>frame</th><th>stream</th><th>region</th><th>rect</th><th>status</th></tr>')
    for row in data["exactRegionInitializerRows"]:
        rows.append(
            "<tr>"
            f"<td>{h(row.get('seq'))}</td>"
            f"<td>{h(row.get('frame'))}</td>"
            f"<td><code>{h(row.get('stream'))}</code></td>"
            f"<td>#{h(row.get('regionIndex'))}</td>"
            f"<td>{h(row.get('rectText'))}</td>"
            f"<td>{h(row.get('status'))}</td>"
            "</tr>"
        )
    rows.append("</table></section>")

    rows.append('<section class="panel"><h2>Surface Composition</h2><table><tr><th>seq</th><th>frame</th><th>region</th><th>src rect</th><th>dst rect</th><th>flags</th><th>repeat</th><th>detail</th></tr>')
    for row in data["surfaceCompositionRows"]:
        region = row.get("region") or {}
        rows.append(
            "<tr>"
            f"<td>{h(row.get('firstSeq'))}..{h(row.get('lastSeq'))}</td>"
            f"<td>{h(row.get('firstFrame'))}..{h(row.get('lastFrame'))}</td>"
            f"<td>#{h(region.get('regionIndex'))} {h(region.get('name'))}</td>"
            f"<td><code>{h(row.get('srcRect'))}</code></td>"
            f"<td><code>{h(row.get('dstRect'))}</code></td>"
            f"<td><code>{h(row.get('flags'))}</code></td>"
            f"<td>{h(row.get('repeatCount'))}</td>"
            f"<td class=\"small\">{h(row.get('sampleDetail'))}</td>"
            "</tr>"
        )
    rows.append("</table></section>")

    rows.append('<section class="panel"><h2>Remaining Runtime Needs</h2><table><tr><th>item</th><th>reason</th><th>next trace</th></tr>')
    for row in data["remainingRuntimeNeeds"]:
        rows.append(
            "<tr>"
            f"<td class=\"pending\">{h(row.get('item'))}</td>"
            f"<td>{h(row.get('reason'))}</td>"
            f"<td>{h(row.get('nextTrace'))}</td>"
            "</tr>"
        )
    rows.append("</table></section>")

    HTML_OUT.write_text("\n".join(rows) + "\n", encoding="utf-8")


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


if __name__ == "__main__":
    main()
