#!/usr/bin/env python3
"""Build a focused report for the remaining dialogue portrait selector gap.

The earlier face slot report proves that face_01.cns is loaded as resource slot
0x000d. This report asks a narrower question: does the EXE contain data that
looks like "slot 0x000d + face frame index" and, if yes, is it a general
speaker selector or only a local draw-source cluster?
"""
from __future__ import annotations

import html
import json
import struct
from collections import Counter, defaultdict
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_face_selector_gap_review.json"
HTML_OUT = WEB / "dialogue_face_selector_gap_review.html"
WEB_HTML_OUT = WEB / "dialogue_face_selector_gap_review.html"

FACE_SLOT = 0x000D
FACE_NAME = "face_01.cns"
INLINE_FACE_OPCODE = b"\x40\x35"
MANUAL_FACE_FRAME_LEGEND = [
    {"frameIndex": 0, "label": "아타호", "usage": "main-dialogue-dominant", "source": "user-observed"},
    {"frameIndex": 1, "label": "스마슈", "usage": "main-dialogue-dominant", "source": "user-observed"},
    {"frameIndex": 2, "label": "린샹", "usage": "main-dialogue-dominant", "source": "user-observed"},
    {"frameIndex": 3, "label": "페톰", "usage": "story-dialogue", "source": "user-observed"},
    {"frameIndex": 4, "label": "키리", "usage": "story-event", "source": "user-observed"},
    {"frameIndex": 5, "label": "화린", "usage": "story-event", "source": "user-observed"},
    {"frameIndex": 6, "label": "알리바바", "usage": "story-event", "source": "user-observed"},
    {"frameIndex": 7, "label": "유리와카마루", "usage": "portrait-only-story-event", "source": "user-observed"},
    {"frameIndex": 8, "label": "황금돼지 두루마리 관련 인물", "usage": "brief-story", "source": "user-observed"},
]
DOMINANT_DIALOGUE_FRAMES = {0, 1, 2}
EVENT_PORTRAIT_CLUSTER_FRAME_LEGEND = {
    5: "화린",
    4: "키리",
    6: "알리바바",
    0: "그외 동료들 동시 컷 / 아타호",
    3: "그외 동료들 동시 컷 / 페톰",
    7: "그외 동료들 동시 컷 / 유리와카마루",
}
EVENT_PORTRAIT_PROMPT_IDS = [
    "story-prompt-2303",
    "story-prompt-2304",
    "story-prompt-2305",
    "story-prompt-2306",
    "story-prompt-2307",
    "story-prompt-2308",
    "story-prompt-2309",
]
SPEAKER_FACE_IDS = {
    "아타호": 1,
    "스마슈": 2,
    "린샹": 3,
    "페톰": 4,
    "키리": 5,
    "화린": 6,
    "알리바바": 7,
    "유리와카마루": 8,
}


ARG_A_LAYOUT_HINTS = {
    0: {
        "promptClass": "in-game",
        "positionSlot": "left",
        "positionSummary": "in-game prompt portrait left slot",
        "evidence": "story-prompt-2373 at render 0x004c062e uses 40 35 00 01 and Ataho portrait is observed on the left.",
    },
    1: {
        "promptClass": "in-game",
        "positionSlot": "right",
        "positionSummary": "in-game prompt portrait right slot",
        "evidence": "paired with 0x00 in-game prompt slot; exact screen sample still useful for direct right-side proof.",
    },
    2: {
        "promptClass": "epilogue-or-special",
        "positionSlot": "left",
        "positionSummary": "epilogue/special prompt portrait left slot",
        "evidence": "story-prompt-4235 at render 0x00546e98 uses 40 35 02 02 and Smash portrait is observed on the left.",
    },
    3: {
        "promptClass": "epilogue-or-special",
        "positionSlot": "right",
        "positionSummary": "epilogue/special prompt portrait right slot",
        "evidence": "paired with 0x02 in epilogue/special prompt layout; the same story-prompt-4235 render block later uses 40 35 03 01 for Ataho.",
    },
}


def arg_a_layout_hint(arg_a: int) -> dict[str, str]:
    return ARG_A_LAYOUT_HINTS.get(
        arg_a,
        {
            "promptClass": "unknown",
            "positionSlot": "unknown",
            "positionSummary": "unknown portrait slot",
        },
    )


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 root_for_va(roots: list[dict[str, Any]], va: int | None) -> dict[str, Any] | None:
    if va is None:
        return None
    for root in roots:
        start = root.get("rootVa")
        end = root.get("rangeEndVa") or root.get("nextRootVa")
        if isinstance(start, int) and isinstance(end, int) and start <= va < end:
            return root
    return None


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


def classify_high0d_row(prev: int | None, value: int, nxt: int | None) -> str:
    low = value & 0xFFFF
    if prev == 0x00288012 and nxt == 0x00168011 and 0 <= low <= 15:
        return "face-draw-cluster-candidate"
    if prev == 0x0019000B and nxt == 0x0020000B:
        return "text/control-run-not-face"
    if low == 0x9012 or low == 0x3012:
        return "face-resource-descriptor"
    if 0 <= low <= 15:
        return "small-high0d-other-context"
    return "high0d-other"


def collect_high0d_rows(exe: bytes, sections: list[dict[str, Any]], roots: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for offset in range(0, len(exe) - 3, 4):
        value = dword_at(exe, offset)
        if value is None or (value >> 16) != FACE_SLOT:
            continue
        va = offset_to_va(sections, offset)
        prev = dword_at(exe, offset - 4)
        nxt = dword_at(exe, offset + 4)
        root = root_for_va(roots, va)
        linked = root.get("linkedCns") if root else []
        low = value & 0xFFFF
        row = {
            "va": va,
            "vaHex": hx(va),
            "fileOffsetHex": hx(offset, 6),
            "section": section_for_offset(sections, offset),
            "valueHex": hx(value),
            "lowWord": low,
            "lowWordHex": hx(low, 4),
            "prevHex": hx(prev),
            "nextHex": hx(nxt),
            "classification": classify_high0d_row(prev, value, nxt),
            "rootVaHex": root.get("rootVaHex") if root else "-",
            "rootClass": root.get("rootClass") if root else "-",
            "sequenceGroupIds": root.get("sequenceGroupIds") if root else [],
            "rootHasFace01": FACE_NAME in (linked or []),
            "linkedCnsSample": (linked or [])[:10],
        }
        rows.append(row)
    return rows


def collect_face_draw_cluster(exe: bytes, sections: list[dict[str, Any]], roots: list[dict[str, Any]]) -> list[dict[str, Any]]:
    cluster = []
    for offset in range(4, len(exe) - 20, 4):
        prev = dword_at(exe, offset - 4)
        value = dword_at(exe, offset)
        nxt = dword_at(exe, offset + 4)
        if prev != 0x00288012 or nxt != 0x00168011 or value is None:
            continue
        if (value >> 16) != FACE_SLOT or not (0 <= (value & 0xFFFF) <= 15):
            continue
        va = offset_to_va(sections, offset)
        root = root_for_va(roots, va)
        linked = root.get("linkedCns") if root else []
        cluster.append(
            {
                "drawOpcodeVaHex": hx(offset_to_va(sections, offset - 4)),
                "selectorVaHex": hx(va),
                "valueHex": hx(value),
                "slotHex": hx(value >> 16, 4),
                "frameIndex": value & 0xFFFF,
                "manualFlashbackRole": EVENT_PORTRAIT_CLUSTER_FRAME_LEGEND.get(value & 0xFFFF, ""),
                "followOpcodeHex": hx(nxt),
                "localIndex": dword_at(exe, offset + 8),
                "modeOrArg": dword_at(exe, offset + 12),
                "subscriptVaHex": hx(dword_at(exe, offset + 16)),
                "rootVaHex": root.get("rootVaHex") if root else "-",
                "rootClass": root.get("rootClass") if root else "-",
                "sequenceGroupIds": root.get("sequenceGroupIds") if root else [],
                "rootHasFace01": FACE_NAME in (linked or []),
                "linkedCnsSample": (linked or [])[:12],
            }
        )
    return cluster


def collect_selector_root_high0d_rejects(roots: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rejects = []
    for root in roots:
        for row in root.get("rowsSample") or []:
            value_hex = row.get("valueHex")
            if not isinstance(value_hex, str):
                continue
            try:
                value = int(value_hex, 16)
            except ValueError:
                continue
            if (value >> 16) != FACE_SLOT:
                continue
            low = value & 0xFFFF
            rejects.append(
                {
                    "rootVaHex": root.get("rootVaHex"),
                    "rowVaHex": row.get("rowVaHex"),
                    "valueHex": value_hex,
                    "lowWordHex": hx(low, 4),
                    "kind": row.get("kind"),
                    "selector": row.get("selector"),
                    "reason": "low word is outside face frame 0..15" if low > 15 else "selector-root row context is not a proven portrait draw consumer",
                }
            )
    return rejects


def collect_face_prompt_samples(story_prompts: dict[str, Any], limit: int = 80) -> list[dict[str, Any]]:
    rows = []
    for prompt in story_prompts.get("prompts") or []:
        resource_names = prompt.get("resourceNames") or []
        if FACE_NAME not in resource_names:
            continue
        lines = prompt.get("lines") or []
        rows.append(
            {
                "id": prompt.get("id"),
                "startVaHex": prompt.get("startVaHex"),
                "blockId": prompt.get("blockId"),
                "classification": prompt.get("classification"),
                "textSourceValueHex": prompt.get("textSourceValueHex"),
                "firstLine": lines[0] if lines else "",
                "lineCount": prompt.get("lineCount"),
                "resourceNames": resource_names,
                "textSample": " / ".join(lines[:4]),
            }
        )
        if len(rows) >= limit:
            break
    return rows


def collect_event_portrait_prompt_samples(story_prompts: dict[str, Any]) -> list[dict[str, Any]]:
    by_id = {prompt.get("id"): prompt for prompt in story_prompts.get("prompts") or []}
    rows = []
    for prompt_id in EVENT_PORTRAIT_PROMPT_IDS:
        prompt = by_id.get(prompt_id)
        if not prompt:
            rows.append({"id": prompt_id, "status": "missing"})
            continue
        lines = prompt.get("lines") or []
        rows.append(
            {
                "id": prompt_id,
                "status": "found",
                "startVaHex": prompt.get("startVaHex"),
                "blockId": prompt.get("blockId"),
                "textSourceValueHex": prompt.get("textSourceValueHex"),
                "lineCount": prompt.get("lineCount"),
                "textSample": " / ".join(lines),
            }
        )
    return rows


def collect_inline_4035_speaker_rows(
    exe: bytes,
    sections: list[dict[str, Any]],
    story_prompts: dict[str, Any],
) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for prompt in story_prompts.get("prompts") or []:
        lines = prompt.get("lines") or []
        if not lines:
            continue
        speaker = str(lines[0]).strip()
        expected_face_id = SPEAKER_FACE_IDS.get(speaker)
        if expected_face_id is None:
            continue
        try:
            speaker_bytes = speaker.encode("cp949")
        except UnicodeEncodeError:
            continue

        start_va = prompt.get("startVa")
        end_va = prompt.get("endVa") or start_va
        render_va_hex = prompt.get("renderVaHex")
        if not isinstance(start_va, int):
            continue
        render_va = int(render_va_hex, 16) if isinstance(render_va_hex, str) and render_va_hex else start_va - 16
        scan_start_va = max(0, render_va - 16)
        scan_end_va = max(start_va + 128, int(end_va or start_va) + 16)
        scan_start = va_to_offset(sections, scan_start_va)
        scan_end = va_to_offset(sections, scan_end_va)
        if scan_start is None or scan_end is None or scan_end <= scan_start:
            continue

        chunk = exe[scan_start:scan_end]
        found_at: int | None = None
        pos = 0
        while True:
            idx = chunk.find(speaker_bytes, pos)
            if idx < 0:
                break
            if idx >= 4 and chunk[idx - 4 : idx - 2] == INLINE_FACE_OPCODE:
                found_at = scan_start + idx
                break
            pos = idx + 1
        if found_at is None:
            continue

        arg_a = exe[found_at - 2]
        face_id = exe[found_at - 1]
        layout_hint = arg_a_layout_hint(arg_a)
        opcode_va = offset_to_va(sections, found_at - 4)
        speaker_va = offset_to_va(sections, found_at)
        observed_frame = face_id - 1 if 1 <= face_id <= 16 else None
        expected_frame = expected_face_id - 1
        resource_names = prompt.get("resourceNames") or []
        rows.append(
            {
                "promptId": prompt.get("id"),
                "speaker": speaker,
                "opcodeVaHex": hx(opcode_va),
                "speakerVaHex": hx(speaker_va),
                "promptStartVaHex": prompt.get("startVaHex"),
                "renderVaHex": prompt.get("renderVaHex"),
                "argA": arg_a,
                "argAHex": hx(arg_a, 2),
                "faceId": face_id,
                "faceIdHex": hx(face_id, 2),
                "observedFrameIndex": observed_frame,
                "expectedFaceId": expected_face_id,
                "expectedFrameIndex": expected_frame,
                "matchesExpectedFace": face_id == expected_face_id,
                "argAPromptClass": layout_hint["promptClass"],
                "argAPositionSlot": layout_hint["positionSlot"],
                "argAPositionSummary": layout_hint["positionSummary"],
                "hasFaceResource": FACE_NAME in resource_names,
                "resourceNamesSample": resource_names[:8],
                "textSample": " / ".join(lines[:4]),
            }
        )
    return rows


def summarize_inline_4035(rows: list[dict[str, Any]]) -> dict[str, Any]:
    pair_counts = Counter((row["argA"], row["faceId"]) for row in rows)
    speaker_counts: dict[str, Counter[tuple[int, int]]] = defaultdict(Counter)
    for row in rows:
        speaker_counts[row["speaker"]][(row["argA"], row["faceId"])] += 1
    mismatches = [row for row in rows if not row["matchesExpectedFace"]]
    return {
        "inline4035SpeakerOpcodeCount": len(rows),
        "inline4035SpeakerFaceMismatchCount": len(mismatches),
        "inline4035ArgACounts": [
            {"argA": key, "argAHex": hx(key, 2), **arg_a_layout_hint(key), "count": value}
            for key, value in Counter(row["argA"] for row in rows).most_common()
        ],
        "inline4035FaceIdCounts": [
            {"faceId": key, "faceIdHex": hx(key, 2), "frameIndex": key - 1, "count": value}
            for key, value in Counter(row["faceId"] for row in rows).most_common()
        ],
        "inline4035ArgAFaceIdCounts": [
            {"argA": arg_a, "argAHex": hx(arg_a, 2), "faceId": face_id, "frameIndex": face_id - 1, "count": count}
            for (arg_a, face_id), count in pair_counts.most_common()
        ],
        "inline4035SpeakerFaceIdCounts": [
            {
                "speaker": speaker,
                "pairs": [
                    {"argA": arg_a, "argAHex": hx(arg_a, 2), **arg_a_layout_hint(arg_a), "faceId": face_id, "frameIndex": face_id - 1, "count": count}
                    for (arg_a, face_id), count in counts.most_common()
                ],
            }
            for speaker, counts in sorted(speaker_counts.items())
        ],
        "inline4035ArgAStatus": "grounded-four-prompt-position-slots",
        "inline4035ArgALayoutHints": [
            {"argA": key, "argAHex": hx(key, 2), **value}
            for key, value in ARG_A_LAYOUT_HINTS.items()
        ],
        "inline4035FrameIdStatus": "grounded",
    }


def summarize_rows(
    high0d_rows: list[dict[str, Any]],
    cluster_rows: list[dict[str, Any]],
    inline_rows: list[dict[str, Any]],
) -> dict[str, Any]:
    class_counts = Counter(row["classification"] for row in high0d_rows)
    low_counts = Counter(row["lowWordHex"] for row in high0d_rows)
    cluster_roots = sorted({row["rootVaHex"] for row in cluster_rows})
    cluster_frames = [row["frameIndex"] for row in cluster_rows]
    cluster_frame_set = set(cluster_frames)
    inline_summary = summarize_inline_4035(inline_rows)
    return {
        "high0dAlignedDwordCount": len(high0d_rows),
        "high0dClassCounts": dict(class_counts),
        "high0dLowWordCountsTop": low_counts.most_common(18),
        "faceDrawClusterCount": len(cluster_rows),
        "faceDrawClusterRoots": cluster_roots,
        "faceDrawClusterFrames": cluster_frames,
        "faceDrawClusterFrameCount": len(set(cluster_frames)),
        "manualDominantDialogueFrames": sorted(DOMINANT_DIALOGUE_FRAMES),
        "dominantFramesMissingFromCluster": sorted(DOMINANT_DIALOGUE_FRAMES - cluster_frame_set),
        "clusterFramesOutsideDominantDialogue": sorted(cluster_frame_set - DOMINANT_DIALOGUE_FRAMES),
        "eventPortraitClusterFrameRoles": [
            {"frameIndex": frame, "role": EVENT_PORTRAIT_CLUSTER_FRAME_LEGEND.get(frame, "")}
            for frame in cluster_frames
        ],
        "faceDrawClusterStatus": "partial-grounded" if cluster_rows else "not-found",
        "faceDrawClusterInterpretation": "story-event-portrait-cluster-candidate" if cluster_rows else "not-found",
        "generalSpeakerSelectorStatus": "grounded-inline-4035-face-id" if inline_summary["inline4035SpeakerFaceMismatchCount"] == 0 and inline_rows else "still-open",
        **inline_summary,
    }


def build_payload() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    roots_json = load_json(OUT / "selector_root_structure_review.json", {})
    roots = roots_json.get("roots") or []
    story_prompts = load_json(OUT / "story_prompts.json", {})

    high0d_rows = collect_high0d_rows(exe, sections, roots)
    cluster_rows = collect_face_draw_cluster(exe, sections, roots)
    inline_4035_rows = collect_inline_4035_speaker_rows(exe, sections, story_prompts)
    selector_rejects = collect_selector_root_high0d_rejects(roots)
    prompt_samples = collect_face_prompt_samples(story_prompts)
    event_portrait_prompts = collect_event_portrait_prompt_samples(story_prompts)
    summary = summarize_rows(high0d_rows, cluster_rows, inline_4035_rows)

    class_examples: dict[str, list[dict[str, Any]]] = defaultdict(list)
    for row in high0d_rows:
        if len(class_examples[row["classification"]]) < 20:
            class_examples[row["classification"]].append(row)

    decisions = [
        {
            "id": "face-slot-frame-cluster",
            "status": "partial-grounded" if cluster_rows else "open",
            "decision": "A local cluster uses 0x00288012 followed by 0x000d000N and then 0x00168011; this is a strong face_01 slot/frame draw-source candidate.",
            "evidence": f"{len(cluster_rows)} rows, frames {summary['faceDrawClusterFrames']}, roots {summary['faceDrawClusterRoots']}.",
        },
        {
            "id": "global-speaker-selector",
            "status": "grounded-for-frame-id",
            "decision": "Normal dialogue speaker portraits use inline opcode bytes 40 35 aa bb before the speaker name. bb is the 1-based face id, so face_01 frame index is bb-1.",
            "evidence": f"{summary['inline4035SpeakerOpcodeCount']} known-speaker prompts matched this opcode and {summary['inline4035SpeakerFaceMismatchCount']} disagreed with the expected speaker face id.",
        },
        {
            "id": "dialogue-face-side",
            "status": "in-game-left-grounded",
            "decision": "argA is the prompt portrait position/layout slot. 0x00 is the in-game left portrait slot; 0x01 is the paired in-game right slot. 0x02 is the epilogue/special left slot, and 0x03 is the paired epilogue/special right slot.",
            "evidence": f"story-prompt-2373 is observed with Ataho portrait on the left and uses 40 35 00 01. argA distribution: {summary['inline4035ArgACounts']}. Kiri/Hwarin/Alibaba only use 0x02/0x03, matching the user observation that they speak in the post-game/epilogue-style talk segment rather than normal in-game dialogue.",
        },
        {
            "id": "story-event-portrait-cluster",
            "status": "manual-matched-partial-grounded",
            "decision": "The 5,4,6,0,3,7 cluster matches a story event portrait display sequence around story-prompt-2303..2309.",
            "evidence": "User-observed order: #5 화린, #4 키리, #6 알리바바, then #0/#3/#7 for '그외 많은 동료들이여'. Extracted prompts 2303..2309 match that event flow.",
        },
        {
            "id": "manual-face-frame-legend",
            "status": "manual-hint",
            "decision": "User observation gives the face_01 layout: top row #0 Ataho, #1 Smash, #2 Linshang, #3 Pethom, #4 Kiri, #5 Hwarin, #6 Alibaba, #7 Yuriwakamru.",
            "evidence": "This is a manual gameplay/layout hint, not an EXE-derived selector proof. It constrains future static/runtime checks.",
        },
        {
            "id": "small-high0d-false-positives",
            "status": "negative-filtered",
            "decision": "Small high-word 0x000d dwords cannot be treated as portrait indexes without opcode context.",
            "evidence": "Common rows such as 0x000d000b sit between 0x0019000b and 0x0020000b and appear in no-face roots too.",
        },
        {
            "id": "selector-root-packed-scalars",
            "status": "negative-filtered",
            "decision": "selector_root rows like 0x000d014f/0x000d0364 are not face frame indexes.",
            "evidence": "Their low word is outside face frame 0..15 and the row kind is packed-control-scalar.",
        },
    ]

    return {
        "kind": "dialogue-face-selector-gap-review",
        "promotionStatus": "dialogue-face-id-grounded-layout-byte-open",
        "summary": summary,
        "decisions": decisions,
        "inline4035SpeakerRowsSample": inline_4035_rows[:180],
        "faceDrawClusterRows": cluster_rows,
        "manualFaceFrameLegend": MANUAL_FACE_FRAME_LEGEND,
        "eventPortraitClusterFrameLegend": [
            {"frameIndex": frame, "role": role}
            for frame, role in sorted(EVENT_PORTRAIT_CLUSTER_FRAME_LEGEND.items())
        ],
        "eventPortraitPromptSamples": event_portrait_prompts,
        "high0dClassExamples": dict(class_examples),
        "selectorRootHigh0dRejects": selector_rejects,
        "facePromptSamples": prompt_samples,
        "nextTargets": [
            "Trace the renderer consumer for inline 40 35 aa bb only if exact coordinate writes are needed.",
            "Trace the renderer consumer for inline 40 35 aa bb only if exact coordinate writes are needed.",
            "Keep the localized frames 5,4,6,0,3,7 tied to the known story event portrait display unless a broader consumer proves otherwise.",
        ],
    }


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:
    metrics = [
        ("inline4035SpeakerOpcodeCount", "inline 40 35 speaker rows"),
        ("inline4035SpeakerFaceMismatchCount", "speaker face mismatches"),
        ("generalSpeakerSelectorStatus", "speaker selector"),
        ("inline4035ArgAStatus", "argA status"),
        ("faceDrawClusterCount", "face draw cluster rows"),
        ("faceDrawClusterFrameCount", "unique frames"),
        ("high0dAlignedDwordCount", "aligned high-0x000d dwords"),
        ("faceDrawClusterStatus", "cluster status"),
    ]
    return "".join(
        f'<div class="metric"><strong>{esc(summary.get(key))}</strong><span>{esc(label)}</span></div>'
        for key, label in metrics
    )


def render_html(payload: dict[str, Any]) -> str:
    summary = payload["summary"]
    class_rows = [
        {"classification": key, "count": value}
        for key, value in summary["high0dClassCounts"].items()
    ]
    low_rows = [
        {"lowWordHex": key, "count": value}
        for key, value in summary["high0dLowWordCountsTop"]
    ]
    inline_speaker_rows = summary["inline4035SpeakerFaceIdCounts"]
    inline_arg_rows = summary["inline4035ArgACounts"]
    inline_face_rows = summary["inline4035FaceIdCounts"]
    inline_pair_rows = summary["inline4035ArgAFaceIdCounts"]
    examples = []
    for classification, rows in payload["high0dClassExamples"].items():
        for row in rows[:10]:
            examples.append({"class": classification, **row})

    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>대화 초상화 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:1520px; 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; }}
    h3 {{ margin:16px 0 8px; font-size:15px; }}
    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; }}
    .metric span {{ color:#667485; font-size:12px; }}
    .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; }}
  </style>
</head>
<body>
<main>
  <header>
    <nav>
      <a href="index.html">관리 홈</a>
      <a href="dialogue_face_slot_selector_review.html">초상화 slot/selector</a>
      <a href="dialogue_template_text_face_review.html">대화창/초상화 추적</a>
      <a href="selector_root_structure_review.html">selector-root 구조</a>
    </nav>
    <h1>대화 초상화 selector 검토</h1>
    <p class="muted">slot 0x000d, inline 40 35 face id, story event portrait cluster를 분리해 검토한다.</p>
    <div class="metrics">{metric_grid(summary)}</div>
  </header>

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

  <section class="panel">
    <h2>일반 대화 inline 40 35 selector</h2>
    <p class="muted"><code>40 35 aa bb</code>가 speaker 이름 바로 앞에 붙는다. 확인된 화자 prompt {esc(summary["inline4035SpeakerOpcodeCount"])}개에서 <code>bb</code>가 사용자 관찰 face id와 모두 일치했다. 따라서 <code>bb</code>는 1-based face id, 실제 <code>face_01</code> frame은 <code>bb-1</code>로 본다.</p>
    <p class="muted">추가 힌트 반영: 초상화 위치는 화자 고정이 아니라 프롬프트 레이아웃에 따라 달라진다. <code>story-prompt-2373</code>은 아타호 초상화가 왼쪽에 나오며 opcode가 <code>40 35 00 01</code>이므로 <code>aa=0x00</code>은 게임 중 왼쪽 슬롯으로 확정한다. <code>aa=0x01</code>은 paired in-game 오른쪽 슬롯으로 둔다. <code>aa=0x02/0x03</code>은 에필로그/특수 프롬프트의 좌우 후보 pair다.</p>
    <h3>argA layout 해석</h3>
    {render_table(summary["inline4035ArgALayoutHints"], [("argAHex","argA"),("promptClass","prompt class"),("positionSlot","slot"),("positionSummary","summary"),("evidence","evidence")])}
    <h3>화자별 face id</h3>
    {render_table(inline_speaker_rows, [("speaker","speaker"),("pairs","argA/face pairs")])}
    <h3>argA 분포</h3>
    {render_table(inline_arg_rows, [("argAHex","argA"),("promptClass","prompt class"),("positionSlot","slot"),("count","count")])}
    <h3>face id 분포</h3>
    {render_table(inline_face_rows, [("faceIdHex","face id"),("frameIndex","face_01 frame"),("count","count")])}
    <h3>argA + face id pair 상위</h3>
    {render_table(inline_pair_rows, [("argAHex","argA"),("faceIdHex","face id"),("frameIndex","frame"),("count","count")])}
    <h3>샘플</h3>
    {render_table(payload["inline4035SpeakerRowsSample"][:90], [("promptId","prompt"),("speaker","speaker"),("opcodeVaHex","opcode"),("argAHex","argA"),("argAPromptClass","prompt class"),("argAPositionSlot","slot"),("faceIdHex","face id"),("observedFrameIndex","frame"),("hasFaceResource","face resource"),("textSample","text")])}
  </section>

  <section class="panel">
    <h2>부분 승격: face draw cluster 후보</h2>
    <p class="muted"><code>0x00288012 → 0x000d000N → 0x00168011</code> 문맥까지 맞는 행만 승격했다. 이것은 위의 일반 대화 selector가 아니라, 특정 story event에서 face_01 frame들을 직접 그리는 local draw-source cluster로 분리한다.</p>
    <p class="muted">수동 관찰 기준 일반 대화 주 사용 프레임은 {esc(summary["manualDominantDialogueFrames"])}이고, 이 cluster에는 {esc(summary["dominantFramesMissingFromCluster"])}가 없다.</p>
    {render_table(payload["faceDrawClusterRows"], [("drawOpcodeVaHex","draw opcode"),("selectorVaHex","slot/frame"),("frameIndex","frame"),("manualFlashbackRole","manual role"),("localIndex","local idx"),("modeOrArg","arg"),("subscriptVaHex","subscript"),("rootVaHex","root"),("sequenceGroupIds","seq"),("linkedCnsSample","resources")])}
  </section>

  <section class="panel">
    <h2>Story Event 초상화 매칭</h2>
    <p class="muted">사용자 관찰로 `5,4,6,0,3,7` cluster는 일반 speaker selector가 아니라 특정 이벤트에서 초상화를 순서대로 보여주는 연출과 맞는 것으로 분리했다.</p>
    {render_table(payload["eventPortraitClusterFrameLegend"], [("frameIndex","frame"),("role","role")])}
    <h3>관련 prompt 흐름</h3>
    {render_table(payload["eventPortraitPromptSamples"], [("id","prompt"),("status","status"),("startVaHex","start"),("blockId","block"),("textSourceValueHex","source"),("textSample","text")])}
  </section>

  <section class="panel">
    <h2>수동 face frame 힌트</h2>
    <p class="muted">게임 관찰로 받은 face_01 80x80 셀 순서. EXE selector 증거는 아니며, 이후 speaker/frame 검증의 기준값으로만 사용한다.</p>
    {render_table(payload["manualFaceFrameLegend"], [("frameIndex","frame"),("label","label"),("usage","usage"),("source","source")])}
  </section>

  <section class="panel">
    <h2>high-word 0x000d 분류</h2>
    <p class="muted">값만 보면 후보가 많지만, opcode 문맥이 없으면 false positive가 많다.</p>
    {render_table(class_rows, [("classification","classification"),("count","count")])}
    <h3>low word 상위 빈도</h3>
    {render_table(low_rows, [("lowWordHex","low word"),("count","count")])}
    <h3>분류별 샘플</h3>
    {render_table(examples[:100], [("class","class"),("vaHex","va"),("valueHex","value"),("prevHex","prev"),("nextHex","next"),("rootVaHex","root"),("rootHasFace01","root has face"),("sequenceGroupIds","seq")])}
  </section>

  <section class="panel">
    <h2>selector-root high-0x000d reject</h2>
    <p class="muted">이 값들은 기존 selector-root 구조에서 눈에 띄지만, frame 0..15가 아니므로 초상화 번호로 올리지 않는다.</p>
    {render_table(payload["selectorRootHigh0dRejects"], [("rootVaHex","root"),("rowVaHex","row"),("valueHex","value"),("lowWordHex","low"),("kind","kind"),("selector","selector"),("reason","reason")])}
  </section>

  <section class="panel">
    <h2>face_01 포함 prompt 샘플</h2>
    <p class="muted">리소스 availability와 실제 speaker/frame 선택은 아직 같은 증거가 아니다. 런타임 검증 시 이 샘플을 기준으로 face frame을 비교한다.</p>
    {render_table(payload["facePromptSamples"], [("id","prompt"),("startVaHex","start"),("blockId","block"),("classification","class"),("textSourceValueHex","source"),("firstLine","first line"),("textSample","sample")])}
  </section>

  <section class="panel">
    <h2>다음 타깃</h2>
    <pre>{esc(json.dumps(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()
