#!/usr/bin/env python3
"""Review active-object initializers whose draw-source selector is blank.

The strict active-object inventory has many rows that write an +0xec script and
the active gate, but do not write object+0x28.  Since object+0x28 is grounded as
the visible draw-source selector, this report checks whether those blank rows
later assign a draw source from their own script or an attached one.

This intentionally keeps command-boundary evidence separate from visual guesses:
if a blank row never writes +0x28 in its decoded script chain, it remains an
invisible hotspot/controller candidate rather than an NPC/sprite placement.
"""
from __future__ import annotations

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

from probe_exe_scene_tables import read_sections
from summarize_object_payload_442c75_callers import decode_stream


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


POSITION_OPCODES = {0x55, 0x64, 0x65, 0x66, 0x67, 0x70, 0x72}
FLAG_OPCODES = {0x31, 0x32}
PROMPT_OPCODES = {0x2F, 0x84}
RESOURCE_OPCODES = {0x11, 0x16, 0x26}


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


def load_json(path: Path, fallback: Any) -> Any:
    if not path.exists():
        return fallback
    return json.loads(path.read_text(encoding="utf-8"))


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


def command_is_field28_write(command: dict[str, Any]) -> bool:
    if command.get("fieldOffset") == 0x28:
        return True
    if command.get("opcode") == 0x08:
        for write in command.get("writes") or []:
            if write.get("fieldOffset") == 0x28:
                return True
    return False


def command_is_ec_write(command: dict[str, Any]) -> bool:
    if command.get("fieldOffset") == 0xEC:
        return True
    if command.get("opcode") == 0x08:
        for write in command.get("writes") or []:
            if write.get("fieldOffset") == 0xEC:
                return True
    return False


def attached_targets(commands: list[dict[str, Any]]) -> list[int]:
    out = []
    for command in commands:
        if command.get("opcode") != 0x20:
            continue
        for operand in command.get("pointerOperands") or []:
            value = operand.get("value")
            if isinstance(value, int):
                out.append(value)
    return out


def summarize_commands(commands: list[dict[str, Any]]) -> dict[str, Any]:
    opcodes = Counter(command.get("opcodeHex") or "" for command in commands)
    opcode_values = {command.get("opcode") for command in commands}
    text_previews = []
    for command in commands:
        for ref in command.get("textPayloadRefs") or []:
            preview = ref.get("textPreview")
            if preview and preview not in text_previews:
                text_previews.append(preview)
    return {
        "commandCount": len(commands),
        "opcodeCounts": dict(opcodes.most_common()),
        "hasPrompt": bool(opcode_values & PROMPT_OPCODES) or bool(text_previews),
        "hasFlag": bool(opcode_values & FLAG_OPCODES),
        "hasPositionOrActiveScan": bool(opcode_values & POSITION_OPCODES),
        "hasResourceCommand": bool(opcode_values & RESOURCE_OPCODES),
        "hasAttachScript": 0x20 in opcode_values,
        "textPreviews": text_previews[:4],
        "field0x28Writes": [
            {
                "vaHex": command.get("vaHex"),
                "opcodeHex": command.get("opcodeHex"),
                "summary": command.get("summary") or "",
            }
            for command in commands
            if command_is_field28_write(command)
        ],
        "field0xecWrites": [
            {
                "vaHex": command.get("vaHex"),
                "opcodeHex": command.get("opcodeHex"),
                "summary": command.get("summary") or "",
            }
            for command in commands
            if command_is_ec_write(command)
        ],
    }


def classify(row: dict[str, Any], summary: dict[str, Any], attached_summary: dict[str, Any]) -> str:
    if summary["field0x28Writes"] or attached_summary["field0x28Writes"]:
        return "dynamic-draw-source"
    if summary["hasPrompt"] or row.get("textPayloadRefCount"):
        return "invisible-text-or-interaction-hotspot"
    if summary["hasFlag"] or attached_summary["hasFlag"]:
        return "invisible-flag-or-branch-controller"
    if summary["hasPositionOrActiveScan"] or attached_summary["hasPositionOrActiveScan"]:
        return "invisible-position-or-active-controller"
    if summary["hasAttachScript"]:
        return "invisible-attached-controller"
    return "blank-selector-unknown-controller"


def build() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    inventory = load_json(OUT / "active_object_script_inventory.json", {}).get("inventory") or []
    blank_rows = [row for row in inventory if not row.get("field0x28Hex")]

    rows: list[dict[str, Any]] = []
    direct_opcode_counts: Counter[str] = Counter()
    attached_opcode_counts: Counter[str] = Counter()
    shape_counts: Counter[str] = Counter()
    field0x2c_counts: Counter[str] = Counter()
    field0x16_counts: Counter[str] = Counter()
    class_counts: Counter[str] = Counter()
    attach_target_count = 0
    attach_target_unique: set[int] = set()

    for source in blank_rows:
        direct = decode_stream(exe, sections, int(source["scriptVa"]), max_commands=120, max_bytes=0x400)
        direct_commands = direct.get("commands") or []
        for opcode, count in summarize_commands(direct_commands)["opcodeCounts"].items():
            direct_opcode_counts[opcode] += count

        attached_streams = []
        attached_commands: list[dict[str, Any]] = []
        for target in attached_targets(direct_commands):
            attach_target_count += 1
            attach_target_unique.add(target)
            attached = decode_stream(exe, sections, target, max_commands=80, max_bytes=0x240)
            attached_streams.append({
                "scriptVa": target,
                "scriptVaHex": hx(target),
                "decodedCommandCount": attached.get("decodedCommandCount"),
                "firstCommands": [
                    {
                        "vaHex": command.get("vaHex"),
                        "opcodeHex": command.get("opcodeHex"),
                        "opcodeName": command.get("opcodeName"),
                        "summary": command.get("summary") or "",
                    }
                    for command in (attached.get("commands") or [])[:8]
                ],
            })
            attached_commands.extend(attached.get("commands") or [])

        direct_summary = summarize_commands(direct_commands)
        attached_summary = summarize_commands(attached_commands)
        for opcode, count in attached_summary["opcodeCounts"].items():
            attached_opcode_counts[opcode] += count

        row_class = classify(source, direct_summary, attached_summary)
        class_counts[row_class] += 1
        shape = f"{source.get('wCandidate')}x{source.get('hCandidate')}"
        shape_counts[shape] += 1
        field0x2c_counts[source.get("field0x2cHex") or "(blank)"] += 1
        field0x16_counts[source.get("field0x16Hex") or "(blank)"] += 1

        rows.append({
            **source,
            "blankReviewClass": row_class,
            "directScript": {
                "decodedCommandCount": direct.get("decodedCommandCount"),
                "firstCommands": [
                    {
                        "vaHex": command.get("vaHex"),
                        "opcodeHex": command.get("opcodeHex"),
                        "opcodeName": command.get("opcodeName"),
                        "summary": command.get("summary") or "",
                    }
                    for command in direct_commands[:10]
                ],
                **direct_summary,
            },
            "attachedScriptCount": len(attached_streams),
            "attachedScripts": attached_streams[:12],
            "attachedSummary": attached_summary,
        })

    direct_field28_rows = [
        row for row in rows if row["directScript"]["field0x28Writes"]
    ]
    attached_field28_rows = [
        row for row in rows if row["attachedSummary"]["field0x28Writes"]
    ]
    dynamic_rows = [
        row for row in rows if row["blankReviewClass"] == "dynamic-draw-source"
    ]
    text_rows = [
        row for row in rows if row["blankReviewClass"] == "invisible-text-or-interaction-hotspot"
    ]
    unknown_rows = [
        row for row in rows if row["blankReviewClass"] == "blank-selector-unknown-controller"
    ]

    summary = {
        "blankField0x28InitializerCount": len(blank_rows),
        "directScriptField0x28WriteRowCount": len(direct_field28_rows),
        "attachedScriptField0x28WriteRowCount": len(attached_field28_rows),
        "dynamicDrawSourceRowCount": len(dynamic_rows),
        "attachCommandCount": attach_target_count,
        "uniqueAttachedScriptCount": len(attach_target_unique),
        "classificationCounts": dict(class_counts.most_common()),
        "topDirectOpcodes": dict(direct_opcode_counts.most_common(16)),
        "topAttachedOpcodes": dict(attached_opcode_counts.most_common(16)),
        "topShapes": dict(shape_counts.most_common(18)),
        "field0x2cCounts": dict(field0x2c_counts.most_common()),
        "field0x16Counts": dict(field0x16_counts.most_common()),
        "conclusion": (
            "No command-boundary object+0x28 writes were found in blank-selector rows, "
            "even after following one level of opcode 0x20 attached scripts. At this evidence level "
            "these rows should be treated as invisible hotspots/controllers, not visible NPC/object placements."
        ),
    }
    return {
        "kind": "hwanse-active-object-blank-draw-source-review",
        "summary": summary,
        "rows": rows,
        "dynamicDrawSourceRows": dynamic_rows,
        "textHotspotSamples": text_rows[:60],
        "unknownControllerRows": unknown_rows,
        "nonClaims": [
            "Blank field0x28 rows are not promoted to visible NPC/sprite placement.",
            "Text/prompt refs prove interaction scripting, not map transition destination.",
            "Opcode 0x20 attached scripts were followed one level; no object+0x28 writer was found there.",
            "If visible NPC placement exists, it likely uses another descriptor path or runtime materialization path, not these blank initializers.",
        ],
    }


def render_html(payload: dict[str, Any]) -> str:
    s = payload["summary"]
    metrics = [
        ("blank field0x28", s["blankField0x28InitializerCount"]),
        ("direct +0x28 writers", s["directScriptField0x28WriteRowCount"]),
        ("attached +0x28 writers", s["attachedScriptField0x28WriteRowCount"]),
        ("dynamic draw-source", s["dynamicDrawSourceRowCount"]),
        ("attach commands followed", s["attachCommandCount"]),
        ("unique attached scripts", s["uniqueAttachedScriptCount"]),
    ]
    metric_html = "".join(
        f"<div class='metric'><span>{esc(label)}</span><strong>{esc(value)}</strong></div>"
        for label, value in metrics
    )
    class_rows = "".join(
        f"<tr><td><code>{esc(key)}</code></td><td>{esc(value)}</td></tr>"
        for key, value in s["classificationCounts"].items()
    )
    shape_rows = "".join(
        f"<tr><td><code>{esc(key)}</code></td><td>{esc(value)}</td></tr>"
        for key, value in s["topShapes"].items()
    )
    opcode_rows = "".join(
        f"<tr><td><code>{esc(key)}</code></td><td>{esc(value)}</td><td>{esc(s['topAttachedOpcodes'].get(key, ''))}</td></tr>"
        for key, value in s["topDirectOpcodes"].items()
    )
    sample_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row.get('initializerVaHex'))}</code><div class='muted'>{esc(row.get('scriptVaHex'))}</div></td>"
        f"<td>{esc(row.get('xCandidate'))},{esc(row.get('yCandidate'))}<div class='muted'>{esc(row.get('wCandidate'))}x{esc(row.get('hCandidate'))}</div></td>"
        f"<td>{esc(row.get('blankReviewClass'))}</td>"
        f"<td>{'<br>'.join(esc(text).replace(chr(10), '<br>') for text in (row['directScript'].get('textPreviews') or [])[:2]) or '-'}</td>"
        f"<td>{esc(row.get('attachedScriptCount'))}</td>"
        f"<td>{'<br>'.join(esc((cmd.get('opcodeHex') or '') + ' ' + (cmd.get('summary') or '')) for cmd in (row['directScript'].get('firstCommands') or [])[:5])}</td>"
        "</tr>"
        for row in payload["rows"][:180]
    )
    non_claims = "".join(f"<li>{esc(item)}</li>" for item in payload["nonClaims"])
    data = json.dumps(payload, ensure_ascii=False)
    return f"""<!doctype html>
<meta charset="utf-8">
<title>Active Object Blank Draw-source Review</title>
<style>
body{{font-family:system-ui,sans-serif;background:#101214;color:#e5e7eb;margin:24px;line-height:1.45}}
a{{color:#93c5fd}} code{{color:#bfdbfe}} table{{border-collapse:collapse;width:100%;margin:14px 0}}
td,th{{border:1px solid #374151;padding:7px 9px;vertical-align:top}} th{{background:#1f2937}}
.metrics{{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;margin:14px 0}}
.metric{{background:#181c20;border:1px solid #2f3640;border-radius:6px;padding:10px}}
.metric span{{display:block;color:#9ca3af;font-size:12px}} .metric strong{{font-size:24px}}
.grid{{display:grid;grid-template-columns:1fr 1fr;gap:16px}} .muted{{color:#9ca3af;font-size:12px}}
@media (max-width:900px){{.grid{{grid-template-columns:1fr}}}}
</style>
<h1>Active Object Blank Draw-source Review</h1>
<p><code>field0x28</code>이 비어 있는 strict active object initializer가 후속 스크립트에서 draw-source를 쓰는지 검증한다.</p>
<div class="metrics">{metric_html}</div>
<p>{esc(s["conclusion"])}</p>
<div class="grid">
  <section><h2>Classification</h2><table><thead><tr><th>class</th><th>count</th></tr></thead><tbody>{class_rows}</tbody></table></section>
  <section><h2>Shape Counts</h2><table><thead><tr><th>w x h</th><th>count</th></tr></thead><tbody>{shape_rows}</tbody></table></section>
</div>
<h2>Top Opcodes</h2>
<table><thead><tr><th>opcode</th><th>direct scripts</th><th>attached scripts</th></tr></thead><tbody>{opcode_rows}</tbody></table>
<h2>Blank Row Samples</h2>
<table><thead><tr><th>init/script</th><th>tile/size</th><th>class</th><th>text preview</th><th>attached</th><th>first commands</th></tr></thead><tbody>{sample_rows}</tbody></table>
<h2>Non-claims</h2><ul>{non_claims}</ul>
<script>window.HWANSE_ACTIVE_OBJECT_BLANK_DRAW_SOURCE_REVIEW = {data};</script>
"""


def main() -> None:
    OUT.mkdir(parents=True, exist_ok=True)
    payload = build()
    (OUT / "active_object_blank_draw_source_review.json").write_text(
        json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    print(json.dumps(payload["summary"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
