#!/usr/bin/env python3
"""Build a focused report for dialogue portrait resource slots.

This review deliberately separates three evidence layers:

* face_01.cns is loaded into resource slot 0x000d in many scene roots;
* field active-object draw-source initializers do not consume that slot;
* dialogue text/source opcodes do not directly encode a face frame selector.

The remaining unknown is the UI/dialogue consumer that chooses a face_01 frame.
"""
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 / "dialogue_face_slot_selector_review.json"
HTML_OUT = WEB / "dialogue_face_slot_selector_review.html"
WEB_HTML_OUT = WEB / "dialogue_face_slot_selector_review.html"

FACE_NAME = "face_01"
FACE_FILENAME = "face_01.cns"
FACE_SLOT = 0x000D


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


def esc(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_offset(sections: list[dict[str, Any]], offset: int) -> str:
    for section in sections:
        start = int(section["raw"])
        end = start + int(section["raw_size"])
        if start <= offset < end:
            return str(section["name"])
    return "file"


def 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:
        rows.append(
            {
                "fileOffset": pos,
                "fileOffsetHex": hx(pos, 6),
                "va": offset_to_va(sections, pos),
                "vaHex": hx(offset_to_va(sections, pos)),
                "section": section_for_offset(sections, pos),
            }
        )
        pos = exe.find(needle, pos + 1)
    return rows


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


def exact_face_descriptor_rows(exe: bytes, sections: list[dict[str, Any]], face_va: int) -> list[dict[str, Any]]:
    # Resource descriptor shape observed around scene manifests:
    #   dword resource-id/opcode-like value, dword cns-string pointer, dword source size.
    # For face_01 the exact triple is 0x000d9012, face_01 string VA, 0x00500050.
    exact = struct.pack("<III", 0x000D9012, face_va, 0x00500050)
    rows: list[dict[str, Any]] = []
    pos = exe.find(exact)
    while pos >= 0:
        rows.append(
            {
                "descriptorVa": offset_to_va(sections, pos),
                "descriptorVaHex": hx(offset_to_va(sections, pos)),
                "fileOffsetHex": hx(pos, 6),
                "slotHex": hx(FACE_SLOT, 4),
                "descriptorDwordHex": "0x000d9012",
                "stringVaHex": hx(face_va),
                "sourceSizeHex": "0x00500050",
                "sourceWidth": 80,
                "sourceHeight": 80,
                "section": section_for_offset(sections, pos),
            }
        )
        pos = exe.find(exact, pos + 1)
    return rows


def scene_manifest_face_rows(scene_manifest: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for scene in scene_manifest:
        for resource in scene.get("resources") or []:
            if resource.get("name") != FACE_NAME:
                continue
            rows.append(
                {
                    "map": scene.get("map"),
                    "sceneIdHex": scene.get("sceneIdHex"),
                    "recordVaHex": scene.get("recordVaHex"),
                    "refVaHex": hx(resource.get("refVa")),
                    "kind": resource.get("kind"),
                    "filename": resource.get("filename"),
                }
            )
    return rows


def face_loaded_slots(active: dict[str, Any]) -> list[dict[str, Any]]:
    rows = []
    for row in active.get("loadedSpriteSlots") or []:
        if FACE_NAME not in (row.get("assets") or []):
            continue
        rows.append(row)
    return rows


def active_face_draw_bindings(active: dict[str, Any]) -> list[dict[str, Any]]:
    rows = []
    for key in ("activeObjectBindings", "activeNonMapSlotBindings", "unmatchedDrawSourceSlots"):
        for row in active.get(key) or []:
            if row.get("drawSlot") == FACE_SLOT or row.get("drawSlotHex") == hx(FACE_SLOT, 4):
                rows.append({"source": key, **row})
            elif FACE_NAME in str(row.get("boundAssets") or []):
                rows.append({"source": key, **row})
    return rows


def dialogue_block_correlation(blocks_json: dict[str, Any]) -> dict[str, Any]:
    face_blocks = []
    no_face_blocks = []
    source_face: Counter[str] = Counter()
    source_no_face: Counter[str] = Counter()
    opcode_face: Counter[str] = Counter()
    opcode_no_face: Counter[str] = Counter()
    opcode_0c_rows = []

    for block in blocks_json.get("blocks") or []:
        has_face = FACE_FILENAME in (block.get("resourceNames") or [])
        rows = face_blocks if has_face else no_face_blocks
        rows.append(block)

        for source in block.get("sourceValues") or []:
            value = source.get("valueHex")
            count = int(source.get("count") or 0)
            if has_face:
                source_face[value] += count
            else:
                source_no_face[value] += count

        opcodes = block.get("commandOpcodeCounts") or {}
        if has_face:
            opcode_face.update(opcodes)
        else:
            opcode_no_face.update(opcodes)

        for command in ((block.get("vmTrace") or {}).get("commands") or []):
            if command.get("opcodeHex") == "0x0c":
                opcode_0c_rows.append(
                    {
                        "blockIndex": block.get("index"),
                        "startVaHex": block.get("startVaHex"),
                        "hasFace01": has_face,
                        "commandVaHex": command.get("vaHex"),
                        "sourceValueHex": command.get("textSourceValueHex"),
                        "resources": block.get("resourceNames"),
                    }
                )

    return {
        "faceBlockCount": len(face_blocks),
        "noFaceBlockCount": len(no_face_blocks),
        "sourceValuesWithFace": source_face.most_common(),
        "sourceValuesWithoutFace": source_no_face.most_common(),
        "opcodesWithFace": opcode_face.most_common(),
        "opcodesWithoutFace": opcode_no_face.most_common(),
        "opcode0cTraceCount": len(opcode_0c_rows),
        "opcode0cRows": opcode_0c_rows,
        "sampleFaceBlocks": [
            {
                "index": block.get("index"),
                "startVaHex": block.get("startVaHex"),
                "classification": block.get("classification"),
                "sourceValues": block.get("sourceValues"),
                "resourceNames": block.get("resourceNames"),
                "sampleText": block.get("sampleText"),
            }
            for block in face_blocks[:12]
        ],
    }


def face_frame_asset(cns_assets: dict[str, Any]) -> dict[str, Any]:
    for row in cns_assets.get("rows") or []:
        if row.get("assetKey") == FACE_NAME or row.get("cns") == FACE_FILENAME:
            return row
    return {}


def build_payload() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    face_offset, face_va = find_face_string(exe, sections)
    if face_offset is None or face_va is None:
        raise RuntimeError(f"{FACE_FILENAME} not found in {EXE}")

    active = load_json(OUT / "active_object_resource_slot_review.json", {})
    scene_manifest = load_json(OUT / "scene_manifest.json", [])
    dialogue_blocks = load_json(OUT / "event_dialogue_blocks.json", {})
    cns_assets = load_json(OUT / "cns_frame_assets.json", {})

    face_refs = dword_refs(exe, sections, face_va)
    face_descriptors = exact_face_descriptor_rows(exe, sections, face_va)
    manifest_rows = scene_manifest_face_rows(scene_manifest)
    loaded_slots = face_loaded_slots(active)
    draw_bindings = active_face_draw_bindings(active)
    dialogue = dialogue_block_correlation(dialogue_blocks)
    frame_asset = face_frame_asset(cns_assets)

    source_values_shared = []
    face_values = {value for value, _ in dialogue["sourceValuesWithFace"]}
    no_face_values = {value for value, _ in dialogue["sourceValuesWithoutFace"]}
    for value in sorted(face_values & no_face_values):
        source_values_shared.append(value)

    summary = {
        "faceStringVaHex": hx(face_va),
        "faceStringFileOffsetHex": hx(face_offset, 6),
        "facePointerRefCount": len(face_refs),
        "facePointerRefSections": dict(Counter(row["section"] for row in face_refs)),
        "exactFaceDescriptorCount": len(face_descriptors),
        "sceneManifestFaceResourceRows": len(manifest_rows),
        "faceLoadedSpriteSlotRootCount": len(loaded_slots),
        "faceSlotHex": hx(FACE_SLOT, 4),
        "activeDrawBindingsUsingFaceSlot": len(draw_bindings),
        "dialogueBlocksWithFaceResource": dialogue["faceBlockCount"],
        "dialogueBlocksWithoutFaceResource": dialogue["noFaceBlockCount"],
        "dialogueOpcode0cTraceCount": dialogue["opcode0cTraceCount"],
        "sourceValuesSharedByFaceAndNoFaceBlocks": source_values_shared,
        "portraitSelectorStatus": "not-isolated",
    }

    decisions = [
        {
            "id": "face-resource-slot",
            "status": "grounded",
            "decision": "face_01.cns is loaded as resource slot 0x000d in scene manifests and loaded sprite slot roots.",
            "evidence": f"{len(manifest_rows)} scene_manifest rows, {len(loaded_slots)} root slot rows.",
        },
        {
            "id": "face-grid",
            "status": "grounded",
            "decision": "face_01.cns is a 640x160 sheet with 80x80 portrait cells, 8 columns x 2 rows.",
            "evidence": "cns_frame_assets canonical face grid.",
        },
        {
            "id": "active-object-field-placement",
            "status": "negative-grounded",
            "decision": "field active-object draw-source initializers do not consume face slot 0x000d.",
            "evidence": f"{len(draw_bindings)} active bindings matched face slot or face asset.",
        },
        {
            "id": "text-source-values",
            "status": "negative-grounded",
            "decision": "0x000300xx text source values are not portrait frame indexes.",
            "evidence": "same source values appear in both face and no-face blocks, and are consumed by text opcodes 0x0d/0x0b.",
        },
        {
            "id": "opcode-0x0c",
            "status": "negative-for-current-trace",
            "decision": "current dialogue block trace contains no opcode 0x0c commands, so child spawn is not the proven portrait path here.",
            "evidence": f"opcode 0x0c trace rows: {dialogue['opcode0cTraceCount']}.",
        },
        {
            "id": "speaker-to-face-frame-selector",
            "status": "open",
            "decision": "The remaining target is a UI/dialogue consumer that selects face slot 0x000d plus frame index.",
            "evidence": "face resource availability is proven, but no frame selector write/read path is isolated yet.",
        },
    ]

    return {
        "kind": "dialogue-face-slot-selector-review",
        "promotionStatus": "face-slot-grounded-selector-open",
        "summary": summary,
        "decisions": decisions,
        "faceResource": {
            "stringVaHex": hx(face_va),
            "pointerRefs": face_refs[:120],
            "exactDescriptors": face_descriptors,
            "frameAsset": frame_asset,
        },
        "sceneManifestFaceRows": manifest_rows,
        "faceLoadedSpriteSlots": loaded_slots,
        "activeFaceDrawBindings": draw_bindings,
        "dialogueCorrelation": dialogue,
        "nextTargets": [
            "Trace UI/dialogue draw paths that read resource slot 0x000d after text opcode 0x0b render.",
            "Search for small face frame indexes 0..15 written near speaker/text state, not for face_01.cns filename refs.",
            "Compare runtime dialogue with visible portrait against writes to resource slot/frame globals if runtime watchpoint is used later.",
        ],
    }


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: Any = row
            for part in key.split("."):
                value = value.get(part) if isinstance(value, dict) else None
            if isinstance(value, (dict, list)):
                value = json.dumps(value, ensure_ascii=False)
            cells.append(f"<td>{esc(value)}</td>")
        body.append("<tr>" + "".join(cells) + "</tr>")
    return (
        "<table><thead><tr>"
        + "".join(f"<th>{esc(label)}</th>" for _, label in columns)
        + "</tr></thead><tbody>"
        + "".join(body)
        + "</tbody></table>"
    )


def metric_grid(summary: dict[str, Any]) -> str:
    keys = [
        ("sceneManifestFaceResourceRows", "scene manifest face rows"),
        ("faceLoadedSpriteSlotRootCount", "loaded slot roots"),
        ("activeDrawBindingsUsingFaceSlot", "active draw bindings"),
        ("dialogueBlocksWithFaceResource", "dialogue blocks with face"),
        ("dialogueOpcode0cTraceCount", "opcode 0x0c trace rows"),
        ("portraitSelectorStatus", "selector status"),
    ]
    return "".join(
        f'<div class="metric"><strong>{esc(summary.get(key))}</strong><span>{esc(label)}</span></div>'
        for key, label in keys
    )


def render_html(payload: dict[str, Any]) -> str:
    summary = payload["summary"]
    decisions = payload["decisions"]
    descriptors = payload["faceResource"]["exactDescriptors"]
    loaded_slots = payload["faceLoadedSpriteSlots"]
    manifest_rows = payload["sceneManifestFaceRows"]
    dialogue = payload["dialogueCorrelation"]
    source_rows = [
        {"scope": "with face_01", "valueHex": value, "count": count}
        for value, count in dialogue["sourceValuesWithFace"][:20]
    ] + [
        {"scope": "without face_01", "valueHex": value, "count": count}
        for value, count in dialogue["sourceValuesWithoutFace"][:20]
    ]
    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>대화 초상화 slot/selector 검토</title>
  <style>
    body {{ margin:0; background:#f5f7fa; color:#17202a; font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; line-height:1.45; }}
    main {{ max-width:1480px; margin:0 auto; padding:18px; display:grid; gap:14px; }}
    header,.panel {{ background:white; border:1px solid #d7dee8; 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:#667485; }}
    nav a {{ margin-right:10px; font-weight:700; color:#185abc; text-decoration:none; }}
    nav a:hover {{ text-decoration:underline; }}
    .metrics {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; margin-top:10px; }}
    .metric {{ background:#f8fafc; border:1px solid #d7dee8; 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 #e1e7ef; padding:7px 8px; vertical-align:top; text-align:left; }}
    th {{ background:#f8fafc; color:#334155; }}
    code,pre {{ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }}
    pre {{ white-space:pre-wrap; background:#f8fafc; border:1px solid #d7dee8; border-radius:6px; padding:10px; }}
    .good {{ color:#0f766e; }}
    .warn {{ color:#a15c00; }}
  </style>
</head>
<body>
<main>
  <header>
    <nav><a href="index.html">관리 홈</a><a href="dialogue_template_text_face_review.html">대화창/텍스트/초상화</a><a href="../out/active_object_resource_slot_review.html">active slot</a></nav>
    <h1>대화 초상화 slot/selector 검토</h1>
    <p class="muted">face_01 리소스 slot은 확정하고, speaker→portrait frame selector는 아직 열린 항목으로 분리한다.</p>
    <div class="metrics">{metric_grid(summary)}</div>
  </header>

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

  <section class="panel">
    <h2>face_01 descriptor</h2>
    <p><code>{esc(summary["faceStringVaHex"])}</code> · pointer refs {esc(summary["facePointerRefCount"])} · sections {esc(summary["facePointerRefSections"])}</p>
    {render_table(descriptors, [("descriptorVaHex","descriptor"),("slotHex","slot"),("descriptorDwordHex","descriptor dword"),("sourceWidth","w"),("sourceHeight","h"),("section","section")])}
  </section>

  <section class="panel">
    <h2>face_01 loaded sprite slots</h2>
    <p class="muted">slot 0x000d는 root별로 face_01 단일 asset에 묶인다. 이 표는 로딩/가용성 근거이고, 표시 frame 선택 근거는 아니다.</p>
    {render_table(loaded_slots, [("rootVaHex","root"),("slotHex","slot"),("assetCount","assets"),("mapCount","maps"),("resourceRowCount","rows"),("status","status"),("maps","map sample")])}
  </section>

  <section class="panel">
    <h2>scene manifest face rows</h2>
    {render_table(manifest_rows[:160], [("map","map"),("sceneIdHex","scene"),("recordVaHex","record"),("refVaHex","face ref"),("kind","kind"),("filename","file")])}
  </section>

  <section class="panel">
    <h2>대화 block correlation</h2>
    <p class="muted">source value가 face frame이면 face 없는 블록과 공유되면 안 되지만, 실제로는 공유된다.</p>
    {render_table(source_rows, [("scope","scope"),("valueHex","source value"),("count","count")])}
    <h3>face resource가 있는 대화 블록 샘플</h3>
    {render_table(dialogue["sampleFaceBlocks"], [("index","block"),("startVaHex","start"),("classification","class"),("sourceValues","sources"),("sampleText","sample")])}
  </section>

  <section class="panel">
    <h2>비주장 / 다음 타깃</h2>
    <pre>{esc(json.dumps({"summary": summary, "nextTargets": payload["nextTargets"]}, ensure_ascii=False, indent=2))}</pre>
  </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()
