#!/usr/bin/env python3
"""Scan strict active-object scripts for live map tile-write commands.

The tick-route report leaves one credible static producer route:

    active-object delayed script -> generic object script runner -> tile-write opcode

This report tests the active-object script inventory directly.  It walks the
strict ``object +0xec`` scripts already inventoried by
``summarize_active_object_script_inventory.py`` and looks for command-boundary
tile-write opcodes:

* 0x58: absolute live tile write
* 0x6b: active-object-relative live tile write
* 0x76: active-object-list live tile write

Raw byte coincidences are reported separately and are not promoted.
"""
from __future__ import annotations

import html
import json
import struct
import sys
from pathlib import Path
from typing import Any


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

sys.path.insert(0, str(ROOT / "tools"))
from probe_exe_scene_tables import read_sections, va_to_offset  # noqa: E402
from summarize_object_payload_442c75_callers import command_length, decode_command, hex32  # noqa: E402


TILE_WRITE_OPCODES = {0x58, 0x6B, 0x76}


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


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, indent=2) + "\n", encoding="utf-8")


def read_u8(exe: bytes, sections: list[dict[str, Any]], va: int) -> int | None:
    offset = va_to_offset(sections, va)
    if offset is None or offset >= len(exe):
        return None
    return exe[offset]


def read_span(exe: bytes, sections: list[dict[str, Any]], va: int, size: int) -> bytes:
    offset = va_to_offset(sections, va)
    if offset is None:
        return b""
    return exe[offset : offset + size]


def parse_tile_write(exe: bytes, sections: list[dict[str, Any]], va: int, opcode: int) -> dict[str, Any] | None:
    data = read_span(exe, sections, va, 12)
    if opcode == 0x58 and len(data) >= 8:
        mode = data[1]
        tile = struct.unpack_from("<H", data, 2)[0]
        x = struct.unpack_from("<H", data, 4)[0]
        y = struct.unpack_from("<H", data, 6)[0]
        return {
            "va": va,
            "vaHex": hex32(va),
            "opcodeHex": f"0x{opcode:02x}",
            "layout": "opcode mode u16(tile) u16(x) u16(y)",
            "mode": mode,
            "modeMeaning": "layer0 live tile grid" if mode == 0 else "layer1 flag grid" if mode == 1 else "unknown",
            "tile": tile,
            "tileHex": f"0x{tile:04x}",
            "x": x,
            "y": y,
            "raw": data[:8].hex(" "),
        }
    if opcode in {0x6B, 0x76} and len(data) >= 12:
        mode = data[1]
        actor_filter = data[2]
        x_offset = struct.unpack_from("<h", data, 6)[0]
        y_offset = struct.unpack_from("<h", data, 8)[0]
        tile = struct.unpack_from("<H", data, 10)[0]
        return {
            "va": va,
            "vaHex": hex32(va),
            "opcodeHex": f"0x{opcode:02x}",
            "layout": "opcode mode actorFilter pad[3] s16(xOffset) s16(yOffset) u16(tile)",
            "mode": mode,
            "modeMeaning": "layer0 live tile grid" if mode == 0 else "layer1 flag grid" if mode == 1 else "unknown",
            "actorFilter": actor_filter,
            "actorFilterHex": f"0x{actor_filter:02x}",
            "xOffset": x_offset,
            "yOffset": y_offset,
            "tile": tile,
            "tileHex": f"0x{tile:04x}",
            "raw": data[:12].hex(" "),
        }
    return None


def is_promotable_tile_write(row: dict[str, Any]) -> bool:
    mode = int(row.get("mode", -1))
    tile = int(row.get("tile", 0xFFFF))
    if mode not in {0, 1} or tile >= 1024:
        return False
    if row.get("opcodeHex") == "0x58":
        return int(row.get("x", 0xFFFF)) < 160 and int(row.get("y", 0xFFFF)) < 160
    return -160 <= int(row.get("xOffset", 9999)) <= 160 and -160 <= int(row.get("yOffset", 9999)) <= 160


def local_command_length(exe: bytes, sections: list[dict[str, Any]], va: int, opcode: int) -> int:
    if opcode in {0x6B, 0x76}:
        return 12
    return command_length(exe, sections, va, opcode)


def walk_script(exe: bytes, sections: list[dict[str, Any]], start_va: int) -> dict[str, Any]:
    cursor = start_va
    visited: set[int] = set()
    commands: list[dict[str, Any]] = []
    tile_writes: list[dict[str, Any]] = []
    for _ in range(120):
        if cursor in visited:
            commands.append({"vaHex": hex32(cursor), "opcodeHex": "", "opcodeName": "loop-detected"})
            break
        if cursor - start_va >= 0x300:
            break
        visited.add(cursor)
        opcode = read_u8(exe, sections, cursor)
        if opcode is None:
            break
        if opcode in TILE_WRITE_OPCODES:
            tile = parse_tile_write(exe, sections, cursor, opcode)
            if tile and is_promotable_tile_write(tile):
                tile_writes.append(tile)
            length = local_command_length(exe, sections, cursor, opcode)
            commands.append(
                {
                    "vaHex": hex32(cursor),
                    "opcodeHex": f"0x{opcode:02x}",
                    "opcodeName": "live-tile-write",
                    "length": length,
                    "summary": tile.get("layout") if tile and is_promotable_tile_write(tile) else "unpromoted tile-write-shaped bytes",
                }
            )
        else:
            try:
                row = decode_command(exe, sections, cursor)
            except ValueError:
                break
            length = local_command_length(exe, sections, cursor, opcode)
            commands.append(
                {
                    "vaHex": row.get("vaHex"),
                    "opcodeHex": row.get("opcodeHex"),
                    "opcodeName": row.get("opcodeName"),
                    "length": length,
                    "summary": row.get("summary", ""),
                }
            )
        if opcode in {0x00, 0x03, 0x05}:
            break
        cursor += max(1, int(commands[-1].get("length") or 4))
    return {
        "decodedCommandCount": len(commands),
        "commands": commands,
        "tileWrites": tile_writes,
    }


def raw_tile_write_candidates(exe: bytes, sections: list[dict[str, Any]], start_va: int, span: int = 0x300) -> list[dict[str, Any]]:
    data = read_span(exe, sections, start_va, span)
    rows: list[dict[str, Any]] = []
    for offset, byte in enumerate(data):
        if byte not in TILE_WRITE_OPCODES:
            continue
        parsed = parse_tile_write(exe, sections, start_va + offset, byte)
        if not parsed:
            continue
        mode = int(parsed.get("mode", -1))
        tile = int(parsed.get("tile", 0xFFFF))
        plausible = mode in {0, 1} and tile < 1024
        if byte == 0x58:
            plausible = plausible and int(parsed.get("x", 0xFFFF)) < 160 and int(parsed.get("y", 0xFFFF)) < 160
        else:
            plausible = plausible and -160 <= int(parsed.get("xOffset", 9999)) <= 160 and -160 <= int(parsed.get("yOffset", 9999)) <= 160
        if plausible:
            parsed["offsetFromScript"] = offset
            parsed["offsetFromScriptHex"] = f"0x{offset:03x}"
            rows.append(parsed)
    return rows


def build_report() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    inventory = read_json(OUT / "active_object_script_inventory.json")
    scripts = inventory.get("scripts") or []

    rows: list[dict[str, Any]] = []
    command_boundary_hits: list[dict[str, Any]] = []
    raw_hits: list[dict[str, Any]] = []
    for script in scripts:
        script_va = int(script["scriptVa"])
        walked = walk_script(exe, sections, script_va)
        raw_candidates = raw_tile_write_candidates(exe, sections, script_va)
        if walked["tileWrites"] or raw_candidates:
            row = {
                "scriptVa": script_va,
                "scriptVaHex": script["scriptVaHex"],
                "classification": script.get("classification"),
                "decodedCommandCount": walked["decodedCommandCount"],
                "commandBoundaryTileWrites": walked["tileWrites"],
                "rawTileWriteCandidates": raw_candidates[:12],
                "firstCommands": walked["commands"][:10],
            }
            rows.append(row)
            command_boundary_hits.extend(
                {
                    **hit,
                    "scriptVaHex": script["scriptVaHex"],
                    "scriptClassification": script.get("classification"),
                }
                for hit in walked["tileWrites"]
            )
            raw_hits.extend(
                {
                    **hit,
                    "scriptVaHex": script["scriptVaHex"],
                    "scriptClassification": script.get("classification"),
                }
                for hit in raw_candidates
            )

    summary = {
        "strictObjectEcScriptCount": len(scripts),
        "scriptsWithAnyTileWriteByteCandidate": len(rows),
        "commandBoundaryTileWriteCount": len(command_boundary_hits),
        "rawTileWriteCandidateCount": len(raw_hits),
        "activeObjectScriptTileWriteProducerFound": len(command_boundary_hits) > 0,
        "rawCandidatesPromoted": False,
        "decision": (
            "No strict active-object +0xec script currently decodes to a command-boundary live tile-write. "
            "Raw plausible bytes exist only as unpromoted candidates if present."
        ),
    }

    return {
        "kind": "hwanse-map-animation-active-object-script-tile-write-review",
        "status": "strict-active-object-script-tile-write-not-found",
        "source": [
            "Hwanse2.exe",
            "out/active_object_script_inventory.json",
            "tools/build_map_animation_active_object_script_tile_write_review.py",
        ],
        "summary": summary,
        "scriptsWithCandidates": rows,
        "commandBoundaryTileWrites": command_boundary_hits,
        "rawTileWriteCandidates": raw_hits[:80],
        "nextFrontier": [
            "Strict object +0xec inventory does not bind the producer. Search non-+0xec VM roots or runtime-installed +0x64 delayed scripts next.",
            "If runtime-installed scripts differ from static +0xec inventory, watch object +0x40/+0x64 around 0x00432ff0 on animated maps.",
        ],
    }


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    cards = [
        ("status", report["status"]),
        ("strict scripts", summary["strictObjectEcScriptCount"]),
        ("scripts w/ candidates", summary["scriptsWithAnyTileWriteByteCandidate"]),
        ("boundary tile writes", summary["commandBoundaryTileWriteCount"]),
        ("raw candidates", summary["rawTileWriteCandidateCount"]),
        ("producer found", summary["activeObjectScriptTileWriteProducerFound"]),
    ]
    card_html = "".join(f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>" for k, v in cards)
    script_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['scriptVaHex'])}</code><br>{h(row['classification'])}</td>"
        f"<td>{h(len(row['commandBoundaryTileWrites']))}</td>"
        f"<td>{h(len(row['rawTileWriteCandidates']))}</td>"
        f"<td>{h(' / '.join((cmd.get('opcodeHex') or '') + ' ' + (cmd.get('opcodeName') or '') for cmd in row['firstCommands'][:5]))}</td>"
        "</tr>"
        for row in report["scriptsWithCandidates"]
    )
    boundary_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['scriptVaHex'])}</code></td>"
        f"<td><code>{h(row['vaHex'])}</code></td>"
        f"<td>{h(row['opcodeHex'])}</td>"
        f"<td>{h(row.get('modeMeaning'))}</td>"
        f"<td>{h(row.get('tileHex'))}</td>"
        f"<td><code>{h(row.get('raw'))}</code></td>"
        "</tr>"
        for row in report["commandBoundaryTileWrites"]
    )
    raw_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['scriptVaHex'])}</code></td>"
        f"<td><code>{h(row['vaHex'])}</code><br>{h(row['offsetFromScriptHex'])}</td>"
        f"<td>{h(row['opcodeHex'])}</td>"
        f"<td>{h(row.get('modeMeaning'))}</td>"
        f"<td>{h(row.get('tileHex'))}</td>"
        f"<td><code>{h(row.get('raw'))}</code></td>"
        "</tr>"
        for row in report["rawTileWriteCandidates"]
    )
    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 Active Object Script Tile Write 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(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:920px; 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">tick route</a>
    <a class="chip" href="../out/active_object_script_inventory.json">active object inventory</a>
  </div>
  <h1>Map Animation Active Object Script Tile Write Review</h1>
  <p>{h(summary["decision"])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Scripts With Candidates</h2>
    <table><thead><tr><th>script</th><th>boundary writes</th><th>raw candidates</th><th>first commands</th></tr></thead><tbody>{script_rows}</tbody></table>
  </section>
  <section>
    <h2>Command-Boundary Tile Writes</h2>
    <table><thead><tr><th>script</th><th>command</th><th>opcode</th><th>mode</th><th>tile</th><th>raw</th></tr></thead><tbody>{boundary_rows}</tbody></table>
  </section>
  <section>
    <h2>Raw Unpromoted Candidates</h2>
    <table><thead><tr><th>script</th><th>candidate</th><th>opcode</th><th>mode</th><th>tile</th><th>raw</th></tr></thead><tbody>{raw_rows}</tbody></table>
  </section>
  <section><h2>Next Frontier</h2><ul>{frontier}</ul></section>
  <section><h2>Raw JSON</h2><pre id="json"></pre></section>
</main>
<script>
window.HWANSE_MAP_ANIMATION_ACTIVE_OBJECT_SCRIPT_TILE_WRITE_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_MAP_ANIMATION_ACTIVE_OBJECT_SCRIPT_TILE_WRITE_REVIEW, null, 2);
</script>
</body>
</html>
"""


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


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