#!/usr/bin/env python3
"""Trace event/object VM text source opcodes around the 0x0b text routine call."""
from __future__ import annotations

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

from probe_exe_scene_tables import classify_cns, find_cns_strings, read_sections, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
TEXT_SOURCE_OPCODES = {
    0x0B: {
        "name": "render current context text id",
        "length": 4,
        "handlerVaHex": "0x0041bb4c",
        "effect": "push context+0x28; call 0x0041b579; copy context+0xce/0xd2 to cursor 0xd6/0xda; advance +4",
        "source": "inherited context+0x28",
    },
    0x0C: {
        "name": "spawn child object with text/source id",
        "length": 0x14,
        "handlerVaHex": "0x0041bb99",
        "effect": "alloc object via 0x435b5b; child+0x1c=stream u16+4; child+0x20=stream u16+6; child+0x40=stream dword+8; child+0x28=stream dword+0x0c; advance +0x14",
        "source": "stream dword at +0x0c, but command-start proof is required",
    },
    0x0D: {
        "name": "set current context text id",
        "length": 8,
        "handlerVaHex": "0x0041bca4",
        "effect": "context+0x28=stream dword+4; use low 16 bits as index into 0x55b134 to seed context+0xce/0xd2/0xd6/0xda; advance +8",
        "source": "stream dword at +4",
    },
}
TEXT_RE = re.compile(r"[\u3131-\u318e\uac00-\ud7a3A-Za-z0-9][\u3131-\u318e\uac00-\ud7a3A-Za-z0-9 　!?.:_+-]{1,}")


def hex32(value: int) -> str:
    return f"0x{value:08x}"


def hex8(value: int) -> str:
    return f"0x{value:02x}"


def load_json(path: Path, default):
    if not path.exists():
        return default
    return json.loads(path.read_text(encoding="utf-8"))


def compact_text(text: str, limit: int = 180) -> str:
    text = " / ".join(str(text or "").splitlines()).strip()
    if len(text) <= limit:
        return text
    return text[: limit - 1] + "..."


def va_for_offset(sections: list[dict], offset: int) -> int | None:
    for section in sections:
        if section["raw"] <= offset < section["raw"] + section["raw_size"]:
            return section["va"] + offset - section["raw"]
    return None


def section_for_va(sections: list[dict], va: int) -> dict | None:
    for section in sections:
        if section["va"] <= va < section["va"] + section["raw_size"]:
            return section
    return None


def section_for_offset(sections: list[dict], offset: int) -> dict | None:
    for section in sections:
        if section["raw"] <= offset < section["raw"] + section["raw_size"]:
            return section
    return None


def dword_at(exe: bytes, offset: int) -> int | None:
    if offset < 0 or offset + 4 > len(exe):
        return None
    return struct.unpack_from("<I", exe, offset)[0]


def word_at(exe: bytes, offset: int) -> int | None:
    if offset < 0 or offset + 2 > len(exe):
        return None
    return struct.unpack_from("<H", exe, offset)[0]


def pointer_target(sections: list[dict], value: int | None) -> dict | None:
    if value is None:
        return None
    section = section_for_va(sections, value)
    if section is None:
        return None
    return {
        "targetVaHex": hex32(value),
        "targetSection": section["name"],
    }


def pointer_ref_count(exe: bytes, sections: list[dict], target: int) -> int:
    needle = struct.pack("<I", target)
    count = 0
    search = 0
    while True:
        hit = exe.find(needle, search)
        if hit < 0:
            return count
        search = hit + 1
        if section_for_offset(sections, hit) is not None:
            count += 1


def text_snippets(data: bytes, base_va: int) -> list[dict]:
    snippets = []
    current = bytearray()
    current_start = 0
    for index, byte in enumerate(data + b"\0"):
        if byte in {0x00, 0x40}:
            if len(current) >= 4:
                decoded = current.decode("cp949", "ignore").strip("\0 ")
                for match in TEXT_RE.finditer(decoded):
                    text = match.group(0).strip()
                    hangul_count = sum(
                        1
                        for char in text
                        if "\u3131" <= char <= "\u318e" or "\uac00" <= char <= "\ud7a3"
                    )
                    if len(text) >= 2 and hangul_count >= 2:
                        snippets.append({
                            "vaHex": hex32(base_va + current_start),
                            "text": text,
                        })
            current.clear()
            current_start = index + 1
        else:
            if not current:
                current_start = index
            current.append(byte)
    deduped = []
    seen = set()
    for item in snippets:
        key = item["text"]
        if key in seen:
            continue
        seen.add(key)
        deduped.append(item)
    return deduped[:10]


def linked_cns_near(exe: bytes, sections: list[dict], strings: dict[int, str], offset: int, radius: int = 0x80) -> list[dict]:
    start = max(0, offset - radius)
    end = min(len(exe) - 4, offset + radius)
    rows = []
    seen = set()
    for off in range(start - (start % 4), end + 1, 4):
        value = dword_at(exe, off)
        if value not in strings:
            continue
        key = (off, value)
        if key in seen:
            continue
        seen.add(key)
        rows.append({
            "refVaHex": hex32(va_for_offset(sections, off) or 0),
            "name": strings[value],
            "kind": classify_cns(strings[value]),
            "distance": off - offset,
            "evidenceStatus": "nearby-cns-proximity",
        })
    return rows[:12]


def route_windows(event_transitions: list[dict]) -> list[dict]:
    windows = []
    for event in event_transitions:
        record_va = int(event["recordVaHex"], 16)
        for choice in event.get("conditionChoices") or []:
            condition_va = int(choice["conditionVaHex"], 16)
            windows.append({
                "startVa": condition_va,
                "endVa": record_va,
                "startVaHex": choice["conditionVaHex"],
                "endVaHex": event["recordVaHex"],
                "map": event.get("map"),
                "recordVaHex": event["recordVaHex"],
                "targets": choice.get("targets") or [],
            })
    return windows


def command_route_context(va: int, windows: list[dict]) -> list[dict]:
    rows = []
    for window in windows:
        if window["startVa"] <= va < window["endVa"]:
            rows.append({
                "map": window["map"],
                "recordVaHex": window["recordVaHex"],
                "conditionVaHex": window["startVaHex"],
                "targets": window["targets"],
            })
    return rows


def build_link_context(story_prompts: dict | None, story_flow_review: dict | None) -> dict[str, Any]:
    flow_rows = {
        row.get("id"): row
        for row in (story_flow_review or {}).get("rows", [])
        if row.get("id")
    }
    flow_groups = {
        group.get("id"): {
            "id": group.get("id"),
            "kind": group.get("kind"),
            "key": group.get("key"),
            "stabilityClass": group.get("stabilityClass"),
            "confidenceStatus": group.get("confidenceStatus"),
            "remainingRisk": group.get("remainingRisk"),
            "promptCount": group.get("promptCount"),
            "firstPromptId": group.get("firstPromptId"),
            "lastPromptId": group.get("lastPromptId"),
        }
        for group in (story_flow_review or {}).get("flowGroups", [])
        if group.get("id")
    }
    prompts_by_render: dict[str, list[dict]] = {}
    for prompt in (story_prompts or {}).get("prompts", []):
        render_va = prompt.get("renderVaHex")
        prompt_id = prompt.get("id")
        if not render_va or not prompt_id:
            continue
        flow_row = flow_rows.get(prompt_id, {})
        entry = {
            "id": prompt_id,
            "globalIndex": prompt.get("globalIndex"),
            "classification": prompt.get("classification"),
            "status": prompt.get("status"),
            "renderVaHex": render_va,
            "waitVaHex": prompt.get("waitVaHex"),
            "displayText": compact_text(prompt.get("displayText") or prompt.get("text") or ""),
            "resourceNames": prompt.get("resourceNames") or [],
            "fieldMaps": prompt.get("fieldMaps") or [],
            "tilesets": prompt.get("tilesets") or [],
            "routeContexts": prompt.get("routeContexts") or [],
            "flowEvidenceClass": flow_row.get("flowEvidenceClass"),
            "flowGroupIds": flow_row.get("flowGroupIds") or [],
        }
        prompts_by_render.setdefault(render_va, []).append(entry)
    return {
        "promptsByRender": prompts_by_render,
        "flowGroupsById": flow_groups,
    }


def cns_evidence_status(row: dict) -> str:
    if row.get("routeContexts"):
        return "route-window-candidate"
    if row.get("nearbyCns"):
        return "nearby-cns-proximity"
    return "unbound"


def cns_evidence_note(status: str) -> str:
    if status == "route-window-candidate":
        return (
            "command VA falls inside a known route-condition window, but this still "
            "does not prove story-dialogue playback by itself"
        )
    if status == "nearby-cns-proximity":
        return (
            "CNS pointers are adjacent to the text command in the local static block; "
            "treat this as resource adjacency, not a canonical map/dialogue binding"
        )
    return "no CNS or route-window evidence attached to this command"


def enrich_row_links(row: dict, link_context: dict[str, Any]) -> None:
    linked_prompts = list((link_context.get("promptsByRender") or {}).get(row["vaHex"], []))
    row["linkedPromptCount"] = len(linked_prompts)
    row["linkedPrompts"] = linked_prompts[:16]
    group_ids = []
    seen_group_ids = set()
    for prompt in linked_prompts:
        for group_id in prompt.get("flowGroupIds") or []:
            if group_id in seen_group_ids:
                continue
            seen_group_ids.add(group_id)
            group_ids.append(group_id)
    groups_by_id = link_context.get("flowGroupsById") or {}
    row["linkedFlowGroups"] = [
        groups_by_id[group_id]
        for group_id in group_ids[:12]
        if group_id in groups_by_id
    ]
    row["linkedFlowGroupCount"] = len(group_ids)
    status = cns_evidence_status(row)
    row["cnsEvidenceStatus"] = status
    row["cnsEvidenceNote"] = cns_evidence_note(status)


def decode_payload(exe: bytes, offset: int, opcode: int) -> dict:
    if opcode == 0x0B:
        return {
            "sourceKind": "inherited-context",
            "sourceDescription": "opcode 0x0b carries no immediate text id; it renders the current object's context+0x28",
        }
    if opcode == 0x0C:
        text_id = dword_at(exe, offset + 0x0C)
        return {
            "sourceKind": "child-context-stream",
            "childType": exe[offset + 2] if offset + 2 < len(exe) else None,
            "childTypeHex": hex8(exe[offset + 2]) if offset + 2 < len(exe) else None,
            "x": word_at(exe, offset + 4),
            "y": word_at(exe, offset + 6),
            "field40Hex": hex32(dword_at(exe, offset + 8)) if dword_at(exe, offset + 8) is not None else None,
            "textSourceValue": text_id,
            "textSourceValueHex": hex32(text_id) if text_id is not None else None,
            "textSourceLow16Hex": f"0x{text_id & 0xffff:04x}" if text_id is not None else None,
            "sourceDescription": "would write stream+0x0c into the spawned child object's context+0x28",
        }
    text_id = dword_at(exe, offset + 4)
    return {
        "sourceKind": "current-context-stream",
        "textSourceValue": text_id,
        "textSourceValueHex": hex32(text_id) if text_id is not None else None,
        "textSourceLow16Hex": f"0x{text_id & 0xffff:04x}" if text_id is not None else None,
        "sourceDescription": "writes stream+4 into the current object's context+0x28",
    }


def classify_command(row: dict) -> str:
    if row.get("currentDwordPointer"):
        return "aligned pointer byte overlap, not a proven event/object command start"
    if row["opcodeHex"] == "0x0b":
        return "plausible render command; text id is inherited from current context+0x28"
    if row["opcodeHex"] == "0x0d":
        return "plausible source command; sets current context+0x28 from stream+4"
    if row["opcodeHex"] == "0x0c":
        return "candidate child text-source command, but current hits need command-start proof"
    return "unclassified"


def command_confidence(row: dict) -> str:
    if row.get("currentDwordPointer"):
        return "low"
    if row["opcodeHex"] in {"0x0b", "0x0d"} and row.get("followingByteHex") in {"0x00", "0x01", "0x02", "0x03", "0x04"}:
        return "medium"
    return "weak"


def scan_commands(
    exe: bytes,
    sections: list[dict],
    strings: dict[int, str],
    windows: list[dict],
    link_context: dict[str, Any],
) -> list[dict]:
    rows = []
    for section in sections:
        if section["name"] not in {".data", ".rdata"}:
            continue
        raw_start = section["raw"]
        raw = exe[raw_start: raw_start + section["raw_size"]]
        for opcode in TEXT_SOURCE_OPCODES:
            pattern = bytes([0x40, opcode])
            search = 0
            while True:
                hit = raw.find(pattern, search)
                if hit < 0:
                    break
                search = hit + 1
                offset = raw_start + hit
                va = section["va"] + hit
                current = dword_at(exe, offset)
                nearby_start = max(0, offset - 0x80)
                nearby_end = min(len(exe), offset + 0x100)
                row = {
                    "va": va,
                    "vaHex": hex32(va),
                    "section": section["name"],
                    "fileOffsetHex": f"0x{offset:06x}",
                    "opcode": opcode,
                    "opcodeHex": hex8(opcode),
                    "handlerVaHex": TEXT_SOURCE_OPCODES[opcode]["handlerVaHex"],
                    "offsetMod4": offset % 4,
                    "currentDwordHex": hex32(current) if current is not None else None,
                    "currentDwordPointer": pointer_target(sections, current),
                    "pointerRefCount": pointer_ref_count(exe, sections, va),
                    "followingByteHex": hex8(exe[offset + 2]) if offset + 2 < len(exe) else None,
                    "payload": decode_payload(exe, offset, opcode),
                    "nearbyCns": linked_cns_near(exe, sections, strings, offset),
                    "nearbyTextSnippets": text_snippets(exe[nearby_start:nearby_end], va_for_offset(sections, nearby_start) or 0),
                    "routeContexts": command_route_context(va, windows),
                }
                row["classification"] = classify_command(row)
                row["confidence"] = command_confidence(row)
                enrich_row_links(row, link_context)
                rows.append(row)
    rows.sort(key=lambda row: (row["opcode"], row["va"]))
    return rows


def selected_rows(rows: list[dict]) -> list[dict]:
    selected = []
    for row in rows:
        if row.get("linkedPromptCount"):
            selected.append(row)
        elif row["opcodeHex"] == "0x0c":
            selected.append(row)
        elif row.get("routeContexts"):
            selected.append(row)
        elif row["opcodeHex"] == "0x0d" and (row.get("nearbyCns") or row.get("nearbyTextSnippets")):
            selected.append(row)
        elif row["opcodeHex"] == "0x0b" and row.get("nearbyCns"):
            selected.append(row)
        if len(selected) >= 180:
            break
    return selected


def build_summary(
    exe: bytes,
    event_transitions: list[dict] | None = None,
    story_prompts: dict | None = None,
    story_flow_review: dict | None = None,
) -> dict:
    sections = read_sections(exe)
    strings = find_cns_strings(exe, sections)
    windows = route_windows(event_transitions or [])
    link_context = build_link_context(story_prompts, story_flow_review)
    rows = scan_commands(exe, sections, strings, windows, link_context)
    counts = Counter(row["opcodeHex"] for row in rows)
    confidence_counts = Counter(row["confidence"] for row in rows)
    cns_evidence_counts = Counter(row.get("cnsEvidenceStatus", "unbound") for row in rows)
    pointer_overlap_counts = Counter(row["opcodeHex"] for row in rows if row.get("currentDwordPointer"))
    route_rows = [row for row in rows if row.get("routeContexts")]
    linked_prompt_rows = [row for row in rows if row.get("linkedPromptCount")]
    linked_flow_rows = [row for row in rows if row.get("linkedFlowGroupCount")]
    opcode0c_rows = [row for row in rows if row["opcodeHex"] == "0x0c"]
    opcode0d_rows = [row for row in rows if row["opcodeHex"] == "0x0d" and row["confidence"] == "medium"]
    source_values = Counter(
        row.get("payload", {}).get("textSourceValueHex")
        for row in opcode0d_rows
        if row.get("payload", {}).get("textSourceValueHex")
    )
    opcode0c_all_pointer_overlap = bool(opcode0c_rows) and all(row.get("currentDwordPointer") for row in opcode0c_rows)
    return {
        "scope": "event/object VM text source flow around opcode 0x0b -> 0x0041b579",
        "handlerEffects": [
            {"opcodeHex": hex8(opcode), **meta}
            for opcode, meta in TEXT_SOURCE_OPCODES.items()
        ],
        "commandCount": len(rows),
        "commandCountsByOpcode": dict(sorted(counts.items())),
        "confidenceCounts": dict(sorted(confidence_counts.items())),
        "cnsEvidenceCounts": dict(sorted(cns_evidence_counts.items())),
        "pointerOverlapCountsByOpcode": dict(sorted(pointer_overlap_counts.items())),
        "routeLinkedCommandCount": len(route_rows),
        "linkedPromptCommandCount": len(linked_prompt_rows),
        "linkedFlowCommandCount": len(linked_flow_rows),
        "linkedPromptTotal": sum(row.get("linkedPromptCount", 0) for row in rows),
        "opcode0cAllPointerOverlap": opcode0c_all_pointer_overlap,
        "opcode0dMediumCommandCount": len(opcode0d_rows),
        "opcode0dTopSourceValues": [
            {"valueHex": value, "count": count}
            for value, count in source_values.most_common(12)
        ],
        "conclusion": (
            "Opcode 0x0b renders the current object's context+0x28 through text routine candidate 0x0041b579. "
            "Opcode 0x0d is the direct current-context producer seen in static byte streams, setting context+0x28 from stream+4. "
            "The three apparent opcode 0x0c child-context producer hits are aligned pointer-byte overlaps, including the two "
            "inside the confirmed map1_02b -> map1_01a condition block, so they are not promoted as executed text-source commands. "
            "This review now cross-links 0x0b render commands to wait-delimited story prompts and story-flow groups when available. "
            "CNS links are evidence-tiered: nearby-cns-proximity means static resource adjacency only, and route-window-candidate "
            "still needs dispatcher/runtime proof before it can be treated as a canonical map/dialogue binding."
        ),
        "selectedRows": selected_rows(rows),
        "rows": rows,
    }


def command_summary(row: dict) -> str:
    payload = row.get("payload") or {}
    if row["opcodeHex"] == "0x0b":
        return "inherits context+0x28"
    if payload.get("textSourceValueHex"):
        return f"{payload['sourceKind']} {payload['textSourceValueHex']} low16={payload.get('textSourceLow16Hex')}"
    return payload.get("sourceKind") or "-"


def context_summary(row: dict) -> str:
    contexts = row.get("routeContexts") or []
    if not contexts:
        return "-"
    return ", ".join(
        f"{item.get('map')}->{','.join(item.get('targets') or []) or '?'} {item.get('conditionVaHex')}..{item.get('recordVaHex')}"
        for item in contexts
    )


def cns_summary(row: dict) -> str:
    items = row.get("nearbyCns") or []
    if not items:
        return row.get("cnsEvidenceStatus") or "-"
    names = ", ".join(
        f"{item.get('name')}:{item.get('kind', '?')}({item.get('distance', 0):+d})"
        for item in items[:6]
    )
    return f"{row.get('cnsEvidenceStatus', '-')}: {names}"


def prompt_summary(row: dict) -> str:
    prompts = row.get("linkedPrompts") or []
    if not prompts:
        return "-"
    return "; ".join(
        f"{prompt.get('id')} {compact_text(prompt.get('displayText', ''), 90)}"
        for prompt in prompts[:4]
    )


def flow_group_summary(row: dict) -> str:
    groups = row.get("linkedFlowGroups") or []
    if not groups:
        return "-"
    return ", ".join(
        f"{group.get('id')}:{group.get('stabilityClass')}"
        for group in groups[:6]
    )


def row_samples(row: dict) -> str:
    samples = []
    samples.extend(item.get("text", "") for item in row.get("nearbyTextSnippets") or [])
    return ", ".join(sample for sample in samples[:8] if sample) or "-"


def md_cell(value: str) -> str:
    return str(value or "-").replace("|", "\\|").replace("\n", "<br>")


def markdown(summary: dict) -> str:
    lines = [
        "# Event Text Source Flow",
        "",
        summary["conclusion"],
        "",
        f"- commands: {summary['commandCount']}",
        f"- by opcode: {json.dumps(summary['commandCountsByOpcode'], ensure_ascii=False)}",
        f"- confidence: {json.dumps(summary['confidenceCounts'], ensure_ascii=False)}",
        f"- cns evidence: {json.dumps(summary['cnsEvidenceCounts'], ensure_ascii=False)}",
        f"- pointer overlaps: {json.dumps(summary['pointerOverlapCountsByOpcode'], ensure_ascii=False)}",
        f"- route-linked commands: {summary['routeLinkedCommandCount']}",
        f"- prompt-linked commands: {summary['linkedPromptCommandCount']}",
        f"- flow-linked commands: {summary['linkedFlowCommandCount']}",
        f"- opcode 0x0c all pointer overlap: {summary['opcode0cAllPointerOverlap']}",
        f"- opcode 0x0d medium commands: {summary['opcode0dMediumCommandCount']}",
        "",
        "## Handler Effects",
        "",
        "| opcode | handler | source | effect |",
        "| --- | --- | --- | --- |",
    ]
    for row in summary.get("handlerEffects") or []:
        lines.append(
            f"| `{row['opcodeHex']}` {row['name']} | `{row['handlerVaHex']}` | "
            f"{row['source']} | {row['effect']} |"
        )
    lines.extend([
        "",
        "## Top 0x0d Source Values",
        "",
        "| value | count |",
        "| --- | ---: |",
    ])
    for row in summary.get("opcode0dTopSourceValues") or []:
        lines.append(f"| `{row['valueHex']}` | {row['count']} |")
    if not summary.get("opcode0dTopSourceValues"):
        lines.append("| - | - |")
    lines.extend([
        "",
        "## Selected Commands",
        "",
        "| va | opcode | confidence | cns evidence | prompts | flow groups | source | route context | text snippets |",
        "| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
    ])
    for row in summary.get("selectedRows") or []:
        lines.append(
            f"| `{row['vaHex']}` | `{row['opcodeHex']}` | {row['confidence']} | "
            f"{md_cell(cns_summary(row))} | {md_cell(prompt_summary(row))} | "
            f"{md_cell(flow_group_summary(row))} | {md_cell(command_summary(row))} | "
            f"{md_cell(context_summary(row))} | {md_cell(row_samples(row))} |"
        )
    if not summary.get("selectedRows"):
        lines.append("| - | - | - | - | - | - | - | - | - |")
    return "\n".join(lines) + "\n"


def html_page(summary: dict) -> str:
    effect_rows = []
    for row in summary.get("handlerEffects") or []:
        effect_rows.append(
            "<tr>"
            f"<td><code>{html.escape(row['opcodeHex'])}</code><br>{html.escape(row['name'])}</td>"
            f"<td><code>{html.escape(row['handlerVaHex'])}</code></td>"
            f"<td>{html.escape(row['source'])}</td>"
            f"<td>{html.escape(row['effect'])}</td>"
            "</tr>"
        )
    source_rows = "".join(
        f"<tr><td><code>{html.escape(row['valueHex'])}</code></td><td>{row['count']}</td></tr>"
        for row in summary.get("opcode0dTopSourceValues") or []
    ) or '<tr><td colspan="2">No values.</td></tr>'
    return "\n".join([
        "<!doctype html>",
        '<html lang="ko">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>Event Text Source Flow</title>",
        "  <style>",
        "    :root{color-scheme:dark;--bg:#101214;--panel:#171a1e;--panel2:#20242a;--line:#303740;--text:#edf0f3;--muted:#aeb7c2;--accent:#7cc7ff;--warn:#ffd166;--good:#7bd88f}",
        "    *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}",
        "    .topbar{position:sticky;top:0;z-index:10;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:14px 18px;background:#0d0f12;border-bottom:1px solid var(--line)}",
        "    h1{font-size:20px;margin:0}h2{font-size:15px;margin:0 0 10px}nav{display:flex;gap:10px;flex-wrap:wrap}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}",
        "    .wrap{padding:18px;display:grid;gap:16px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:14px}.summary{color:var(--muted);max-width:1200px}",
        "    .metrics{display:flex;gap:8px;flex-wrap:wrap}.chip{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--line);border-radius:999px;padding:2px 8px;background:var(--panel2);color:var(--muted);white-space:nowrap}.chip.good{color:var(--good);border-color:#2f6b3b}.chip.warn{color:var(--warn);border-color:#765f25}.chip.blue{color:var(--accent);border-color:#285c7e}",
        "    .filters{display:flex;gap:10px;align-items:center;flex-wrap:wrap}input[type=search],select{background:#0f1115;color:var(--text);border:1px solid var(--line);border-radius:6px;padding:8px 10px}input[type=search]{min-width:320px;flex:1}",
        "    .layout{display:grid;grid-template-columns:minmax(0,1fr) 420px;gap:16px;align-items:start}.tablewrap{overflow:auto;max-height:72vh;border:1px solid var(--line);border-radius:8px}",
        "    table{border-collapse:collapse;width:100%;background:var(--panel)}th,td{border-bottom:1px solid var(--line);padding:7px 8px;vertical-align:top;text-align:left}th{position:sticky;top:0;background:#1d2228;z-index:1;color:#dbe4ee}tr{cursor:pointer}tr:hover,tr.selected{background:#202832}",
        "    code{color:#f2d479}.muted{color:var(--muted)}small{color:var(--muted)}pre{white-space:pre-wrap;margin:0;background:#0f1115;border:1px solid var(--line);border-radius:6px;padding:10px}.refs{display:grid;gap:8px}.ref-row{border:1px solid var(--line);border-radius:6px;padding:8px;background:#111419}.kv{display:grid;grid-template-columns:118px minmax(0,1fr);gap:6px 10px}.kv dt{color:var(--muted)}.kv dd{margin:0;min-width:0}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}",
        "    @media (max-width:980px){.layout{grid-template-columns:1fr}.tablewrap{max-height:none}input[type=search]{min-width:100%}}",
        "  </style>",
        "</head>",
        "<body>",
        "  <div class=\"topbar\"><h1>Event Text Source Flow</h1><nav><a href=\"../web/index.html\">관리 홈</a><a href=\"story_prompts.html\">대사 프롬프트</a><a href=\"story_flow_review.html\">대사 흐름</a></nav></div>",
        "  <div class=\"wrap\">",
        f"    <section class=\"panel summary\"><p>{html.escape(summary['conclusion'])}</p><div class=\"metrics\">",
        f"      <span class=\"chip\">commands {summary['commandCount']}</span>",
        f"      <span class=\"chip blue\">prompt-linked {summary['linkedPromptCommandCount']}</span>",
        f"      <span class=\"chip blue\">flow-linked {summary['linkedFlowCommandCount']}</span>",
        f"      <span class=\"chip warn\">route-window {summary['routeLinkedCommandCount']}</span>",
        f"      <span class=\"chip\">0x0c pointer-overlap {summary['opcode0cAllPointerOverlap']}</span>",
        f"      <span class=\"chip\">cns {html.escape(json.dumps(summary['cnsEvidenceCounts'], ensure_ascii=False))}</span>",
        "    </div></section>",
        "    <section class=\"panel\"><h2>Handler Effects</h2><div class=\"tablewrap\" style=\"max-height:none\"><table><thead><tr><th>opcode</th><th>handler</th><th>source</th><th>effect</th></tr></thead>",
        f"    <tbody>{''.join(effect_rows)}</tbody></table></div></section>",
        "    <section class=\"panel\"><h2>Top 0x0d Source Values</h2><table><thead><tr><th>value</th><th>count</th></tr></thead><tbody>",
        f"    {source_rows}</tbody></table></section>",
        "    <section class=\"panel\"><h2>Commands</h2><div class=\"filters\"><input id=\"search\" type=\"search\" placeholder=\"prompt id, text, cns, va, flow group\"><select id=\"status\"><option value=\"\">all evidence</option></select><label class=\"chip\"><input id=\"promptOnly\" type=\"checkbox\"> prompt-linked only</label><span id=\"count\" class=\"muted\"></span></div></section>",
        "    <main class=\"layout\"><section class=\"tablewrap\"><table><thead><tr><th>va</th><th>opcode</th><th>evidence</th><th>prompt</th><th>flow</th><th>cns</th></tr></thead><tbody id=\"rows\"><tr><td colspan=\"6\">Loading...</td></tr></tbody></table></section><aside class=\"panel\"><h2 id=\"detailTitle\">Detail</h2><div id=\"detail\" class=\"muted\">Select a command row.</div></aside></main>",
        "  </div>",
        "  <script>",
        "    const DATA_URL='event_text_source_flow.json';",
        "    let data=null;let selectedKey='';",
        "    const $=id=>document.getElementById(id);",
        "    const esc=v=>String(v??'').replace(/[&<>\"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[ch]));",
        "    const short=(v,n=120)=>{v=String(v??'').replace(/\\s+/g,' ').trim();return v.length>n?v.slice(0,n-1)+'...':v};",
        "    const keyOf=row=>`${row.vaHex}:${row.opcodeHex}`;",
        "    function chip(text,cls=''){return `<span class=\"chip ${cls}\">${esc(text||'-')}</span>`}",
        "    function statusClass(status){return status==='nearby-cns-proximity'?'warn':status==='route-window-candidate'?'blue':status==='unbound'?'':'good'}",
        "    function promptText(row){return (row.linkedPrompts||[]).map(p=>`${p.id} ${p.displayText||''}`).join(' | ')}",
        "    function flowText(row){return (row.linkedFlowGroups||[]).map(g=>`${g.id}:${g.stabilityClass||''}`).join(', ')}",
        "    function cnsText(row){return (row.nearbyCns||[]).map(c=>`${c.name}:${c.kind||''}`).join(', ')}",
        "    function rowSearch(row){return [row.vaHex,row.opcodeHex,row.confidence,row.classification,row.cnsEvidenceStatus,promptText(row),flowText(row),cnsText(row),JSON.stringify(row.nearbyTextSnippets||[])].join(' ').toLowerCase()}",
        "    function renderRows(){const q=$('search').value.trim().toLowerCase();const status=$('status').value;const promptOnly=$('promptOnly').checked;const filtered=data.rows.filter(row=>(!status||(row.cnsEvidenceStatus||'unbound')===status)&&(!promptOnly||row.linkedPromptCount)&&(!q||rowSearch(row).includes(q)));$('count').textContent=`${filtered.length} / ${data.rows.length}`;const rows=filtered.slice(0,1500);$('rows').innerHTML=rows.map(row=>{const firstPrompt=(row.linkedPrompts||[])[0];const cns=(row.nearbyCns||[]).slice(0,3).map(c=>`${c.name}`).join(', ');const flow=(row.linkedFlowGroups||[]).slice(0,2).map(g=>g.id).join(', ');return `<tr data-key=\"${esc(keyOf(row))}\"><td><code>${esc(row.vaHex)}</code><br><small>${esc(row.confidence)}</small></td><td><code>${esc(row.opcodeHex)}</code></td><td>${chip(row.cnsEvidenceStatus,statusClass(row.cnsEvidenceStatus))}<br><small>${esc(short(row.classification,80))}</small></td><td>${firstPrompt?`<code>${esc(firstPrompt.id)}</code><br><small>${esc(short(firstPrompt.displayText,120))}</small>`:'-'}</td><td>${flow?esc(flow):'-'}</td><td>${cns?esc(cns):'-'}</td></tr>`}).join('')||'<tr><td colspan=\"6\">No rows.</td></tr>';for(const tr of $('rows').querySelectorAll('tr[data-key]'))tr.addEventListener('click',()=>selectRow(tr.dataset.key));if(filtered.length>1500)$('rows').insertAdjacentHTML('beforeend','<tr><td colspan=\"6\" class=\"muted\">Showing first 1500 rows. Use filters to narrow the list.</td></tr>');highlightSelected()}",
        "    function highlightSelected(){for(const tr of document.querySelectorAll('#rows tr[data-key]'))tr.classList.toggle('selected',tr.dataset.key===selectedKey)}",
        "    function promptHtml(row){const prompts=row.linkedPrompts||[];if(!prompts.length)return '<p class=\"muted\">No linked prompt.</p>';return `<div class=\"refs\">${prompts.map(p=>`<div class=\"ref-row\"><a href=\"story_prompts.html#${encodeURIComponent(p.id)}\"><code>${esc(p.id)}</code></a> <a href=\"story_flow_review.html?prompt=${encodeURIComponent(p.id)}\">flow</a><br><small>${esc(p.classification||'')} · wait ${esc(p.waitVaHex||'-')} · ${esc(p.flowEvidenceClass||'-')}</small><pre>${esc(p.displayText||'')}</pre></div>`).join('')}</div>`}",
        "    function groupsHtml(row){const groups=row.linkedFlowGroups||[];if(!groups.length)return '<p class=\"muted\">No linked flow group.</p>';return `<div class=\"refs\">${groups.map(g=>`<div class=\"ref-row\"><a href=\"story_flow_review.html?group=${encodeURIComponent(g.id)}\"><code>${esc(g.id)}</code></a> ${chip(g.stabilityClass||'-','blue')}<br><small>${esc(g.kind||'-')} · ${esc(g.key||'-')} · prompts ${esc(g.promptCount||'-')}</small><div>${esc(g.confidenceStatus||'')}</div><div class=\"muted\">${esc(g.remainingRisk||'')}</div></div>`).join('')}</div>`}",
        "    function cnsHtml(row){const cns=row.nearbyCns||[];if(!cns.length)return '<p class=\"muted\">No nearby CNS pointer.</p>';return `<div class=\"refs\">${cns.map(c=>`<div class=\"ref-row\"><code>${esc(c.name)}</code> ${chip(c.kind||'-')}<br><small>ref ${esc(c.refVaHex)} · distance ${esc(c.distance)}</small></div>`).join('')}</div>`}",
        "    function routeHtml(row){const contexts=row.routeContexts||[];if(!contexts.length)return '<p class=\"muted\">No route window context.</p>';return `<div class=\"refs\">${contexts.map(c=>`<div class=\"ref-row\"><code>${esc(c.map||'-')}</code> -> ${esc((c.targets||[]).join(', ')||'?')}<br><small>${esc(c.conditionVaHex)}..${esc(c.recordVaHex)}</small></div>`).join('')}</div>`}",
        "    function textSnippetHtml(row){const snippets=row.nearbyTextSnippets||[];if(!snippets.length)return '<p class=\"muted\">No nearby decoded text snippets.</p>';return `<div class=\"refs\">${snippets.map(s=>`<div class=\"ref-row\"><code>${esc(s.vaHex)}</code><div>${esc(s.text)}</div></div>`).join('')}</div>`}",
        "    function detailHtml(row){return `<dl class=\"kv\"><dt>source</dt><dd>${esc(row.payload?.sourceDescription||'-')}</dd><dt>classification</dt><dd>${esc(row.classification||'-')}</dd><dt>CNS evidence</dt><dd>${chip(row.cnsEvidenceStatus,statusClass(row.cnsEvidenceStatus))}<div class=\"muted\">${esc(row.cnsEvidenceNote||'')}</div></dd><dt>file offset</dt><dd><code>${esc(row.fileOffsetHex)}</code></dd><dt>handler</dt><dd><code>${esc(row.handlerVaHex)}</code></dd></dl><h2>Linked Prompts</h2>${promptHtml(row)}<h2>Flow Groups</h2>${groupsHtml(row)}<h2>Nearby CNS</h2>${cnsHtml(row)}<h2>Route Window Context</h2>${routeHtml(row)}<h2>Nearby Text Snippets</h2>${textSnippetHtml(row)}`}",
        "    function selectRow(key){selectedKey=key;const row=data.rows.find(r=>keyOf(r)===key);if(!row)return;highlightSelected();$('detailTitle').textContent=`${row.vaHex} ${row.opcodeHex}`;$('detail').classList.remove('muted');$('detail').innerHTML=detailHtml(row);history.replaceState(null,'',`?cmd=${encodeURIComponent(key)}`)}",
        "    async function load(){const res=await fetch(DATA_URL,{cache:'no-store'});data=await res.json();const statuses=[...new Set(data.rows.map(r=>r.cnsEvidenceStatus||'unbound'))].sort();$('status').innerHTML='<option value=\"\">all evidence</option>'+statuses.map(s=>`<option value=\"${esc(s)}\">${esc(s)}</option>`).join('');$('search').addEventListener('input',renderRows);$('status').addEventListener('change',renderRows);$('promptOnly').addEventListener('change',renderRows);renderRows();const params=new URLSearchParams(location.search);const cmd=params.get('cmd');if(cmd){selectRow(cmd)}else{const first=data.rows.find(r=>r.linkedPromptCount)||data.rows[0];if(first)selectRow(keyOf(first));}}",
        "    load().catch(err=>{$('rows').innerHTML=`<tr><td colspan=\"6\">Failed to load JSON: ${esc(err.message)}</td></tr>`});",
        "  </script>",
        "</body>",
        "</html>",
        "",
    ])


def write_outputs(summary: dict, out_dir: Path = OUT) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "event_text_source_flow.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "event_text_source_flow.html").write_text(html_page(summary), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--event-transitions", type=Path, default=OUT / "event_transitions.json")
    parser.add_argument("--story-prompts", type=Path, default=OUT / "story_prompts.json")
    parser.add_argument("--story-flow-review", type=Path, default=OUT / "story_flow_review.json")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(
        args.exe.read_bytes(),
        load_json(args.event_transitions, []),
        load_json(args.story_prompts, {}),
        load_json(args.story_flow_review, {}),
    )
    write_outputs(summary, args.out_dir)
    print(
        "wrote event text source flow -> "
        f"{args.out_dir / 'event_text_source_flow.html'} "
        f"({summary['commandCount']} commands)"
    )


if __name__ == "__main__":
    main()
