#!/usr/bin/env python3
"""Classify tile-write VM roots after object script routes are negative.

The map animation search now has three negative object-side results:

* strict ``object +0xec`` scripts do not contain command-boundary tile writes;
* runtime ``object +0x64`` opcode 0x20 attach targets do not contain them; and
* runtime ``object +0x64`` opcode 0x18 state-table entries do not contain them.

This report therefore scans the remaining VM-shaped live tile-write commands in
the EXE data section and groups them by their best local command-stream root.
It answers a narrower question: do any non-object tile-write roots bind to the
known animated maps or animated layer0 tiles?
"""
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


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

sys.path.insert(0, str(ROOT / "tools"))
from build_map_animation_frame_script_tile_write_review import (  # noqa: E402
    collect_opcode18_state_tables,
    collect_opcode20_targets,
)
from build_map_animation_tile_review import (  # noqa: E402
    build_palette_binding_context,
    palette_command_alignment,
    palette_root_resource_binding,
)
from probe_exe_scene_tables import offset_to_va, read_sections  # noqa: E402


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


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


def read_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 write_json(path: Path, payload: Any) -> None:
    path.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")


def ascii_token_around(blob: bytes, offset: int, radius: int = 64) -> str:
    segment = blob[max(0, offset - radius) : min(len(blob), offset + radius)]
    runs: list[bytes] = []
    current = bytearray()
    for byte in segment:
        if 0x20 <= byte <= 0x7E:
            current.append(byte)
        else:
            if len(current) >= 4:
                runs.append(bytes(current))
            current.clear()
    if len(current) >= 4:
        runs.append(bytes(current))
    text_runs: list[str] = []
    for run in runs:
        try:
            text_runs.append(run.decode("ascii"))
        except UnicodeDecodeError:
            continue
    interesting = [
        text
        for text in text_runs
        if any(token in text.lower() for token in (".cns", "map_", "cara_", "btl_", ".mlk", ".wlk"))
    ]
    return " | ".join(interesting[:4])


def binding_digest(binding: dict[str, Any]) -> dict[str, Any]:
    return {
        "status": binding.get("status"),
        "rootVaHex": binding.get("rootVaHex"),
        "containingGroups": [
            {
                "id": group.get("id"),
                "selector": group.get("selector"),
                "rootVaHex": group.get("rootVaHex"),
                "maps": group.get("maps", [])[:10],
                "animatedMaps": group.get("animatedMaps", []),
                "animatedTilesets": group.get("animatedTilesets", []),
                "linkClass": group.get("linkClass"),
            }
            for group in binding.get("containingGroups", [])[:4]
        ],
        "nearestGroups": [
            {
                "id": group.get("id"),
                "selector": group.get("selector"),
                "rootVaHex": group.get("rootVaHex"),
                "distanceBytes": group.get("distanceBytes"),
                "maps": group.get("maps", [])[:10],
                "animatedMaps": group.get("animatedMaps", []),
                "animatedTilesets": group.get("animatedTilesets", []),
                "linkClass": group.get("linkClass"),
            }
            for group in binding.get("nearestGroups", [])[:4]
        ],
        "note": binding.get("note"),
    }


def animated_sets(animation_review: dict[str, Any]) -> dict[str, Any]:
    maps = animation_review.get("maps") or []
    animated_tiles = {
        int(cell["layer0"])
        for row in maps
        for cell in row.get("animatedCells", [])
        if isinstance(cell.get("layer0"), int)
    }
    animated_coords_by_map: dict[str, set[tuple[int, int]]] = {}
    all_coords: set[tuple[int, int]] = set()
    for row in maps:
        name = str(row.get("map"))
        coords = {
            (int(cell["x"]), int(cell["y"]))
            for cell in row.get("animatedCells", [])
            if isinstance(cell.get("x"), int) and isinstance(cell.get("y"), int)
        }
        animated_coords_by_map[name] = coords
        all_coords.update(coords)
    return {
        "maps": maps,
        "animatedTiles": animated_tiles,
        "animatedCoordsByMap": animated_coords_by_map,
        "allAnimatedCoords": all_coords,
        "maxWidth": max((int(row.get("width") or 0) for row in maps), default=0),
        "maxHeight": max((int(row.get("height") or 0) for row in maps), default=0),
    }


def root_membership(root_va_hex: str | None, object_ranges: list[dict[str, Any]]) -> dict[str, Any]:
    if not root_va_hex:
        return {"class": "unbound-no-root"}
    root_va = int(root_va_hex, 16)
    exact = [row for row in object_ranges if row["startVa"] == root_va]
    contained = [row for row in object_ranges if row["startVa"] <= root_va < row["endVa"]]
    if exact:
        return {"class": exact[0]["kind"] + "-exact", "matches": exact[:4]}
    if contained:
        return {"class": contained[0]["kind"] + "-contained", "matches": contained[:4]}
    return {"class": "non-object-or-resource-root"}


def make_object_ranges(
    scripts: list[dict[str, Any]],
    opcode20_targets: dict[int, dict[str, Any]],
    opcode18_entries: dict[int, dict[str, Any]],
) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for script in scripts:
        start = int(script["scriptVa"])
        rows.append(
            {
                "kind": "strict-ec-script",
                "startVa": start,
                "startVaHex": hx(start),
                "endVa": start + 0x300,
                "endVaHex": hx(start + 0x300),
                "classification": script.get("classification"),
            }
        )
    for target in opcode20_targets:
        rows.append(
            {
                "kind": "opcode20-frame-script",
                "startVa": target,
                "startVaHex": hx(target),
                "endVa": target + 0x300,
                "endVaHex": hx(target + 0x300),
            }
        )
    for target in opcode18_entries:
        rows.append(
            {
                "kind": "opcode18-frame-state-entry",
                "startVa": target,
                "startVaHex": hx(target),
                "endVa": target + 0x300,
                "endVaHex": hx(target + 0x300),
            }
        )
    return rows


def parse_tile_write_candidate(
    blob: bytes,
    offset: int,
    opcode: int,
    max_width: int,
    max_height: int,
    animated_tiles: set[int],
    all_animated_coords: set[tuple[int, int]],
) -> dict[str, Any] | None:
    if opcode == 0x58:
        if offset + 8 > len(blob):
            return None
        mode = blob[offset + 1]
        if mode not in {0, 1}:
            return None
        tile = struct.unpack_from("<H", blob, offset + 2)[0]
        x = struct.unpack_from("<H", blob, offset + 4)[0]
        y = struct.unpack_from("<H", blob, offset + 6)[0]
        plausible = tile < 1024 and x < max_width and y < max_height
        tile_hit = tile in animated_tiles
        coord_hit = (x, y) in all_animated_coords
        if not (plausible or tile_hit or coord_hit):
            return None
        return {
            "opcodeHex": "0x58",
            "mode": mode,
            "modeMeaning": "write layer0 grid" if mode == 0 else "write layer1 flag grid",
            "tile": tile,
            "tileHex": hx(tile, 4),
            "x": x,
            "y": y,
            "raw": blob[offset : offset + 8].hex(" "),
            "tileIsKnownAnimatedLayer0": tile_hit,
            "absoluteCoordinateIsAnimatedInSomeMap": coord_hit,
            "plausible": plausible,
        }
    if opcode in {0x6B, 0x76}:
        if offset + 12 > len(blob):
            return None
        mode = blob[offset + 1]
        if mode not in {0, 1}:
            return None
        actor_filter = blob[offset + 2]
        x_offset = struct.unpack_from("<h", blob, offset + 6)[0]
        y_offset = struct.unpack_from("<h", blob, offset + 8)[0]
        tile = struct.unpack_from("<H", blob, offset + 10)[0]
        plausible = -128 <= x_offset <= 128 and -128 <= y_offset <= 128 and tile < 1024
        tile_hit = tile in animated_tiles
        if not (plausible or tile_hit):
            return None
        return {
            "opcodeHex": f"0x{opcode:02x}",
            "mode": mode,
            "modeMeaning": "write layer0 grid" if mode == 0 else "write layer1 flag grid",
            "actorFilter": actor_filter,
            "actorFilterHex": hx(actor_filter, 2),
            "xOffset": x_offset,
            "yOffset": y_offset,
            "tile": tile,
            "tileHex": hx(tile, 4),
            "raw": blob[offset : offset + 12].hex(" "),
            "tileIsKnownAnimatedLayer0": tile_hit,
            "absoluteCoordinateIsAnimatedInSomeMap": False,
            "plausible": plausible,
        }
    return None


def build_report() -> dict[str, Any]:
    blob = EXE.read_bytes()
    sections = read_sections(blob)
    animation_review = read_json(MAP_ANIMATION_REVIEW)
    sets = animated_sets(animation_review)
    binding_context = build_palette_binding_context(sets["maps"])
    inventory = read_json(OUT / "active_object_script_inventory.json")
    scripts = inventory.get("scripts") or []
    opcode20 = collect_opcode20_targets(blob, sections, scripts)
    opcode18 = collect_opcode18_state_tables(blob, sections, scripts)
    object_ranges = make_object_ranges(scripts, opcode20["targets"], opcode18["entries"])

    data_section = next(section for section in sections if section["name"] == ".data")
    data_start = int(data_section["raw"])
    data_end = min(len(blob) - 12, data_start + int(data_section["raw_size"]))

    rows: list[dict[str, Any]] = []
    rejected_ascii: list[dict[str, Any]] = []
    for offset in range(data_start, data_end):
        opcode = blob[offset]
        if opcode not in {0x58, 0x6B, 0x76}:
            continue
        parsed = parse_tile_write_candidate(
            blob,
            offset,
            opcode,
            sets["maxWidth"],
            sets["maxHeight"],
            sets["animatedTiles"],
            sets["allAnimatedCoords"],
        )
        if not parsed:
            continue
        alignment = palette_command_alignment(blob, offset)
        status = alignment.get("status") or "unknown"
        if status not in {"vm-aligned-local-high", "vm-aligned-local-medium"}:
            continue
        ascii_context = ascii_token_around(blob, offset)
        va = offset_to_va(sections, offset)
        if ascii_context:
            if len(rejected_ascii) < 24:
                rejected_ascii.append(
                    {
                        "commandVaHex": hx(va),
                        **parsed,
                        "asciiContext": ascii_context,
                        "rejectedReason": "candidate overlaps asset/string bytes",
                    }
                )
            continue
        best_root = alignment.get("bestRootVa")
        binding = palette_root_resource_binding(best_root, binding_context)
        membership = root_membership(best_root, object_ranges)
        binding_status = binding.get("status") or "unknown"
        root_has_animated_map = binding_status == "resource-group-contains-animated-map"
        direct_hit = parsed["tileIsKnownAnimatedLayer0"] or parsed["absoluteCoordinateIsAnimatedInSomeMap"]
        promoted = bool(root_has_animated_map and direct_hit)
        rows.append(
            {
                "commandVaHex": hx(va),
                **parsed,
                "alignmentStatus": status,
                "bestRootVaHex": best_root,
                "commandIndex": alignment.get("commandIndex"),
                "commandCount": alignment.get("commandCount"),
                "directReferenceCount": alignment.get("directReferenceCount"),
                "preview": (alignment.get("preview") or [])[:10],
                "rootMembership": membership,
                "resourceBinding": binding_digest(binding),
                "rootHasAnimatedMapBinding": root_has_animated_map,
                "directAnimatedEvidence": direct_hit,
                "promotedAnimatedProducerEvidence": promoted,
                "classification": (
                    "promoted-animated-map-tile-write-root"
                    if promoted
                    else "animated-coordinate-only-nonanimated-root"
                    if parsed["absoluteCoordinateIsAnimatedInSomeMap"]
                    else "animated-tile-only-nonanimated-root"
                    if parsed["tileIsKnownAnimatedLayer0"]
                    else "ordinary-tile-write-root"
                ),
            }
        )

    rows.sort(key=lambda row: (row.get("bestRootVaHex") or "", row["commandVaHex"], row["opcodeHex"]))
    roots: dict[str, dict[str, Any]] = {}
    for row in rows:
        key = row.get("bestRootVaHex") or "unbound"
        root = roots.setdefault(
            key,
            {
                "rootVaHex": key,
                "commandCount": 0,
                "opcodeCounts": Counter(),
                "classificationCounts": Counter(),
                "rootMembership": row.get("rootMembership"),
                "resourceBinding": row.get("resourceBinding"),
                "promotedAnimatedProducerEvidence": False,
                "directAnimatedEvidenceCount": 0,
                "commands": [],
            },
        )
        root["commandCount"] += 1
        root["opcodeCounts"][row["opcodeHex"]] += 1
        root["classificationCounts"][row["classification"]] += 1
        root["promotedAnimatedProducerEvidence"] = (
            root["promotedAnimatedProducerEvidence"] or row["promotedAnimatedProducerEvidence"]
        )
        if row["directAnimatedEvidence"]:
            root["directAnimatedEvidenceCount"] += 1
        if len(root["commands"]) < 12:
            root["commands"].append(row)

    root_rows: list[dict[str, Any]] = []
    for root in roots.values():
        root_rows.append(
            {
                **root,
                "opcodeCounts": dict(sorted(root["opcodeCounts"].items())),
                "classificationCounts": dict(sorted(root["classificationCounts"].items())),
            }
        )
    root_rows.sort(key=lambda row: (not row["promotedAnimatedProducerEvidence"], row["rootVaHex"]))

    opcode_counts = Counter(row["opcodeHex"] for row in rows)
    membership_counts = Counter((row.get("rootMembership") or {}).get("class", "unknown") for row in rows)
    binding_counts = Counter((row.get("resourceBinding") or {}).get("status", "unknown") for row in rows)
    classification_counts = Counter(row["classification"] for row in rows)
    promoted_rows = [row for row in rows if row["promotedAnimatedProducerEvidence"]]

    summary = {
        "alignedTileWriteCommandCount": len(rows),
        "uniqueRootCount": len(root_rows),
        "opcodeCounts": dict(sorted(opcode_counts.items())),
        "rootMembershipCounts": dict(sorted(membership_counts.items())),
        "resourceBindingCounts": dict(sorted(binding_counts.items())),
        "classificationCounts": dict(sorted(classification_counts.items())),
        "directAnimatedEvidenceCommandCount": sum(1 for row in rows if row["directAnimatedEvidence"]),
        "promotedAnimatedProducerCommandCount": len(promoted_rows),
        "promotedAnimatedProducerRootCount": sum(1 for row in root_rows if row["promotedAnimatedProducerEvidence"]),
        "asciiRejectedSampleCount": len(rejected_ascii),
        "nonObjectTileWriteRootScanProvesProducer": bool(promoted_rows),
        "decision": (
            "Aligned tile-write VM roots exist, but none both bind to a known animated map root and write a known animated tile/coordinate. "
            "The non-object/root scan therefore does not promote the visible fire/waterfall producer."
        ),
    }

    return {
        "kind": "hwanse-map-animation-non-object-tile-write-root-review",
        "status": "non-object-tile-write-root-producer-not-found",
        "source": [
            "Hwanse2.exe",
            "out/map_animation_tile_review.json",
            "out/active_object_script_inventory.json",
            "tools/build_map_animation_non_object_tile_write_root_review.py",
        ],
        "summary": summary,
        "roots": root_rows,
        "commands": rows[:240],
        "rejectedAsciiSamples": rejected_ascii,
        "nextFrontier": [
            "Tile-write VM command roots are not the current visible map animation producer.",
            "Search for a specialized map animation loop or animated-map load/update dispatcher that computes source tile ids without 0x58/0x6b/0x76 command bytes.",
        ],
    }


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    cards = [
        ("status", report["status"]),
        ("commands", summary["alignedTileWriteCommandCount"]),
        ("roots", summary["uniqueRootCount"]),
        ("direct animated evidence", summary["directAnimatedEvidenceCommandCount"]),
        ("promoted commands", summary["promotedAnimatedProducerCommandCount"]),
        ("producer proven", summary["nonObjectTileWriteRootScanProvesProducer"]),
    ]
    card_html = "".join(f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>" for k, v in cards)
    root_rows = []
    for row in report["roots"]:
        binding = row.get("resourceBinding") or {}
        membership = row.get("rootMembership") or {}
        groups = binding.get("containingGroups") or binding.get("nearestGroups") or []
        group_text = "; ".join(
            f"{group.get('id')} maps={','.join(group.get('maps') or [])} animated={','.join(group.get('animatedMaps') or [])}"
            for group in groups[:2]
        )
        root_rows.append(
            "<tr>"
            f"<td><code>{h(row['rootVaHex'])}</code></td>"
            f"<td>{h(row['commandCount'])}</td>"
            f"<td><code>{h(row['opcodeCounts'])}</code></td>"
            f"<td>{h(membership.get('class'))}</td>"
            f"<td>{h(binding.get('status'))}<br>{h(group_text)}</td>"
            f"<td><code>{h(row['classificationCounts'])}</code></td>"
            "</tr>"
        )
    command_rows = []
    for row in report["commands"][:120]:
        command_rows.append(
            "<tr>"
            f"<td><code>{h(row['commandVaHex'])}</code><br>{h(row['opcodeHex'])}</td>"
            f"<td><code>{h(row.get('bestRootVaHex'))}</code></td>"
            f"<td>{h((row.get('rootMembership') or {}).get('class'))}</td>"
            f"<td>{h((row.get('resourceBinding') or {}).get('status'))}</td>"
            f"<td>{h(row['classification'])}</td>"
            f"<td><code>{h(row['raw'])}</code></td>"
            "</tr>"
        )
    payload = json.dumps(report, ensure_ascii=False)
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Map Animation Non-Object Tile Write Root Review</title>
  <style>
    body {{ margin:0; background:#101318; color:#edf1f7; font-family:system-ui,sans-serif; }}
    main {{ max-width:1240px; margin:0 auto; padding:24px; }}
    a {{ color:#8ecbff; }}
    .nav {{ display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px; }}
    .chip {{ border:1px solid #334155; border-radius:999px; padding:6px 10px; text-decoration:none; background:#161b22; }}
    .summary {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:12px; margin:16px 0; }}
    .card {{ border:1px solid #2b3544; border-radius:8px; padding:12px; background:#161b22; }}
    .card b {{ display:block; color:#9fb1c9; font-size:12px; text-transform:uppercase; }}
    .card span {{ display:block; margin-top:8px; font-size:18px; overflow-wrap:anywhere; }}
    section {{ border:1px solid #273244; border-radius:10px; padding:16px; margin:16px 0; background:#141922; overflow:auto; }}
    table {{ border-collapse:collapse; width:100%; min-width:1040px; font-size:13px; }}
    th,td {{ border-bottom:1px solid #283342; padding:8px; text-align:left; vertical-align:top; }}
    th {{ color:#b9c7dc; background:#111722; }}
    code {{ color:#dbeafe; overflow-wrap:anywhere; }}
    pre {{ white-space:pre-wrap; background:#0b0f14; border:1px solid #253044; border-radius:8px; padding:12px; max-height:360px; overflow:auto; }}
  </style>
</head>
<body>
<main>
  <div class="nav">
    <a class="chip" href="index.html">index</a>
    <a class="chip" href="map_animation_execution_boundary_review.html">animation boundary</a>
    <a class="chip" href="map_animation_execution_boundary_review.html">live buffer writers</a>
    <a class="chip" href="map_animation_execution_boundary_review.html">frame script writes</a>
  </div>
  <h1>Map Animation Non-Object Tile Write Root Review</h1>
  <p>{h(summary["decision"])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Root Summary</h2>
    <table><thead><tr><th>root</th><th>commands</th><th>opcodes</th><th>object membership</th><th>resource binding</th><th>classification</th></tr></thead><tbody>{''.join(root_rows)}</tbody></table>
  </section>
  <section>
    <h2>Command Sample</h2>
    <table><thead><tr><th>command</th><th>best root</th><th>membership</th><th>binding</th><th>classification</th><th>raw</th></tr></thead><tbody>{''.join(command_rows)}</tbody></table>
  </section>
  <section><h2>Raw JSON</h2><pre id="json"></pre></section>
</main>
<script>
window.HWANSE_MAP_ANIMATION_NON_OBJECT_TILE_WRITE_ROOT_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_MAP_ANIMATION_NON_OBJECT_TILE_WRITE_ROOT_REVIEW, null, 2);
</script>
</body>
</html>
"""


def main() -> int:
    report = build_report()
    write_json(OUT / "map_animation_non_object_tile_write_root_review.json", report)
    html_text = render_html(report)
    print("map_animation_non_object_tile_write_root_review ok")
    print(json.dumps(report["summary"], ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
