#!/usr/bin/env python3
"""Classify dynamic object-VM target +0 writers by root context.

The status-comment selector consumer reads object+0xa8+0 only after a status
payload binds object+0xa8 to 0x004576d8.  A previous broad scan found 52
dynamic object-VM commands that write to local target offset +0, but none were
inside the status/menu payload.

This review prevents a false promotion: local target offset +0 is not enough.
The candidate root/resource contexts are battle effects, monsters, or field
object scripts, so they are object-local writers unless a status/menu base
binding is also proven.
"""
from __future__ import annotations

import html
import json
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any


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


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


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


def classify_context(row: dict[str, Any]) -> str:
    cns = " ".join(row.get("linkedCns") or []).lower()
    maps = row.get("fieldMaps") or []
    if "btl_" in cns or any(name.startswith("z") for name in (row.get("linkedCns") or [])):
        return "battle-or-monster-object-local"
    if maps or "map_" in cns or "cara_" in cns:
        return "field-object-local"
    return "resource-object-local"


def build() -> dict[str, Any]:
    indirect = load_json("status_comment_indirect_selector_writer_review.json")
    target_rows = indirect.get("target0WriterCandidates", [])
    if not isinstance(target_rows, list):
        target_rows = []

    root_groups: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
    for row in target_rows:
        if isinstance(row, dict):
            root_groups[(row.get("rootVaHex", ""), row.get("rootClass", ""))].append(row)

    root_rows = []
    class_counts: Counter[str] = Counter()
    opcode_counts: Counter[str] = Counter()
    status_menu_hits = 0
    status_comment_hits = 0
    status_backing_bind_hits = 0
    for (root_va, root_class), rows in sorted(root_groups.items(), key=lambda item: item[0][0]):
        maps = sorted({m for row in rows for m in (row.get("fieldMaps") or [])})
        cns = sorted({c for row in rows for c in (row.get("linkedCns") or [])})
        opcodes = Counter(row.get("opcodeHex", "") for row in rows)
        contexts = Counter(classify_context(row) for row in rows)
        context_class = contexts.most_common(1)[0][0] if contexts else "unknown"
        class_counts[context_class] += len(rows)
        opcode_counts.update(opcodes)
        status_menu_hits += sum(1 for row in rows if row.get("inStatusMenuPayloadRange"))
        status_comment_hits += sum(1 for row in rows if row.get("inStatusCommentPayloadRange"))
        # None of these rows contains a proven 40 1b status backing bind in the
        # previous scan. Keep an explicit column so future scans can promote it
        # only if that binding is observed.
        root_rows.append(
            {
                "rootVaHex": root_va,
                "rootClass": root_class,
                "candidateCount": len(rows),
                "opcodeCounts": dict(opcodes),
                "contextClass": context_class,
                "fieldMapCount": len(maps),
                "fieldMaps": maps[:16],
                "linkedCnsCount": len(cns),
                "linkedCns": cns[:20],
                "statusBackingBindProven": False,
                "sampleCommandVaHexes": [row.get("commandVaHex", "") for row in rows[:8]],
            }
        )

    summary = {
        "target0CandidateCount": len(target_rows),
        "uniqueRootCount": len(root_rows),
        "opcodeCounts": dict(opcode_counts),
        "contextClassCounts": dict(class_counts),
        "statusMenuPayloadHitCount": status_menu_hits,
        "statusCommentPayloadHitCount": status_comment_hits,
        "statusBackingBindHitCount": status_backing_bind_hits,
        "statusCommentWriterPromoted": False,
        "decision": (
            "The 52 target +0 dynamic writes are object-local active-script/resource writers. "
            "They do not occur inside status/menu payloads and no 0x004576d8 backing-base bind is proven, "
            "so they must not be promoted as the status comment story/chapter selector writer."
        ),
    }
    return {
        "version": 1,
        "kind": "hwanse-status-comment-target0-writer-context-review",
        "sourceArtifacts": ["out/status_comment_indirect_selector_writer_review.json"],
        "summary": summary,
        "rootRows": root_rows,
        "candidateRows": target_rows,
    }


def render_html(data: dict[str, Any]) -> str:
    cards = "".join(
        f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>"
        for k, v in data["summary"].items()
    )
    rows = "".join(
        "<tr>"
        f"<td><code>{h(row['rootVaHex'])}</code><br>{h(row['rootClass'])}</td>"
        f"<td>{h(row['candidateCount'])}</td>"
        f"<td>{h(row['contextClass'])}</td>"
        f"<td>{h(row['opcodeCounts'])}</td>"
        f"<td>{h(', '.join(row['fieldMaps']))}</td>"
        f"<td>{h(', '.join(row['linkedCns']))}</td>"
        f"<td>{h(row['statusBackingBindProven'])}</td>"
        f"<td>{h(', '.join(row['sampleCommandVaHexes']))}</td>"
        "</tr>"
        for row in data["rootRows"]
    )
    return f"""<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Status Comment Target +0 Writer Context 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; }}
  .muted {{ color:#667085; }}
  .cards {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,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; color:#667085; font-size:12px; }}
  .card span {{ font-family:ui-monospace,SFMono-Regular,Menlo,monospace; overflow-wrap:anywhere; }}
  .scroll {{ overflow-x:auto; }}
  table {{ width:100%; border-collapse:collapse; }}
  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; }}
</style>
<h1>Status Comment Target +0 Writer Context Review</h1>
<p class="muted">dynamic writer target offset <code>+0</code> 후보를 root/resource 문맥으로 재분류합니다. offset만 같아도 status/comment backing writer로 승격하지 않습니다.</p>
<div class="cards">{cards}</div>
<div class="scroll"><table>
<thead><tr><th>root</th><th>count</th><th>context</th><th>opcodes</th><th>field maps</th><th>linked CNS</th><th>status backing bind</th><th>samples</th></tr></thead>
<tbody>{rows}</tbody>
</table></div>
"""


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


if __name__ == "__main__":
    main()
