#!/usr/bin/env python3
"""Build a focused dialogue template/text/face static review.

This report intentionally joins only three grounded axes:

* window.cns screen regions that look like dialogue/prompt boxes,
* event-object VM opcodes that set and render text sources,
* scene resource packages that load face_01.cns.

The exact portrait draw consumer is not promoted here unless a direct route is
found.  Keeping that gap explicit matters because face_01.cns being present in
a scene package proves availability, not which speaker portrait is drawn.
"""
from __future__ import annotations

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 offset_to_va, read_sections, va_to_offset


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

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

REGION_DRAW_FUNCTION_VA = 0x0041B579
TEXT_RENDER_HANDLER_VA = 0x0041BB4C
TEXT_SOURCE_HANDLER_VA = 0x0041BCA4

DIALOGUE_REGION_IDS = {9, 10, 11, 12, 13}
RELATED_REGION_IDS = {0, 14, 15, 16, 17, 39, 40, 42}


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


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


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 = int(section["va"])
        end = start + int(section["raw_size"])
        if start <= va < end:
            return section
    return None


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


def byte_hex(data: bytes, limit: int = 32) -> str:
    trimmed = data[:limit]
    suffix = " ..." if len(data) > limit else ""
    return " ".join(f"{b:02x}" for b in trimmed) + suffix


def text_sections(sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return [s for s in sections if s.get("name") == ".text"]


def find_calls_to(exe: bytes, sections: list[dict[str, Any]], target_va: int) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for section in text_sections(sections):
        start = int(section["raw"])
        end = start + int(section["raw_size"])
        for off in range(start, max(start, end - 4)):
            if exe[off] != 0xE8:
                continue
            rel = struct.unpack_from("<i", exe, off + 1)[0]
            call_va = offset_to_va(sections, off)
            if call_va is None:
                continue
            if call_va + 5 + rel != target_va:
                continue
            context_start = max(start, off - 10)
            context_end = min(end, off + 10)
            rows.append(
                {
                    "callVa": call_va,
                    "callVaHex": hx(call_va),
                    "fileOffsetHex": hx(off, 6),
                    "section": section.get("name"),
                    "bytesAround": byte_hex(exe[context_start:context_end]),
                    "functionHint": classify_call_site(call_va),
                }
            )
    return rows


def classify_call_site(call_va: int) -> str:
    if 0x0041BB4C <= call_va < 0x0041BC05:
        return "event text render opcode 0x0b handler"
    if 0x0041B579 <= call_va < 0x0041B6A0:
        return "inside region draw function"
    if 0x00406000 <= call_va < 0x00407000:
        return "menu/window VM opcode area"
    if 0x00410000 <= call_va < 0x00413000:
        return "screen/region initializer or draw helper area"
    return "unclassified text callsite"


def find_ascii_string_va(exe: bytes, sections: list[dict[str, Any]], text: str) -> int | None:
    needle = text.encode("ascii") + b"\0"
    off = exe.find(needle)
    if off < 0:
        return None
    return offset_to_va(sections, off)


def find_dword_refs(exe: bytes, sections: list[dict[str, Any]], value: int) -> list[dict[str, Any]]:
    needle = struct.pack("<I", value)
    rows: list[dict[str, Any]] = []
    pos = exe.find(needle)
    while pos >= 0:
        va = offset_to_va(sections, pos)
        section = section_for_offset(sections, pos)
        rows.append(
            {
                "refVa": va,
                "refVaHex": hx(va),
                "fileOffsetHex": hx(pos, 6),
                "section": section.get("name") if section else "file",
            }
        )
        pos = exe.find(needle, pos + 1)
    return rows


def prompt_speaker_counts() -> dict[str, Any]:
    path = OUT / "scene_event_vm_prompt_sequence_review.json"
    data = load_json(path, {})
    prompts = data.get("prompts") or data.get("rows") or []
    # Fall back to story_prompts.html when JSON layout is not a flat prompt list.
    if not prompts and (OUT / "story_prompts.html").exists():
        text = (OUT / "story_prompts.html").read_text(encoding="utf-8", errors="replace")
        speakers = re.findall(r'data-kind="speaker">([^<]+)</div>', text)
        counts = Counter(speakers)
        return {
            "source": "out/story_prompts.html",
            "speakerCounts": dict(counts.most_common(20)),
            "mainHeroSpeakerCounts": {name: counts.get(name, 0) for name in ["아타호", "린샹", "스마슈"]},
            "speakerCount": len(counts),
        }
    counts: Counter[str] = Counter()
    for row in prompts:
        text = str(row.get("displayText") or row.get("text") or "")
        first = text.splitlines()[0].strip() if text.strip() else ""
        if first and "「" not in first and len(first) <= 12:
            counts[first] += 1
    return {
        "source": "out/scene_event_vm_prompt_sequence_review.json",
        "speakerCounts": dict(counts.most_common(20)),
        "mainHeroSpeakerCounts": {name: counts.get(name, 0) for name in ["아타호", "린샹", "스마슈"]},
        "speakerCount": len(counts),
    }


def build_payload() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    hud = load_json(OUT / "hud_normal_static_hint_review.json", {})
    opcode_dict = load_json(OUT / "scene_event_vm_opcode_dictionary.json", {})
    handler_refs = load_json(OUT / "event_handler_text_refs.json", {})
    seq_resources = load_json(OUT / "scene_seq_resource_record_link_review.json", {})
    cns_assets = load_json(OUT / "cns_frame_assets.json", {})

    screen_regions = (
        ((hud.get("exe_evidence") or {}).get("screen_region_rect_table") or {}).get("resource_bindings")
        or []
    )
    dialogue_regions = [row for row in screen_regions if row.get("index") in DIALOGUE_REGION_IDS]
    related_regions = [row for row in screen_regions if row.get("index") in RELATED_REGION_IDS]

    template_rows = (
        ((hud.get("exe_evidence") or {}).get("window_cns_template_table") or {}).get("templates")
        or []
    )
    templates_by_index = {int(row["index"]): row for row in template_rows if "index" in row}
    for row in dialogue_regions + related_regions:
        template = templates_by_index.get(int(row.get("template_index", -1)))
        row["template"] = {
            "index": template.get("index"),
            "widthTiles": template.get("width_tiles"),
            "heightTiles": template.get("height_tiles"),
            "templateVaHex": hx(template.get("template_va")),
            "uniqueTileIds": template.get("unique_tile_ids"),
        } if template else None

    opcodes = {
        row.get("opcodeHex"): row
        for row in opcode_dict.get("opcodeDictionary") or []
        if row.get("opcodeHex") in {"0x03", "0x08", "0x0b", "0x0d", "0x0e", "0x15"}
    }

    face_va = find_ascii_string_va(exe, sections, "face_01.cns")
    face_refs = find_dword_refs(exe, sections, face_va) if face_va is not None else []
    face_refs_by_section = Counter(row["section"] for row in face_refs)
    face_records = []
    groups = seq_resources.get("groups") or []
    face_group_count = 0
    face_record_count = 0
    for group in groups:
        records = []
        for record in group.get("resourceRecords") or []:
            if "face_01.cns" in (record.get("resources") or []):
                face_record_count += 1
                records.append(
                    {
                        "map": record.get("map"),
                        "sceneIdHex": record.get("sceneIdHex"),
                        "recordVaHex": record.get("recordVaHex"),
                        "resourceCount": record.get("resourceCount"),
                        "resources": record.get("resources"),
                    }
                )
        if records or "face_01.cns" in (group.get("resources") or []):
            face_group_count += 1
            face_records.append(
                {
                    "groupId": group.get("id"),
                    "contextLabel": group.get("contextLabel") or group.get("map") or group.get("selector"),
                    "rootVaHex": group.get("rootVaHex"),
                    "rootEndVaHex": group.get("rootEndVaHex"),
                    "evidenceStatus": group.get("evidenceStatus"),
                    "promptCount": group.get("promptCount"),
                    "choiceCount": group.get("choiceCount"),
                    "fieldMaps": group.get("fieldMaps") or ([group.get("map")] if group.get("map") else []),
                    "resourceSample": (group.get("resources") or [])[:12],
                    "faceResourceRecords": records[:8],
                    "faceResourceRecordCount": len(records),
                }
            )

    face_asset = next(
        (
            row
            for row in cns_assets.get("rows") or []
            if row.get("cns") == "face_01.cns" or row.get("assetKey") == "face_01"
        ),
        {},
    )

    region_call_sites = find_calls_to(exe, sections, REGION_DRAW_FUNCTION_VA)

    render_handler_row = next(
        (
            row
            for row in handler_refs.get("rows") or []
            if row.get("handlerVaHex") == hx(TEXT_RENDER_HANDLER_VA)
            or "0x0b" in (row.get("opcodes") or [])
        ),
        {},
    )

    summary = {
        "dialogueRegionCount": len(dialogue_regions),
        "dialogueRegionIndexes": [row.get("index") for row in dialogue_regions],
        "dialogueRegionTemplates": [row.get("template_index") for row in dialogue_regions],
        "regionDrawFunctionVaHex": hx(REGION_DRAW_FUNCTION_VA),
        "regionDrawCallSiteCount": len(region_call_sites),
        "textRenderHandlerVaHex": hx(TEXT_RENDER_HANDLER_VA),
        "textSourceHandlerVaHex": hx(TEXT_SOURCE_HANDLER_VA),
        "face01StringVaHex": hx(face_va),
        "face01DwordRefCount": len(face_refs),
        "face01DwordRefsBySection": dict(face_refs_by_section),
        "sceneTextGroupCount": len(groups),
        "sceneTextGroupsWithFace01": face_group_count,
        "face01ResourceRecordCount": face_record_count,
        "portraitDrawConsumerStatus": "not-isolated",
        "conclusion": (
            "Dialogue window templates and text rendering opcodes are grounded, and face_01.cns is "
            "available in most scene resource packages.  A direct consumer that chooses a portrait "
            "frame for a speaker is still not proven."
        ),
    }

    decisions = [
        {
            "id": "dialogue-window-regions",
            "status": "grounded",
            "decision": "screen regions #9..#13 bind to window.cns templates #6..#10, all 22 tiles wide and 6..10 tiles tall.",
            "evidence": "0x004548b0 region RECT table + 0x00454b60 resource/template table.",
        },
        {
            "id": "text-render-consumer",
            "status": "grounded",
            "decision": "event-object opcode 0x0d sets context+0x28 text source; opcode 0x0b passes context+0x28 to 0x0041b579.",
            "evidence": "scene_event_vm_opcode_dictionary and event_handler_text_refs.",
        },
        {
            "id": "face-resource-availability",
            "status": "grounded",
            "decision": "face_01.cns is repeatedly present in scene resource packages, so portrait assets are available during dialogue-heavy scenes.",
            "evidence": f"{face_group_count}/{len(groups)} scene text groups include face_01.cns.",
        },
        {
            "id": "speaker-to-portrait-index",
            "status": "blocked",
            "decision": "do not infer portrait frame from speaker text alone.  The frame selector/draw consumer is still missing.",
            "evidence": "No direct .text reference to the face_01.cns string pointer; current refs are data/package refs.",
        },
    ]

    return {
        "kind": "dialogue-template-text-face-review",
        "promotionStatus": "dialogue-template-text-grounded-face-consumer-blocked",
        "summary": summary,
        "decisions": decisions,
        "dialogueRegions": dialogue_regions,
        "relatedRegions": related_regions,
        "textOpcodes": opcodes,
        "textRenderHandler": render_handler_row,
        "regionDrawCallSites": region_call_sites,
        "face01": {
            "asset": face_asset,
            "stringVaHex": hx(face_va),
            "dwordRefs": face_refs[:80],
            "dwordRefCount": len(face_refs),
            "dwordRefsBySection": dict(face_refs_by_section),
            "sceneGroups": face_records,
        },
        "speakerCounts": prompt_speaker_counts(),
        "nextTargets": [
            "Search for a draw path that uses face_01 resource id/frame index, not just the string/package record.",
            "Inspect script bytes near opcode 0x0d/0x0b clusters for preceding style/window/portrait opcodes.",
            "Compare prompts whose first line is 아타호/린샹/스마슈 against nearby command bytes to find a portrait selector pattern.",
        ],
    }


def render_table(rows: list[dict[str, Any]], columns: list[tuple[str, str]]) -> str:
    body = []
    for row in rows:
        cells = []
        for key, label in columns:
            value = row
            for part in key.split("."):
                value = value.get(part) if isinstance(value, dict) else None
            if isinstance(value, (list, dict)):
                value = json.dumps(value, ensure_ascii=False)
            cells.append(f"<td>{h(value)}</td>")
        body.append("<tr>" + "".join(cells) + "</tr>")
    return (
        "<table><thead><tr>"
        + "".join(f"<th>{h(label)}</th>" for _, label in columns)
        + "</tr></thead><tbody>"
        + "".join(body)
        + "</tbody></table>"
    )


def render_html(payload: dict[str, Any]) -> str:
    summary = payload["summary"]
    region_cols = [
        ("index", "region"),
        ("rect.x", "x"),
        ("rect.y", "y"),
        ("rect.w", "w"),
        ("rect.h", "h"),
        ("resource_id_hex", "resource"),
        ("template_index", "template"),
        ("template.widthTiles", "tw"),
        ("template.heightTiles", "th"),
        ("role", "role"),
    ]
    opcode_rows = []
    for key, row in payload["textOpcodes"].items():
        opcode_rows.append(
            {
                "opcode": key,
                "handlerVaHex": row.get("handlerVaHex"),
                "role": row.get("role"),
                "decodedLength": row.get("decodedLength"),
                "operandLayout": row.get("operandLayout"),
                "effect": row.get("effect"),
            }
        )

    face_groups = payload["face01"]["sceneGroups"]
    face_group_rows = [
        {
            "groupId": row.get("groupId"),
            "contextLabel": row.get("contextLabel"),
            "rootVaHex": row.get("rootVaHex"),
            "promptCount": row.get("promptCount"),
            "choiceCount": row.get("choiceCount"),
            "faceResourceRecordCount": row.get("faceResourceRecordCount"),
            "fieldMaps": ", ".join((row.get("fieldMaps") or [])[:8]),
            "resourceSample": ", ".join(row.get("resourceSample") or []),
        }
        for row in face_groups[:80]
    ]

    decisions = payload["decisions"]
    speaker_counts = payload["speakerCounts"]
    metric_items = [
        ("dialogue regions", summary["dialogueRegionCount"]),
        ("face scene groups", f"{summary['sceneTextGroupsWithFace01']}/{summary['sceneTextGroupCount']}"),
        ("face data refs", summary["face01DwordRefCount"]),
        ("region draw calls", summary["regionDrawCallSiteCount"]),
    ]
    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>대화창 템플릿 / 텍스트 / 초상화 추적</title>
  <style>
    body {{ margin:0; background:#f6f7f9; color:#18212b; font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; line-height:1.45; }}
    main {{ max-width:1440px; margin:0 auto; padding:18px; display:grid; gap:14px; }}
    header,.panel {{ background:white; border:1px solid #d8dee6; border-radius:8px; }}
    header {{ padding:14px; }}
    h1 {{ margin:0 0 6px; font-size:22px; }}
    h2 {{ margin:0 0 10px; font-size:17px; }}
    p {{ margin:6px 0; }}
    .muted {{ color:#617080; }}
    .metrics {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:10px; }}
    .metric {{ background:#f8fafc; border:1px solid #d8dee6; border-radius:6px; padding:10px; }}
    .metric strong {{ display:block; font-size:20px; }}
    .panel {{ padding:14px; overflow:auto; }}
    table {{ width:100%; border-collapse:collapse; font-size:13px; }}
    th,td {{ border-bottom:1px solid #e1e6ee; padding:7px 8px; vertical-align:top; text-align:left; }}
    th {{ background:#f8fafc; color:#334155; }}
    code {{ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }}
    .tag {{ display:inline-block; border-radius:999px; padding:2px 8px; background:#eef2f6; font-size:12px; }}
    .good {{ color:#0f766e; }} .warn {{ color:#a15c00; }}
    nav a {{ margin-right:10px; font-weight:700; color:#185abc; text-decoration:none; }}
    nav a:hover {{ text-decoration:underline; }}
  </style>
</head>
<body>
<main>
  <header>
    <nav><a href="index.html">관리 홈</a><a href="scene_event_text_consumer_trace_review.html">text consumer</a><a href="ui_window_review.html">UI window</a></nav>
    <h1>대화창 템플릿 / 텍스트 / 초상화 추적</h1>
    <p class="muted">{h(summary["conclusion"])}</p>
    <div class="metrics">{''.join(f'<div class="metric"><strong>{h(v)}</strong><span>{h(k)}</span></div>' for k, v in metric_items)}</div>
  </header>

  <section class="panel">
    <h2>판정</h2>
    {render_table(decisions, [("id","id"),("status","status"),("decision","decision"),("evidence","evidence")])}
  </section>

  <section class="panel">
    <h2>대화창 region 후보</h2>
    {render_table(payload["dialogueRegions"], region_cols)}
  </section>

  <section class="panel">
    <h2>텍스트 출력 opcode</h2>
    {render_table(opcode_rows, [("opcode","opcode"),("handlerVaHex","handler"),("role","role"),("decodedLength","len"),("operandLayout","operand"),("effect","effect")])}
  </section>

  <section class="panel">
    <h2>face_01.cns</h2>
    <p><code>{h(summary["face01StringVaHex"])}</code> · refs {h(summary["face01DwordRefCount"])} · {h(summary["face01DwordRefsBySection"])}</p>
    <p class="muted">frame hint: {h(payload["face01"]["asset"].get("frameHint"))}</p>
    {render_table(face_group_rows, [("groupId","group"),("contextLabel","context"),("rootVaHex","root"),("promptCount","prompts"),("choiceCount","choices"),("faceResourceRecordCount","face records"),("fieldMaps","field maps"),("resourceSample","resource sample")])}
  </section>

  <section class="panel">
    <h2>화자 이름 분포</h2>
    <p class="muted">텍스트 첫 줄이 초상화 선택을 직접 증명하지는 않는다. 다만 face selector 후보를 찾을 때 대조 표본으로 쓴다.</p>
    <pre>{h(json.dumps(speaker_counts, ensure_ascii=False, indent=2))}</pre>
  </section>

  <section class="panel">
    <h2>0x0041b579 호출 지점</h2>
    {render_table(payload["regionDrawCallSites"], [("callVaHex","call"),("section","section"),("functionHint","hint"),("bytesAround","bytes")])}
  </section>

  <section class="panel">
    <h2>다음 타깃</h2>
    <ul>{''.join(f'<li>{h(item)}</li>' for item in payload["nextTargets"])}</ul>
  </section>
</main>
</body>
</html>
"""


def main() -> None:
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    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 {HTML_OUT}")
    print(f"wrote {WEB_HTML_OUT}")


if __name__ == "__main__":
    main()
