#!/usr/bin/env python3
"""Scan object-relative live tile-write commands for map animation evidence.

Opcode 0x58 writes an absolute tile coordinate.  Opcodes 0x6b/0x76 write a tile
relative to an active object.  Because fire/waterfall animation could in theory
be implemented through object-relative tile mutation, this report scans those
two command layouts and checks whether they bind to known animated map roots.
"""
from __future__ import annotations

import html
import json
import struct
import sys
from collections import Counter
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_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


OPCODES = {
    0x6B: {
        "handlerVaHex": "0x00408d10",
        "name": "active-object-relative live tile write",
        "filterMeaning": "actor id or 0xff wildcard",
    },
    0x76: {
        "handlerVaHex": "0x00409f23",
        "name": "active-object-list live tile write",
        "filterMeaning": "actor id, 0xfe current context, or 0xff wildcard",
    },
}


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 | None:
    return None if value is None else f"0x{value:0{width}x}"


def read_json(path: Path, fallback: Any) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return fallback


def write_json(path: Path, payload: Any) -> None:
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\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
    return " | ".join(text for text in text_runs if ".cns" in text.lower() or "map_" in text.lower())


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", [])[:8],
                "animatedMaps": group.get("animatedMaps", []),
                "animatedTilesets": group.get("animatedTilesets", []),
                "linkClass": group.get("linkClass"),
            }
            for group in binding.get("containingGroups", [])[:3]
        ],
        "nearestGroups": [
            {
                "id": group.get("id"),
                "selector": group.get("selector"),
                "rootVaHex": group.get("rootVaHex"),
                "distanceBytes": group.get("distanceBytes"),
                "maps": group.get("maps", [])[:8],
                "animatedMaps": group.get("animatedMaps", []),
                "animatedTilesets": group.get("animatedTilesets", []),
                "linkClass": group.get("linkClass"),
            }
            for group in binding.get("nearestGroups", [])[:3]
        ],
        "note": binding.get("note"),
    }


def build_report() -> dict[str, Any]:
    blob = EXE.read_bytes()
    sections = read_sections(blob)
    animation_review = read_json(MAP_ANIMATION_REVIEW, {})
    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)
    }
    binding_context = build_palette_binding_context(maps)
    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]] = []
    alignment_counts: Counter[str] = Counter()
    binding_counts: Counter[str] = Counter()
    opcode_counts: Counter[str] = Counter()
    animated_tile_direct_hits = 0

    for offset in range(data_start, data_end):
        opcode = blob[offset]
        if opcode not in OPCODES:
            continue
        mode = blob[offset + 1]
        if mode not in (0, 1):
            continue
        actor_filter = blob[offset + 2]
        # Bytes +3..+5 are padding/reserved by the handler; it reads +6/+8/+0a.
        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 and not tile_hit:
            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)
        if ascii_context:
            if len(rejected_ascii) < 24:
                rejected_ascii.append(
                    {
                        "vaHex": hx(offset_to_va(sections, offset)),
                        "opcodeHex": hx(opcode, 2),
                        "raw": blob[offset : offset + 12].hex(" "),
                        "asciiContext": ascii_context,
                    }
                )
            continue

        binding = palette_root_resource_binding(alignment.get("bestRootVa"), binding_context)
        binding_status = binding.get("status") or "unknown"
        opcode_hex = hx(opcode, 2) or ""
        alignment_counts[status] += 1
        binding_counts[binding_status] += 1
        opcode_counts[opcode_hex] += 1
        if tile_hit:
            animated_tile_direct_hits += 1
        root_animated = binding_status == "resource-group-contains-animated-map"
        classification = "animated-root-candidate" if root_animated else "rejected-nonanimated-or-unbound-root"
        if tile_hit and root_animated:
            classification = "direct-animated-tile-root-candidate"

        rows.append(
            {
                "vaHex": hx(offset_to_va(sections, offset)),
                "opcodeHex": opcode_hex,
                "handlerVaHex": OPCODES[opcode]["handlerVaHex"],
                "handlerName": OPCODES[opcode]["name"],
                "mode": mode,
                "modeMeaning": "write layer0 tile grid 0x00595af0" if mode == 0 else "write layer1 flag grid 0x0058d7d0",
                "actorFilter": actor_filter,
                "actorFilterHex": hx(actor_filter, 2),
                "actorFilterMeaning": OPCODES[opcode]["filterMeaning"],
                "xOffset": x_offset,
                "yOffset": y_offset,
                "tile": tile,
                "tileHex": hx(tile, 4),
                "tileIsKnownAnimatedLayer0": tile_hit,
                "raw": blob[offset : offset + 12].hex(" "),
                "alignmentStatus": status,
                "bestRootVa": alignment.get("bestRootVa"),
                "directReferenceCount": alignment.get("directReferenceCount"),
                "preview": (alignment.get("preview") or [])[:8],
                "resourceBinding": binding_digest(binding),
                "classification": classification,
            }
        )

    rows.sort(key=lambda row: (row["opcodeHex"], row["vaHex"] or ""))
    animated_root_rows = [row for row in rows if row["classification"].endswith("root-candidate")]
    summary = {
        "candidateCount": len(rows),
        "opcodeCounts": dict(sorted(opcode_counts.items())),
        "alignmentCounts": dict(sorted(alignment_counts.items())),
        "bindingCounts": dict(sorted(binding_counts.items())),
        "animatedTileDirectHitCount": animated_tile_direct_hits,
        "animatedRootCandidateCount": len(animated_root_rows),
        "directAnimatedTileRootCandidateCount": sum(
            1 for row in rows if row["classification"] == "direct-animated-tile-root-candidate"
        ),
        "asciiRejectedSampleCount": len(rejected_ascii),
        "objectRelativeAnimationBindingProven": False,
        "decision": (
            "object-relative tile-write command candidates are real VM-shaped streams, but they currently bind to "
            "nonanimated/unbound resource groups.  They do not prove the fire/waterfall visible animation loop."
        ),
    }

    return {
        "kind": "hwanse-map-animation-object-relative-tile-write-review",
        "status": "object-relative-tile-write-grounded-animation-binding-unproven",
        "source": [
            "Hwanse2.exe",
            "out/map_animation_tile_review.json",
            "tools/build_map_animation_object_relative_tile_write_review.py",
        ],
        "layout": {
            "0x6b/0x76": "opcode mode actorFilter pad[3] s16(xOffset) s16(yOffset) u16(tileId)",
            "handlerAdvanceBytes": 12,
            "mode0": "write layer0 grid",
            "mode1": "write layer1 flag/collision grid",
        },
        "summary": summary,
        "rows": rows,
        "animatedRootRows": animated_root_rows,
        "rejectedAsciiSamples": rejected_ascii,
        "nextFrontier": [
            "animated maps와 같은 selector/resource root에 들어오는 object-relative writer가 새로 발견되기 전까지 map animation proof로 승격하지 않는다.",
            "door/NPC/event tile edits 분석에는 이 opcode 0x6b/0x76 stream을 별도 근거로 사용할 수 있다.",
        ],
    }


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    cards = [
        ("status", report["status"]),
        ("candidates", summary["candidateCount"]),
        ("animated tile hits", summary["animatedTileDirectHitCount"]),
        ("animated root candidates", summary["animatedRootCandidateCount"]),
        ("binding", "proven" if summary["objectRelativeAnimationBindingProven"] else "unproven"),
    ]
    card_html = "".join(f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>" for k, v in cards)
    rows = []
    for row in report["rows"][:160]:
        binding = row.get("resourceBinding") or {}
        rows.append(
            "<tr>"
            f"<td><code>{h(row['vaHex'])}</code><br><code>{h(row['opcodeHex'])}</code></td>"
            f"<td>{h(row['mode'])}<br>{h(row['modeMeaning'])}</td>"
            f"<td><code>{h(row['actorFilterHex'])}</code><br>{h(row['actorFilterMeaning'])}</td>"
            f"<td>{h(row['xOffset'])}, {h(row['yOffset'])}</td>"
            f"<td>{h(row['tile'])}<br><code>{h(row['tileHex'])}</code></td>"
            f"<td>{h(row['alignmentStatus'])}<br><code>{h(row['bestRootVa'])}</code></td>"
            f"<td>{h(binding.get('status'))}</td>"
            f"<td>{h(row['classification'])}</td>"
            "</tr>"
        )
    frontier = "".join(f"<li>{h(item)}</li>" for item in report["nextFrontier"])
    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 Object-Relative Tile Write Review</title>
  <style>
    body {{ margin:0; background:#101318; color:#edf1f7; font-family:system-ui,sans-serif; }}
    main {{ max-width:1280px; 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(170px,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:1080px; 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; }}
    .warn {{ color:#fbbf24; }}
    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_review.html">map review</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>
  </div>
  <h1>Map Animation Object-Relative Tile Write Review</h1>
  <p class="warn">{h(summary['decision'])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Layout</h2>
    <pre>{h(json.dumps(report['layout'], ensure_ascii=False, indent=2))}</pre>
  </section>
  <section>
    <h2>Candidate Commands</h2>
    <table><thead><tr><th>VA/op</th><th>mode</th><th>actor filter</th><th>offset</th><th>tile</th><th>alignment</th><th>binding</th><th>classification</th></tr></thead><tbody>{''.join(rows)}</tbody></table>
  </section>
  <section>
    <h2>Next Frontier</h2>
    <ul>{frontier}</ul>
  </section>
  <section>
    <h2>Raw JSON</h2>
    <pre id="raw"></pre>
  </section>
</main>
<script>
window.HWANSE_MAP_ANIMATION_OBJECT_RELATIVE_TILE_WRITE_REVIEW = {payload};
document.getElementById('raw').textContent = JSON.stringify(window.HWANSE_MAP_ANIMATION_OBJECT_RELATIVE_TILE_WRITE_REVIEW, null, 2);
</script>
</body>
</html>
"""


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


if __name__ == "__main__":
    main()
