#!/usr/bin/env python3
"""Review map_*3 active-object projection binding evidence.

This report sits above ``map3_manual_placements``.  The manual placement file
labels visible map_*3 objects and matches many of them to strict active object
initializers.  This pass asks a narrower question:

* do active opcode 0x64 tile-to-draw projection commands exist for the same
  tile coordinates?
* do those commands and initializers share a selector/root range?
* are we looking at a coordinate-only clue, an initializer-grounded clue, or a
  stronger root-linked clue?

It intentionally does not promote coordinate-only rows to final placement
proof.  Same visual maps can have multiple resource/scene variants, so repeated
initializer rows are expected.  Some door-looking rows can also be scene-driven
active states: an NPC can open a door, enter, and close it again, so a
projection command may represent state/motion rather than initial placement.
"""
from __future__ import annotations

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

sys.path.insert(0, str(Path(__file__).resolve().parent))

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"


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


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


def scan_opcode64_candidates(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for sec in sections:
        if sec["name"] not in {".data", ".rdata"}:
            continue
        raw = int(sec["raw"])
        blob = exe[raw: raw + int(sec["raw_size"])]
        for pos in range(0, max(len(blob) - 8, 0)):
            if blob[pos] != 0x64:
                continue
            mode = blob[pos + 1]
            if mode not in {1, 2, 3, 4}:
                continue
            x = struct.unpack_from("<H", blob, pos + 2)[0]
            y = struct.unpack_from("<H", blob, pos + 4)[0]
            object_id = blob[pos + 6]
            state = blob[pos + 7]
            if x > 180 or y > 140 or state > 0x40:
                continue
            va = offset_to_va(sections, raw + pos)
            rows.append({
                "commandVa": va,
                "commandVaHex": hx(va),
                "section": sec["name"],
                "mode": mode,
                "x": x,
                "y": y,
                "objectId": object_id,
                "objectIdHex": f"0x{object_id:02x}",
                "state": state,
                "stateHex": f"0x{state:02x}",
                "rawHex": blob[pos: pos + 8].hex(" "),
            })
    return rows


def range_roots(roots: list[dict[str, Any]], va: int | None) -> list[dict[str, Any]]:
    if va is None:
        return []
    hits = []
    for root in roots:
        start = root.get("rootVa")
        end = root.get("rangeEndVa")
        if isinstance(start, int) and isinstance(end, int) and start <= va < end:
            hits.append(root)
    return hits


def root_brief(root: dict[str, Any]) -> dict[str, Any]:
    return {
        "rootVaHex": root.get("rootVaHex"),
        "rangeEndVaHex": root.get("rangeEndVaHex"),
        "rootClass": root.get("rootClass"),
        "selectorKeys": root.get("selectorKeys") or [],
        "linkedCns": root.get("linkedCns") or [],
        "fieldMaps": root.get("fieldMaps") or [],
        "sequenceGroupIds": root.get("sequenceGroupIds") or [],
    }


def scene_resource_roots(
    scene_manifest: list[dict[str, Any]],
    roots: list[dict[str, Any]],
    map_name: str,
    asset: str,
) -> list[dict[str, Any]]:
    hits: dict[str, dict[str, Any]] = {}
    for row in scene_manifest:
        if row.get("map") != map_name:
            continue
        for resource in row.get("resources") or []:
            if resource.get("name") != asset or not isinstance(resource.get("refVa"), int):
                continue
            for root in range_roots(roots, int(resource["refVa"])):
                hits[str(root.get("rootVaHex"))] = root_brief(root)
    return list(hits.values())


def object_id_match(command: dict[str, Any], initializer: dict[str, Any]) -> str:
    field = initializer.get("field0x16Hex")
    if not field:
        return "initializer-field0x16-not-written"
    try:
        value = int(str(field), 16)
    except ValueError:
        return "initializer-field0x16-unparseable"
    return "object-id-match" if value == command["objectId"] else "object-id-different"


def initializer_roots(
    roots: list[dict[str, Any]],
    initializer: dict[str, Any],
) -> list[dict[str, Any]]:
    vas = []
    for key in ("initializerVaHex", "scriptVaHex"):
        text = initializer.get(key)
        if isinstance(text, str) and text.startswith("0x"):
            try:
                vas.append(int(text, 16))
            except ValueError:
                pass
    by_root: dict[str, dict[str, Any]] = {}
    for va in vas:
        for root in range_roots(roots, va):
            by_root[str(root.get("rootVaHex"))] = root_brief(root)
    return list(by_root.values())


def classify_binding(
    manual: dict[str, Any],
    command_hits: list[dict[str, Any]],
    initializer_hits: list[dict[str, Any]],
    resource_roots: list[dict[str, Any]],
) -> str:
    if not command_hits and not initializer_hits:
        return "manual-only"
    if initializer_hits and not command_hits:
        return "initializer-grounded-no-projection-command"
    if command_hits and not initializer_hits:
        return "projection-coordinate-only"
    command_root_keys = {
        root.get("rootVaHex")
        for hit in command_hits
        for root in hit.get("commandRoots", [])
        if root.get("rootVaHex")
    }
    initializer_root_keys = {
        root.get("rootVaHex")
        for hit in initializer_hits
        for root in hit.get("initializerRoots", [])
        if root.get("rootVaHex")
    }
    resource_root_keys = {root.get("rootVaHex") for root in resource_roots if root.get("rootVaHex")}
    if command_root_keys & initializer_root_keys:
        return "projection-and-initializer-same-root-candidate"
    if (command_root_keys & resource_root_keys) or (initializer_root_keys & resource_root_keys):
        return "projection-or-initializer-resource-root-candidate"
    return "projection-and-initializer-coordinate-candidate"


def build() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    roots_data = load_json(OUT / "selector_root_structure_review.json", {})
    roots = roots_data.get("roots", []) if isinstance(roots_data, dict) else []
    scene_manifest = load_json(OUT / "scene_manifest.json", [])
    manual_data = load_json(OUT / "map3_manual_placements.json", {})
    active_data = load_json(OUT / "active_object_script_inventory.json", {})
    manual_rows = manual_data.get("flatRows", []) if isinstance(manual_data, dict) else []
    inventory = active_data.get("inventory", []) if isinstance(active_data, dict) else []

    commands = scan_opcode64_candidates(exe, sections)
    commands_by_xy: dict[tuple[int, int], list[dict[str, Any]]] = defaultdict(list)
    for row in commands:
        row["commandRoots"] = [root_brief(root) for root in range_roots(roots, int(row["commandVa"]))]
        commands_by_xy[(int(row["x"]), int(row["y"]))].append(row)

    slot0c_rows = [
        row for row in inventory
        if isinstance(row, dict) and str(row.get("field0x28Hex", "")).startswith("0x000c")
    ]
    initializers_by_xy: dict[tuple[int, int], list[dict[str, Any]]] = defaultdict(list)
    for row in slot0c_rows:
        x = row.get("xCandidate")
        y = row.get("yCandidate")
        if isinstance(x, int) and isinstance(y, int):
            copy = dict(row)
            copy["initializerRoots"] = initializer_roots(roots, row)
            initializers_by_xy[(x, y)].append(copy)

    binding_rows: list[dict[str, Any]] = []
    for manual in manual_rows:
        placement = manual.get("placementTile") or []
        if len(placement) != 2:
            continue
        x, y = int(placement[0]), int(placement[1])
        expected_fields = set(manual.get("candidateField0x28Hexes") or [])
        expected_w = int(manual.get("rect", [0, 0, 0, 0])[2]) // 16
        expected_h = int(manual.get("rect", [0, 0, 0, 0])[3]) // 16

        command_hits = commands_by_xy.get((x, y), [])
        initializer_hits = [
            row for row in initializers_by_xy.get((x, y), [])
            if (not expected_fields or row.get("field0x28Hex") in expected_fields)
            and row.get("wCandidate") == expected_w
            and row.get("hCandidate") == expected_h
        ]
        resource_roots = scene_resource_roots(scene_manifest, roots, str(manual.get("map")), str(manual.get("asset")))

        command_sample = []
        for command in command_hits[:12]:
            command_sample.append({
                "commandVaHex": command["commandVaHex"],
                "mode": command["mode"],
                "objectIdHex": command["objectIdHex"],
                "stateHex": command["stateHex"],
                "rawHex": command["rawHex"],
                "commandRoots": command["commandRoots"][:3],
            })
        initializer_sample = []
        for initializer in initializer_hits[:12]:
            oid_results = sorted({object_id_match(command, initializer) for command in command_hits}) if command_hits else []
            initializer_sample.append({
                "initializerVaHex": initializer.get("initializerVaHex"),
                "scriptVaHex": initializer.get("scriptVaHex"),
                "field0x28Hex": initializer.get("field0x28Hex"),
                "field0x16Hex": initializer.get("field0x16Hex"),
                "scriptClassification": initializer.get("scriptClassification"),
                "objectIdMatchAgainstProjection": oid_results,
                "initializerRoots": initializer.get("initializerRoots", [])[:3],
            })

        binding_status = classify_binding(manual, command_sample, initializer_sample, resource_roots)
        binding_rows.append({
            "map": manual.get("map"),
            "asset": manual.get("asset"),
            "rectLabel": manual.get("rectLabel"),
            "label": manual.get("label"),
            "tile": [x, y],
            "rect": manual.get("rect"),
            "candidateField0x28Hexes": sorted(expected_fields),
            "manualExePlacementStatus": manual.get("exePlacementStatus"),
            "bindingStatus": binding_status,
            "opcode64CoordinateHitCount": len(command_hits),
            "initializerCoordinateHitCount": len(initializer_hits),
            "resourceRootCount": len(resource_roots),
            "opcode64Hits": command_sample,
            "initializerHits": initializer_sample,
            "resourceRoots": resource_roots[:8],
        })

    status_counts = Counter(row["bindingStatus"] for row in binding_rows)
    command_root_hit_count = sum(1 for row in commands if row.get("commandRoots"))
    manual_command_hit_count = sum(1 for row in binding_rows if row["opcode64CoordinateHitCount"])
    manual_init_hit_count = sum(1 for row in binding_rows if row["initializerCoordinateHitCount"])
    object_id_match_counts = Counter()
    for row in binding_rows:
        for init in row["initializerHits"]:
            for item in init.get("objectIdMatchAgainstProjection", []):
                object_id_match_counts[item] += 1

    report = {
        "kind": "map3-projection-binding-review",
        "promotionStatus": "projection-command-grounded-binding-partial",
        "summary": {
            "opcode64CandidateCount": len(commands),
            "opcode64RootContainedCount": command_root_hit_count,
            "manualPlacementCount": len(binding_rows),
            "manualPlacementsWithOpcode64CoordinateHit": manual_command_hit_count,
            "manualPlacementsWithInitializerHit": manual_init_hit_count,
            "bindingStatusCounts": dict(sorted(status_counts.items())),
            "objectIdMatchCounts": dict(sorted(object_id_match_counts.items())),
            "conclusion": (
                "opcode 0x64 is grounded as tile-to-draw projection.  The same "
                "manual map_*3 coordinates often have initializer rows, and a "
                "small subset also has opcode64 coordinate hits.  This improves "
                "static evidence for active overlays, but most rows remain "
                "initializer-grounded rather than full command/root-bound proof. "
                "Door-like objects may also be scene-driven open/close or NPC "
                "pass-through states, so projection rows are not automatically "
                "initial placement rows."
            ),
        },
        "bindingRows": binding_rows,
        "opcode64RowsWithManualCoordinates": [
            row for row in binding_rows if row["opcode64CoordinateHitCount"]
        ],
        "opcode64CandidateRows": commands[:400],
        "notes": [
            "A coordinate hit alone is not final placement proof.",
            "Repeated initializer hits are expected because identical visual maps can appear under multiple resource/scene variants.",
            "Door-like map_*3 rows can be dynamic active objects: scenario scripts may open a door, move an NPC through it, then close it again.",
            "objectId matching is only available when initializer field0x16 is explicitly written.",
            "Root overlap is candidate evidence unless direct execution into the command stream is proven.",
            "Even when opcode64 uses the same tile, it can be a later re-projection/state change rather than the first draw placement.",
        ],
    }
    return report


def write_html(report: dict[str, Any]) -> str:
    s = report["summary"]
    rows = []
    for row in report["bindingRows"]:
        op64 = "<br>".join(
            html.escape(f"{hit['commandVaHex']} oid={hit['objectIdHex']} st={hit['stateHex']}")
            for hit in row["opcode64Hits"][:5]
        ) or "-"
        inits = "<br>".join(
            html.escape(f"{hit['initializerVaHex']} {hit['field0x28Hex']} id={hit.get('field0x16Hex') or '-'}")
            for hit in row["initializerHits"][:5]
        ) or "-"
        roots = "<br>".join(
            html.escape(f"{root.get('rootVaHex')} {'/'.join(root.get('selectorKeys') or [])} {' '.join(root.get('linkedCns') or [])}")
            for root in row["resourceRoots"][:4]
        ) or "-"
        rows.append(
            "<tr>"
            f"<td><code>{html.escape(str(row['map']))}</code></td>"
            f"<td><code>{html.escape(str(row['asset']))}</code></td>"
            f"<td>{html.escape(str(row['rectLabel']))}</td>"
            f"<td><code>{row['tile'][0]},{row['tile'][1]}</code></td>"
            f"<td><span class=\"tag {status_class(row['bindingStatus'])}\">{html.escape(row['bindingStatus'])}</span></td>"
            f"<td>{row['opcode64CoordinateHitCount']}<br>{op64}</td>"
            f"<td>{row['initializerCoordinateHitCount']}<br>{inits}</td>"
            f"<td>{row['resourceRootCount']}<br>{roots}</td>"
            "</tr>"
        )
    counts = "".join(
        f"<li><code>{html.escape(key)}</code>: <strong>{value}</strong></li>"
        for key, value in s["bindingStatusCounts"].items()
    )
    data_json = json.dumps({
        "promotionStatus": report["promotionStatus"],
        "summary": report["summary"],
    }, ensure_ascii=False, indent=2)
    return "\n".join([
        "<!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>map_*3 projection binding review</title>",
        "  <style>",
        "    body{font:14px system-ui,sans-serif;margin:24px;background:#f6f7f9;color:#17202a}",
        "    table{border-collapse:collapse;width:100%;background:#fff}",
        "    th,td{border:1px solid #d8dee6;padding:7px 9px;vertical-align:top}",
        "    th{background:#eef2f6;position:sticky;top:0}",
        "    code{background:#f8fafc;border:1px solid #d5dce5;border-radius:4px;padding:1px 4px}",
        "    .tag{display:inline-block;border-radius:999px;padding:2px 7px;font-size:12px;background:#e8edf3}",
        "    .good{background:#dff5e5;color:#0f5e2b}.warn{background:#fff4cd;color:#6b5300}.bad{background:#fde2e2;color:#7f1d1d}",
        "    .meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin:14px 0}",
        "    .box{background:#fff;border:1px solid #d8dee6;border-radius:8px;padding:12px}",
        "  </style>",
        "</head>",
        "<body>",
        "  <h1>map_*3 projection binding review</h1>",
        f"  <p><code>HWANSE_MAP3_PROJECTION_BINDING_REVIEW_READY</code> · status: <code>{html.escape(report['promotionStatus'])}</code></p>",
        f"  <p>{html.escape(s['conclusion'])}</p>",
        "  <div class=\"meta\">",
        f"    <div class=\"box\">opcode64 candidates<br><strong>{s['opcode64CandidateCount']}</strong></div>",
        f"    <div class=\"box\">root-contained opcode64<br><strong>{s['opcode64RootContainedCount']}</strong></div>",
        f"    <div class=\"box\">manual placements with op64 coord<br><strong>{s['manualPlacementsWithOpcode64CoordinateHit']}</strong></div>",
        f"    <div class=\"box\">manual placements with initializer<br><strong>{s['manualPlacementsWithInitializerHit']}</strong></div>",
        "  </div>",
        "  <h2>status counts</h2>",
        f"  <ul>{counts}</ul>",
        "  <h2>binding rows</h2>",
        "  <table><thead><tr><th>map</th><th>asset</th><th>rect</th><th>tile</th><th>status</th><th>opcode64</th><th>initializer</th><th>resource roots</th></tr></thead>",
        f"  <tbody>{''.join(rows)}</tbody></table>",
        f"  <script>window.HWANSE_MAP3_PROJECTION_BINDING_REVIEW = {data_json};</script>",
        "</body>",
        "</html>",
        "",
    ])


def status_class(status: str) -> str:
    if "same-root" in status:
        return "good"
    if status in {"manual-only", "projection-coordinate-only"}:
        return "bad"
    return "warn"


def main() -> None:
    report = build()
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    (OUT / "map3_projection_binding_review.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    html_text = write_html(report)
    (WEB / "map3_projection_binding_review.html").write_text(html_text, encoding="utf-8")
    print(f"wrote {OUT / 'map3_projection_binding_review.json'}")


if __name__ == "__main__":
    main()
