#!/usr/bin/env python3
"""Consolidate the consumer-side meaning of status/comment selector +0.

Previous passes proved that the status short-comment payload reads selector
byte object+0xa8+0 after binding object+0xa8 to 0x004576d8.  They also proved
that direct and local dynamic writers for backing offset +0 are still missing.

This report deliberately separates those two facts:

* consumer-side meaning: the same +0 selector drives chapter labels, chapter
  titles, and the status-comment group selector in the status/menu payload.
* producer-side writer: still not proven by static x86 absolute or local
  object-VM writer scans.
"""
from __future__ import annotations

import html
import json
import sys
from pathlib import Path
from typing import Any


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

if str(TOOLS) not in sys.path:
    sys.path.insert(0, str(TOOLS))

from probe_exe_scene_tables import read_sections, va_to_offset  # noqa: E402


def load_json(path: Path) -> dict[str, Any]:
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return {}
    return data if isinstance(data, dict) else {}


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


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


def link(path: str) -> str:
    if path.startswith("out/"):
        return "../" + path
    if path.startswith("web/"):
        return path.removeprefix("web/")
    return path


def normalize_text(value: str) -> str:
    return value.replace("\u3000", " ").replace("\x0e", "").strip()


def read_text_record(exe: bytes, sections: list[dict[str, Any]], va_hex: str) -> dict[str, Any]:
    va = int(va_hex, 16)
    off = va_to_offset(sections, va)
    if off is None:
        return {"vaHex": va_hex, "text": "", "rawHex": "", "status": "unmapped"}
    buf = bytearray()
    pos = off
    end = min(len(exe), off + 256)
    terminator = ""
    while pos < end:
        if pos + 4 <= len(exe):
            word = exe[pos : pos + 4]
            if word == b"\x40\x0a\x00\x00":
                terminator = "entry-break-400a0000"
                break
            if word == b"\x40\x02\x00\x00":
                buf.extend(b"\n")
                pos += 4
                continue
            if word in {b"\x40\x03\x00\x00", b"\x40\x08\x00\x00", b"\x40\x0d\x00\x00"}:
                terminator = f"control-{word.hex()}"
                break
        b = exe[pos]
        if b == 0:
            terminator = "nul"
            break
        buf.append(b)
        pos += 1
    text = normalize_text(buf.decode("cp949", errors="replace"))
    return {
        "vaHex": va_hex,
        "fileOffsetHex": hx(off),
        "text": text,
        "rawHex": exe[off:pos].hex(" "),
        "terminator": terminator or "scan-limit",
    }


def source_offset0_consumers(vm: dict[str, Any]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for key in ("opcode13Tables", "opcode14Tables"):
        for row in vm.get(key, []):
            if row.get("sourceModeHex") == "0x03" and row.get("sourceOffsetHex") == "0x00":
                rows.append(
                    {
                        "opcode": "40 13" if key == "opcode13Tables" else "40 14",
                        "recordVaHex": row.get("recordVaHex", ""),
                        "sourceModeHex": row.get("sourceModeHex", ""),
                        "sourceOffsetHex": row.get("sourceOffsetHex", ""),
                        "selectorIdHex": row.get("selectorIdHex", ""),
                        "classification": row.get("classification", ""),
                        "entryCount": len(row.get("entries", [])),
                        "entryPointerHexes": [entry.get("pointerVaHex", "") for entry in row.get("entries", [])],
                    }
                )
    rows.sort(key=lambda row: (row["recordVaHex"], row["opcode"]))
    return rows


def table_by_record(vm: dict[str, Any], record_va_hex: str) -> dict[str, Any]:
    for key in ("opcode13Tables", "opcode14Tables"):
        for row in vm.get(key, []):
            if row.get("recordVaHex") == record_va_hex:
                return row
    return {}


def build() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)

    vm = load_json(OUT / "status_menu_vm_table_review.json")
    ui = load_json(OUT / "status_menu_ui_expression_review.json")
    binding = load_json(OUT / "status_comment_selector_binding_review.json")
    backing_writer = load_json(OUT / "status_comment_backing_writer_scan.json")
    indirect_writer = load_json(OUT / "status_comment_indirect_selector_writer_review.json")

    consumers = source_offset0_consumers(vm)
    chapter_numbers_row = table_by_record(vm, "0x004e8b1a")
    chapter_titles_row = table_by_record(vm, "0x004e8ba2")
    status_selector_row = table_by_record(vm, "0x004e8608")

    chapter_numbers = {
        int(entry.get("index", -1)): read_text_record(exe, sections, str(entry.get("pointerVaHex", "")))
        for entry in chapter_numbers_row.get("entries", [])
        if entry.get("pointerVaHex")
    }
    chapter_titles = {
        int(entry.get("index", -1)): read_text_record(exe, sections, str(entry.get("pointerVaHex", "")))
        for entry in chapter_titles_row.get("entries", [])
        if entry.get("pointerVaHex")
    }

    binding_by_table = {group.get("tableIdHex"): group for group in binding.get("groups", [])}
    status_groups = []
    for index, group in enumerate(ui.get("statusCommentGroups", [])):
        table_id = group.get("tableIdHex", "")
        binding_group = binding_by_table.get(table_id, {})
        comment_block = binding_group.get("commentBlock", {}) if isinstance(binding_group, dict) else {}
        actor_tables = group.get("actorTables", [])
        first_actor_table = actor_tables[0] if actor_tables else {}
        status_groups.append(
            {
                "index": index,
                "tableIdHex": table_id,
                "actorTableCount": group.get("actorTableCount", 0),
                "maxEntryCount": group.get("maxEntryCount", 0),
                "samples": group.get("samples", [])[:3],
                "firstActorTableVaHex": first_actor_table.get("tableVaHex", ""),
                "firstActorEntryCount": first_actor_table.get("entryCount", 0),
                "bindingStatus": binding_group.get("status", ""),
                "commentBlockVaHex": comment_block.get("blockVaHex", ""),
                "containingSceneGroupIds": binding_group.get("containingSceneGroupIds", []),
            }
        )

    index_rows = []
    status_entries = status_selector_row.get("entries", [])
    for index in range(max(len(status_groups), len(chapter_numbers), len(chapter_titles), len(status_entries))):
        group = status_groups[index] if index < len(status_groups) else {}
        status_entry = status_entries[index] if index < len(status_entries) else {}
        index_rows.append(
            {
                "selectorValue": index,
                "selectorValueHex": hx(index, 2),
                "chapterNumber": chapter_numbers.get(index, {}).get("text", ""),
                "chapterNumberVaHex": chapter_numbers.get(index, {}).get("vaHex", ""),
                "chapterTitle": chapter_titles.get(index, {}).get("text", ""),
                "chapterTitleVaHex": chapter_titles.get(index, {}).get("vaHex", ""),
                "statusCommentBlockVaHex": status_entry.get("pointerVaHex", group.get("commentBlockVaHex", "")),
                "statusCommentTableIdHex": group.get("tableIdHex", ""),
                "actorTableCount": group.get("actorTableCount", ""),
                "maxEntryCount": group.get("maxEntryCount", ""),
                "sample": " / ".join(group.get("samples", [])[:1]),
                "bindingStatus": group.get("bindingStatus", ""),
                "sceneGroups": group.get("containingSceneGroupIds", []),
            }
        )

    backing_summary = backing_writer.get("summary", {}) if isinstance(backing_writer.get("summary"), dict) else {}
    indirect_summary = indirect_writer.get("summary", {}) if isinstance(indirect_writer.get("summary"), dict) else {}

    source_artifacts = [
        "out/status_menu_vm_table_review.json",
        "out/status_menu_ui_expression_review.json",
        "out/status_comment_selector_binding_review.json",
        "out/status_comment_backing_writer_scan.json",
        "out/status_comment_indirect_selector_writer_review.json",
    ]

    summary = {
        "selectorBackingBaseVaHex": "0x004576d8",
        "selectorOffsetHex": "0x00",
        "meaningCandidate": "story/chapter selector for status/menu context",
        "meaningConfidence": "high-consumer-evidence-writer-unproven",
        "sourceOffset0ConsumerCount": len(consumers),
        "statusCommentGroupCount": len(status_groups),
        "chapterNumberListCount": len(chapter_numbers),
        "chapterTitleListCount": len(chapter_titles),
        "statusSelectorEntryCount": len(status_entries),
        "offset0DirectWriterProven": bool(backing_summary.get("offset0WriterProven", False)),
        "offset0DirectWriteLikeCount": backing_summary.get("offset0WriteLikeCount", 0),
        "offset0DynamicWriterInStatusMenuPayloadCount": indirect_summary.get("target0InStatusMenuPayloadCount", 0),
        "offset0DynamicWriterInStatusCommentPayloadCount": indirect_summary.get("target0InStatusCommentPayloadCount", 0),
        "producerStatus": "writer-unresolved",
        "decision": (
            "Consumer-side meaning is promoted: selector +0 indexes chapter number/title lists "
            "and the 9-entry status-comment group array.  The upstream writer remains unresolved."
        ),
    }

    return {
        "version": 1,
        "kind": "hwanse-status-comment-selector-meaning-review",
        "sourceArtifacts": source_artifacts,
        "summary": summary,
        "sourceOffset0Consumers": consumers,
        "indexRows": index_rows,
        "statusCommentGroups": status_groups,
        "chapterNumberEntries": chapter_numbers,
        "chapterTitleEntries": chapter_titles,
        "evidenceGaps": [
            {
                "name": "direct x86 writer for 0x004576d8+0",
                "status": "not-found",
                "evidence": f"write-like count {backing_summary.get('offset0WriteLikeCount', 0)}",
            },
            {
                "name": "local object-VM dynamic writer inside status/menu payload",
                "status": "not-found",
                "evidence": f"status/menu target0 candidates {indirect_summary.get('target0InStatusMenuPayloadCount', 0)}",
            },
            {
                "name": "game meaning",
                "status": "promoted",
                "evidence": "same selector consumes chapter labels, chapter titles, and status-comment group blocks",
            },
        ],
    }


def render_html(data: dict[str, Any]) -> str:
    s = data["summary"]
    cards = "".join(
        f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>"
        for k, v in s.items()
    )
    consumer_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['opcode'])}</code></td>"
        f"<td><code>{h(row['recordVaHex'])}</code></td>"
        f"<td><code>{h(row['sourceModeHex'])}:{h(row['sourceOffsetHex'])}</code></td>"
        f"<td>{h(row['classification'])}</td>"
        f"<td>{h(row['entryCount'])}</td>"
        "</tr>"
        for row in data["sourceOffset0Consumers"]
    )
    index_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['selectorValueHex'])}</code></td>"
        f"<td>{h(row['chapterNumber'])}<br><code>{h(row['chapterNumberVaHex'])}</code></td>"
        f"<td>{h(row['chapterTitle'])}<br><code>{h(row['chapterTitleVaHex'])}</code></td>"
        f"<td><code>{h(row['statusCommentTableIdHex'])}</code><br><code>{h(row['statusCommentBlockVaHex'])}</code></td>"
        f"<td>{h(row['actorTableCount'])} / {h(row['maxEntryCount'])}</td>"
        f"<td>{h(row['sample'])}</td>"
        f"<td>{h(row['bindingStatus'])}<br>{h(', '.join(row.get('sceneGroups', [])))}</td>"
        "</tr>"
        for row in data["indexRows"]
    )
    group_rows = "".join(
        "<tr>"
        f"<td>{h(group['index'])}</td>"
        f"<td><code>{h(group['tableIdHex'])}</code></td>"
        f"<td><code>{h(group['commentBlockVaHex'])}</code></td>"
        f"<td>{h(group['actorTableCount'])}</td>"
        f"<td>{h(group['maxEntryCount'])}</td>"
        f"<td>{h(' / '.join(group.get('samples', [])[:2]))}</td>"
        f"<td>{h(group['bindingStatus'])}</td>"
        "</tr>"
        for group in data["statusCommentGroups"]
    )
    gap_rows = "".join(
        "<tr>"
        f"<td>{h(row['name'])}</td>"
        f"<td>{h(row['status'])}</td>"
        f"<td>{h(row['evidence'])}</td>"
        "</tr>"
        for row in data["evidenceGaps"]
    )
    links = "".join(
        f"<a class='chip' href='{h(link(path))}'>{h(path)}</a>"
        for path in data["sourceArtifacts"]
    )
    return f"""<!doctype html>
<meta charset="utf-8">
<title>Status Comment Selector Meaning Review</title>
<style>
  :root {{ color-scheme: light dark; }}
  body {{ margin: 24px; font-family: system-ui, -apple-system, Segoe UI, sans-serif; line-height: 1.45; }}
  h1 {{ margin: 0 0 8px; font-size: 24px; }}
  h2 {{ margin: 28px 0 10px; font-size: 18px; }}
  .muted {{ color: #667085; }}
  .cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 10px; margin: 16px 0; }}
  .card {{ border: 1px solid #d0d5dd; border-radius: 8px; padding: 10px 12px; background: rgba(127,127,127,.05); }}
  .card b {{ display: block; font-size: 12px; color: #667085; }}
  .card span {{ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }}
  table {{ width: 100%; border-collapse: collapse; margin: 8px 0 20px; }}
  th, td {{ border: 1px solid #d0d5dd; padding: 7px 8px; vertical-align: top; }}
  th {{ text-align: left; background: rgba(127,127,127,.08); }}
  code {{ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }}
  .scroll {{ overflow-x: auto; }}
  .chip {{ display: inline-block; margin: 0 6px 6px 0; padding: 5px 8px; border: 1px solid #d0d5dd; border-radius: 999px; text-decoration: none; color: inherit; }}
</style>
<h1>Status Comment Selector Meaning Review</h1>
<p class="muted">상태창 짧은 문구 selector <code>0x004576d8+0</code>의 소비 의미와 남은 writer gap을 분리한 정리 페이지입니다.</p>
<div class="cards">{cards}</div>
<h2>Source +0 Consumers</h2>
<div class="scroll"><table>
<thead><tr><th>opcode</th><th>record</th><th>source</th><th>classification</th><th>entries</th></tr></thead>
<tbody>{consumer_rows}</tbody>
</table></div>
<h2>Selector Index Mapping</h2>
<div class="scroll"><table>
<thead><tr><th>selector</th><th>chapter no.</th><th>chapter title</th><th>status comment block</th><th>actor/max</th><th>sample</th><th>binding</th></tr></thead>
<tbody>{index_rows}</tbody>
</table></div>
<h2>Status Comment Groups</h2>
<div class="scroll"><table>
<thead><tr><th>index</th><th>table</th><th>block</th><th>actors</th><th>max entries</th><th>samples</th><th>binding</th></tr></thead>
<tbody>{group_rows}</tbody>
</table></div>
<h2>Evidence Gaps</h2>
<table>
<thead><tr><th>item</th><th>status</th><th>evidence</th></tr></thead>
<tbody>{gap_rows}</tbody>
</table>
<h2>Source Artifacts</h2>
<p>{links}</p>
"""


def main() -> None:
    data = build()
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    (OUT / "status_comment_selector_meaning_review.json").write_text(
        json.dumps(data, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    (WEB / "status_comment_selector_meaning_review.html").write_text(
        render_html(data),
        encoding="utf-8",
    )


if __name__ == "__main__":
    main()
