#!/usr/bin/env python3
"""Build a Scene/Event text consumer trace review.

This report connects three layers without over-promoting route proof:

1. scene text sequence entries contain text pointers,
2. selected-root/opcode consumers can execute a root/stream,
3. event-object VM text opcodes consume the current text source.

The missing piece remains route binding: proving that a normal scene/event
record selects and executes the specific text root.
"""
from __future__ import annotations

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

from probe_exe_scene_tables import offset_to_va, read_sections


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

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


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


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


def section_for_va(sections: list[dict[str, Any]], va: int | None) -> dict[str, Any] | None:
    if va is None:
        return None
    for section in sections:
        start = section["va"]
        end = start + section["size"]
        if start <= va < end:
            return section
    return None


def find_dword_refs(exe: bytes, sections: list[dict[str, Any]], value: int) -> list[dict[str, Any]]:
    pattern = struct.pack("<I", value)
    rows: list[dict[str, Any]] = []
    offset = 0
    while True:
        hit = exe.find(pattern, offset)
        if hit < 0:
            break
        va = offset_to_va(sections, hit)
        section = section_for_va(sections, va)
        rows.append(
            {
                "fileOffsetHex": f"0x{hit:06x}",
                "refVa": va,
                "refVaHex": hx(va),
                "section": section["name"] if section else "file",
            }
        )
        offset = hit + 1
    return rows


def selected_group(sequence_review: dict[str, Any], prompt_review: dict[str, Any]) -> dict[str, Any]:
    selected_id = (prompt_review.get("summary") or {}).get("selectedGroupId")
    groups = sequence_review.get("groups") or []
    if selected_id:
        match = next((row for row in groups if row.get("id") == selected_id), None)
        if match:
            return match
    return groups[0] if groups else {}


def selected_sequence(group: dict[str, Any], prompt_review: dict[str, Any]) -> dict[str, Any]:
    selected_id = (prompt_review.get("summary") or {}).get("selectedSequenceId")
    sequences = group.get("sequences") or []
    if selected_id:
        match = next((row for row in sequences if row.get("id") == selected_id), None)
        if match:
            return match
    return sequences[0] if sequences else {}


def command_counts_from_prompts(prompts: list[dict[str, Any]]) -> Counter[str]:
    counts: Counter[str] = Counter()
    for prompt in prompts:
        for trace in prompt.get("trace") or []:
            opcode = trace.get("opcodeHex")
            if opcode:
                counts[opcode] += 1
    return counts


def prompt_consumer_rows(prompts: list[dict[str, Any]], limit: int = 80) -> list[dict[str, Any]]:
    rows = []
    for prompt in prompts[:limit]:
        trace = prompt.get("trace") or []
        render_rows = [row for row in trace if row.get("opcodeHex") == "0x0b"]
        source_rows = [row for row in trace if row.get("opcodeHex") == "0x0d"]
        wait_rows = [row for row in trace if row.get("opcodeHex") == "0x06"]
        rows.append(
            {
                "order": prompt.get("order"),
                "promptId": prompt.get("promptId") or prompt.get("id"),
                "status": prompt.get("status"),
                "startVaHex": prompt.get("startVaHex"),
                "renderVaHex": prompt.get("renderVaHex"),
                "waitVaHex": prompt.get("waitVaHex"),
                "renderOpcodeCount": len(render_rows),
                "sourceOpcodeCount": len(source_rows),
                "waitOpcodeCount": len(wait_rows),
                "consumerEvidence": "text-render-consumer-grounded" if render_rows else "no-render-opcode-in-slice",
                "text": " / ".join(str(prompt.get("displayText") or "").splitlines())[:240],
                "renderRows": [
                    {
                        "vaHex": row.get("vaHex"),
                        "rawBytes": row.get("rawBytes"),
                        "handlerVaHex": "0x0041bb4c",
                        "callsTextRoutineHex": "0x0041b579",
                    }
                    for row in render_rows[:6]
                ],
                "sourceRows": [
                    {
                        "vaHex": row.get("vaHex"),
                        "rawBytes": row.get("rawBytes"),
                        "handlerVaHex": "0x0041bca4",
                    }
                    for row in source_rows[:6]
                ],
            }
        )
    return rows


def entry_ref_rows(
    exe: bytes,
    sections: list[dict[str, Any]],
    entries: list[dict[str, Any]],
    limit: int = 80,
) -> list[dict[str, Any]]:
    rows = []
    for entry in entries[:limit]:
        entry_va = int(entry.get("entryVa") or 0)
        text_va = int(entry.get("textVa") or 0)
        text_refs = find_dword_refs(exe, sections, text_va) if text_va else []
        entry_refs = find_dword_refs(exe, sections, entry_va) if entry_va else []
        text_code_refs = [row for row in text_refs if row.get("section") == ".text"]
        expected_table_ref = entry_va + 4
        rows.append(
            {
                "entryVaHex": entry.get("entryVaHex") or hx(entry_va),
                "textVaHex": entry.get("textVaHex") or hx(text_va),
                "sentinelHex": entry.get("sentinelHex"),
                "promptCount": entry.get("promptCount"),
                "sample": entry.get("sample", "")[:240],
                "textVaRefCount": len(text_refs),
                "textVaCodeRefCount": len(text_code_refs),
                "entryVaRefCount": len(entry_refs),
                "expectedEntryTableRefHex": hx(expected_table_ref),
                "textVaRefs": text_refs[:8],
                "entryVaRefs": entry_refs[:8],
                "promotion": "entry-table-only" if not text_code_refs else "direct-code-ref-found",
            }
        )
    return rows


def build_payload() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    sequence_review = load_json(OUT / "scene_text_sequence_review.json", {})
    prompt_review = load_json(OUT / "scene_event_vm_prompt_sequence_review.json", {})
    selected_consumer = load_json(OUT / "selected_scene_text_root_consumer_review.json", {})
    handler_refs = load_json(OUT / "event_handler_text_refs.json", {})
    opcode_dict = load_json(OUT / "scene_event_vm_opcode_dictionary.json", {})
    map1_trace = load_json(OUT / "map1_01a_scene_trace.json", {})

    group = selected_group(sequence_review, prompt_review)
    sequence = selected_sequence(group, prompt_review)
    entries = sequence.get("entries") or []
    prompts = sequence.get("prompts") or []
    traced_prompts = ((map1_trace.get("map1_01aTrace") or {}).get("prompts") or [])
    prompt_rows = prompt_consumer_rows(traced_prompts or prompts)
    entry_rows = entry_ref_rows(exe, sections, entries)
    command_counts = command_counts_from_prompts(traced_prompts or prompts)

    root_va = int(group.get("rootVaHex") or "0", 16) if group.get("rootVaHex") else None
    root_refs = find_dword_refs(exe, sections, root_va) if root_va else []
    root_code_refs = [row for row in root_refs if row.get("section") == ".text"]

    opcodes = {
        row.get("opcodeHex"): row
        for row in opcode_dict.get("opcodeDictionary") or []
        if row.get("opcodeHex") in {"0x0b", "0x0d"}
    }
    event_handler_text_row = next(
        (
            row
            for row in handler_refs.get("rows") or []
            if "0x0b" in (row.get("opcodes") or [])
        ),
        {},
    )

    text_pointer_code_ref_count = sum(row["textVaCodeRefCount"] for row in entry_rows)
    text_pointer_ref_count = sum(row["textVaRefCount"] for row in entry_rows)
    rendered_prompt_count = sum(1 for row in prompt_rows if row["renderOpcodeCount"])

    summary = {
        "selectedGroupId": group.get("id"),
        "selectedSequenceId": sequence.get("id"),
        "selectedRootVaHex": group.get("rootVaHex"),
        "selectedRootEndVaHex": group.get("rootEndVaHex"),
        "selectedEvidenceStatus": group.get("evidenceStatus"),
        "entryCount": len(entries),
        "promptCount": len(prompts),
        "tracedPromptCount": len(traced_prompts),
        "rootRefCount": len(root_refs),
        "rootCodeRefCount": len(root_code_refs),
        "textPointerRefCount": text_pointer_ref_count,
        "textPointerCodeRefCount": text_pointer_code_ref_count,
        "renderPromptCount": rendered_prompt_count,
        "opcode0bRenderCommandCount": command_counts.get("0x0b", 0),
        "opcode0dSourceCommandCount": command_counts.get("0x0d", 0),
        "routeTextBindingProofFound": False,
        "textConsumerProofFound": bool(opcodes.get("0x0b") and event_handler_text_row),
        "promotionStatus": "text-consumer-grounded-route-binding-blocked",
    }

    decisions = [
        {
            "item": "text pointer direct consumer",
            "promotion": "blocked",
            "evidence": f"selected sequence text pointer refs={text_pointer_ref_count}, code refs={text_pointer_code_ref_count}",
            "remainingGap": "story text pointers are data-entry refs; no direct .text consumer of individual textVa values was found",
        },
        {
            "item": "selected root consumer",
            "promotion": "grounded",
            "evidence": "selected root global 0x0059de30 consumer is already grounded by selected_scene_text_root_consumer_review",
            "remainingGap": "route path still needs proof that the selected root is chosen for the current scene",
        },
        {
            "item": "render opcode consumer",
            "promotion": "grounded",
            "evidence": "event-object opcode 0x0b handler 0x0041bb4c pushes context+0x28 and calls text routine 0x0041b579",
            "remainingGap": "connect executed scene record -> selected root -> specific prompt stream",
        },
        {
            "item": "source opcode producer",
            "promotion": "grounded",
            "evidence": "event-object opcode 0x0d handler 0x0041bca4 writes stream+4 into context+0x28",
            "remainingGap": "per-prompt source id values are still interpreted by VM state, not by direct text pointer refs",
        },
    ]

    return {
        "scope": "scene/event text pointer consumer trace",
        "promotionStatus": summary["promotionStatus"],
        "summary": summary,
        "consumerChain": [
            "selector/root table stores a scene text root pointer",
            "selected root consumer can move 0x0059de30 into the active stream",
            "opcode 0x0d can set current object context+0x28 from stream+4",
            "opcode 0x0b consumes context+0x28 and calls text routine 0x0041b579",
            "individual textVa pointers remain data-entry refs, not direct code refs",
        ],
        "opcodeConsumers": {
            "0x0b": {
                "handlerVaHex": (opcodes.get("0x0b") or {}).get("handlerVaHex"),
                "effect": (opcodes.get("0x0b") or {}).get("effect"),
                "eventHandlerTextRefRow": event_handler_text_row,
            },
            "0x0d": {
                "handlerVaHex": (opcodes.get("0x0d") or {}).get("handlerVaHex"),
                "effect": (opcodes.get("0x0d") or {}).get("effect"),
            },
        },
        "rootRefs": root_refs,
        "entryRows": entry_rows,
        "promptConsumerRows": prompt_rows,
        "commandOpcodeCounts": dict(command_counts),
        "decisions": decisions,
        "remainingProofs": [
            "scene/event record consumer path to selected root",
            "runtime watchpoint or stricter static proof for root selection on map1_01a/map1_02b route",
            "selected option -> next prompt target for choice branches",
        ],
        "sourceArtifacts": {
            "sceneTextSequence": "out/scene_text_sequence_review.json",
            "map1Trace": "out/map1_01a_scene_trace.json",
            "promptSequenceReview": "out/scene_event_vm_prompt_sequence_review.json",
            "selectedRootConsumer": "out/selected_scene_text_root_consumer_review.json",
            "eventHandlerTextRefs": "out/event_handler_text_refs.json",
        },
    }


def render_html(payload: dict[str, Any]) -> str:
    s = payload["summary"]

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

    def tag(value: str) -> str:
        cls = {
            "grounded": "good",
            "blocked": "bad",
            "entry-table-only": "warn",
            "text-render-consumer-grounded": "good",
        }.get(value, "muted")
        return f'<span class="tag {cls}">{esc(value)}</span>'

    entry_rows = []
    for row in payload["entryRows"]:
        entry_rows.append(
            "<tr>"
            f"<td><code>{esc(row['entryVaHex'])}</code></td>"
            f"<td><code>{esc(row['textVaHex'])}</code></td>"
            f"<td>{esc(row['textVaRefCount'])}</td>"
            f"<td>{esc(row['textVaCodeRefCount'])}</td>"
            f"<td>{tag(row['promotion'])}</td>"
            f"<td>{esc(row.get('sample','')[:220])}</td>"
            "</tr>"
        )
    prompt_rows = []
    for row in payload["promptConsumerRows"]:
        prompt_rows.append(
            "<tr>"
            f"<td>{esc(row['order'])}</td>"
            f"<td>{esc(row['promptId'])}</td>"
            f"<td>{esc(row['status'])}</td>"
            f"<td>{esc(row['renderOpcodeCount'])}</td>"
            f"<td>{esc(row['sourceOpcodeCount'])}</td>"
            f"<td>{esc(row['waitOpcodeCount'])}</td>"
            f"<td>{tag(row['consumerEvidence'])}</td>"
            f"<td>{esc(row['text'])}</td>"
            "</tr>"
        )
    decision_rows = []
    for row in payload["decisions"]:
        decision_rows.append(
            "<tr>"
            f"<td>{esc(row['item'])}</td>"
            f"<td>{tag(row['promotion'])}</td>"
            f"<td>{esc(row['evidence'])}</td>"
            f"<td>{esc(row['remainingGap'])}</td>"
            "</tr>"
        )

    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link rel="icon" href="../favicon.ico" />
  <title>Scene/Event Text Consumer 역추적</title>
  <style>
    :root {{ color-scheme: light; --border:#d8dee6; --ink:#17202a; --muted:#607080; --panel:#fff; --head:#eef2f6; --bg:#f6f7f9; }}
    * {{ box-sizing:border-box; }}
    body {{ margin:0; background:var(--bg); color:var(--ink); font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; line-height:1.45; }}
    main {{ max-width:1500px; margin:0 auto; padding:18px; }}
    header {{ display:flex; justify-content:space-between; align-items:flex-start; gap:16px; margin-bottom:14px; }}
    h1 {{ margin:0; font-size:24px; }}
    h2 {{ margin:0; font-size:17px; }}
    nav {{ display:flex; flex-wrap:wrap; gap:10px; justify-content:flex-end; }}
    a {{ color:#185abc; font-weight:700; text-decoration:none; }}
    a:hover {{ text-decoration:underline; }}
    section {{ background:var(--panel); border:1px solid var(--border); border-radius:8px; margin:14px 0; overflow:hidden; }}
    .head {{ display:flex; justify-content:space-between; gap:12px; padding:12px 14px; background:var(--head); border-bottom:1px solid var(--border); }}
    .body {{ padding:14px; }}
    .metrics {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:10px; }}
    .metric {{ border:1px solid var(--border); border-radius:6px; background:#f8fafc; padding:10px; }}
    .metric strong {{ display:block; font-size:22px; }}
    table {{ width:100%; border-collapse:collapse; }}
    th,td {{ padding:8px 10px; border-bottom:1px solid var(--border); vertical-align:top; text-align:left; font-size:13px; }}
    th {{ background:#f8fafc; color:#344050; }}
    code {{ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }}
    .tag {{ display:inline-block; padding:2px 7px; border-radius:999px; background:#edf2f7; color:#334155; font-size:12px; white-space:nowrap; }}
    .tag.good {{ color:#0f766e; background:#e6f4f1; }}
    .tag.warn {{ color:#a15c00; background:#fff4df; }}
    .tag.bad {{ color:#b42318; background:#fdebea; }}
    .tag.muted {{ color:#607080; background:#edf2f7; }}
    .muted {{ color:var(--muted); }}
  </style>
</head>
<body>
<main data-page="scene-event-text-consumer-trace-review">
  <header>
    <div>
      <h1>Scene/Event Text Consumer 역추적</h1>
      <p class="muted">scene-seq 텍스트 포인터와 event-object VM text consumer를 분리해서 본다.</p>
    </div>
    <nav>
      <a href="index.html">관리 홈</a>
      <a href="scene_event_vm_review.html">Scene/Event VM</a>
      <a href="scene_event_vm_prompt_sequence_review.html">prompt sequence</a>
      <a href="selected_scene_text_root_consumer_review.html">selected root consumer</a>
      <a href="../out/scene_event_text_consumer_trace_review.json">JSON</a>
    </nav>
  </header>
  <section>
    <div class="head"><h2>요약</h2><span class="tag warn">{esc(payload['promotionStatus'])}</span></div>
    <div class="body metrics">
      <div class="metric"><strong>{esc(s['entryCount'])}</strong><span>selected entries</span></div>
      <div class="metric"><strong>{esc(s['promptCount'])}</strong><span>sequence prompts</span></div>
      <div class="metric"><strong>{esc(s['tracedPromptCount'])}</strong><span>trace prompts</span></div>
      <div class="metric"><strong>{esc(s['textPointerCodeRefCount'])}</strong><span>text pointer .text refs</span></div>
      <div class="metric"><strong>{esc(s['renderPromptCount'])}</strong><span>render consumer prompts</span></div>
    </div>
    <div class="body">
      <p><code>{esc(s['selectedRootVaHex'])}</code> root는 ref가 잡히지만, 개별 <code>textVa</code>는 entry table 안에서만 직접 참조된다. 실제 소비자는 opcode <code>0x0d</code>/<code>0x0b</code> 흐름이다.</p>
    </div>
  </section>
  <section>
    <div class="head"><h2>판정</h2><span>route proof는 여전히 별도</span></div>
    <table><thead><tr><th>item</th><th>promotion</th><th>evidence</th><th>remaining gap</th></tr></thead><tbody>{''.join(decision_rows)}</tbody></table>
  </section>
  <section>
    <div class="head"><h2>Entry Text Pointer Refs</h2><span>selected sequence</span></div>
    <table><thead><tr><th>entry</th><th>text</th><th>refs</th><th>code refs</th><th>promotion</th><th>sample</th></tr></thead><tbody>{''.join(entry_rows)}</tbody></table>
  </section>
  <section>
    <div class="head"><h2>Prompt Consumer Rows</h2><span>map1 trace slice</span></div>
    <table><thead><tr><th>#</th><th>prompt</th><th>status</th><th>render</th><th>source</th><th>wait</th><th>evidence</th><th>text</th></tr></thead><tbody>{''.join(prompt_rows)}</tbody></table>
  </section>
</main>
<script>
window.HWANSE_SCENE_EVENT_TEXT_CONSUMER_TRACE_READY = {{
  loaded: true,
  textConsumerTraceImplemented: true,
  promotionStatus: "{esc(payload['promotionStatus'])}",
  textConsumerProofFound: {str(bool(s['textConsumerProofFound'])).lower()},
  routeTextBindingProofFound: false,
  textPointerCodeRefCount: {s['textPointerCodeRefCount']}
}};
</script>
</body>
</html>
"""


def main() -> None:
    payload = build_payload()
    JSON_OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    html_text = render_html(payload)
    HTML_OUT.write_text(html_text, encoding="utf-8")
    WEB_HTML_OUT.write_text(html_text, encoding="utf-8")
    print(f"wrote {JSON_OUT}")
    print(f"wrote {WEB_HTML_OUT}")
    print(json.dumps(payload["summary"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
