#!/usr/bin/env python3
"""Scan runtime-installed object frame scripts for live map tile writes.

The strict ``object +0xec`` active-object script inventory does not contain a
command-boundary live map tile writer.  The remaining object-side route is the
runtime frame script path:

* opcode 0x20 stores a dword operand into ``object +0x64``.
* opcode ``18 a0 64 68`` stores ``object +0x64`` from a state table indexed by
  ``object +0x68``.

This report scans those runtime-installed script targets for the same
0x58/0x6b/0x76 live tile-write commands used by the active-object script scan.
It deliberately separates command-boundary hits from raw byte coincidences.
"""
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 build_map_animation_active_object_script_tile_write_review import (  # noqa: E402
    raw_tile_write_candidates,
    walk_script,
)
from probe_exe_scene_tables import read_sections, va_to_offset  # noqa: E402
from summarize_object_payload_442c75_callers import (  # noqa: E402
    decode_initializer,
    hex32,
    is_va,
)


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_u32(exe: bytes, sections: list[dict[str, Any]], va: int) -> int | None:
    offset = va_to_offset(sections, va)
    if offset is None or offset + 4 > len(exe):
        return None
    return struct.unpack_from("<I", exe, offset)[0]


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 command_length(exe: bytes, sections: list[dict[str, Any]], va: int, opcode: int) -> int:
    # Importing local_command_length avoids changing the public report shape of
    # the active-object scan while keeping 0x6b/0x76 at their proven length.
    from build_map_animation_active_object_script_tile_write_review import local_command_length

    return local_command_length(exe, sections, va, opcode)


def iter_script_commands(
    exe: bytes,
    sections: list[dict[str, Any]],
    start_va: int,
    *,
    max_commands: int = 120,
    max_span: int = 0x300,
) -> list[dict[str, Any]]:
    cursor = start_va
    visited: set[int] = set()
    rows: list[dict[str, Any]] = []
    for _ in range(max_commands):
        if cursor in visited or cursor - start_va >= max_span:
            break
        visited.add(cursor)
        opcode = read_u8(exe, sections, cursor)
        if opcode is None:
            break
        try:
            length = command_length(exe, sections, cursor, opcode)
        except Exception:
            break
        raw = read_span(exe, sections, cursor, min(length, 16))
        rows.append(
            {
                "va": cursor,
                "vaHex": hex32(cursor),
                "opcode": opcode,
                "opcodeHex": f"0x{opcode:02x}",
                "length": length,
                "rawHex": raw.hex(" "),
            }
        )
        if opcode in {0x00, 0x03, 0x05}:
            break
        cursor += max(1, length)
    return rows


def scan_initializers(exe: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    rows: list[dict[str, Any]] = []
    field_counts = {0x40: 0, 0x64: 0, 0xEC: 0}
    initializer_count = 0
    for section in sections:
        if section["name"] not in {".text", ".rdata", ".data"}:
            continue
        raw = exe[int(section["raw"]) : int(section["raw"]) + int(section["raw_size"])]
        for offset in range(0, max(0, len(raw) - 4)):
            if raw[offset : offset + 4] != b"\x08\x00\x00\x00":
                continue
            va = int(section["va"]) + offset
            try:
                init = decode_initializer(exe, sections, va)
            except Exception:
                init = None
            if not init:
                continue
            initializer_count += 1
            relevant = []
            for write in init["writes"]:
                field = int(write["fieldOffset"])
                if field not in field_counts:
                    continue
                field_counts[field] += 1
                relevant.append(write)
            if relevant:
                rows.append(
                    {
                        "initializerVa": va,
                        "initializerVaHex": hex32(va),
                        "section": section["name"],
                        "writes": relevant,
                    }
                )
    return {
        "initializerCount": initializer_count,
        "fieldWriteCounts": {
            "+0x40": field_counts[0x40],
            "+0x64": field_counts[0x64],
            "+0xec": field_counts[0xEC],
        },
        "relevantInitializers": rows[:80],
    }


def collect_opcode20_targets(
    exe: bytes,
    sections: list[dict[str, Any]],
    scripts: list[dict[str, Any]],
) -> dict[str, Any]:
    refs: list[dict[str, Any]] = []
    targets: dict[int, dict[str, Any]] = {}
    for script in scripts:
        script_va = int(script["scriptVa"])
        for cmd in iter_script_commands(exe, sections, script_va):
            if cmd["opcode"] != 0x20 or cmd["length"] < 8:
                continue
            target = read_u32(exe, sections, int(cmd["va"]) + 4)
            if target is None or not is_va(sections, target):
                continue
            ref = {
                "sourceScriptVa": script_va,
                "sourceScriptVaHex": script["scriptVaHex"],
                "commandVa": cmd["va"],
                "commandVaHex": cmd["vaHex"],
                "targetVa": target,
                "targetVaHex": hex32(target),
                "rawHex": cmd["rawHex"],
            }
            refs.append(ref)
            targets.setdefault(target, {"targetVa": target, "targetVaHex": hex32(target), "refs": []})
            targets[target]["refs"].append(ref)
    return {"refs": refs, "targets": targets}


def collect_opcode18_state_tables(
    exe: bytes,
    sections: list[dict[str, Any]],
    scripts: list[dict[str, Any]],
) -> dict[str, Any]:
    refs: list[dict[str, Any]] = []
    tables: dict[int, dict[str, Any]] = {}
    entries: dict[int, dict[str, Any]] = {}
    for script in scripts:
        script_va = int(script["scriptVa"])
        for cmd in iter_script_commands(exe, sections, script_va):
            if cmd["opcode"] != 0x18 or cmd["length"] < 8:
                continue
            raw4 = read_span(exe, sections, int(cmd["va"]), 4)
            if raw4 != bytes([0x18, 0xA0, 0x64, 0x68]):
                continue
            table_va = read_u32(exe, sections, int(cmd["va"]) + 4)
            if table_va is None or not is_va(sections, table_va):
                continue
            ref = {
                "sourceScriptVa": script_va,
                "sourceScriptVaHex": script["scriptVaHex"],
                "commandVa": cmd["va"],
                "commandVaHex": cmd["vaHex"],
                "tableVa": table_va,
                "tableVaHex": hex32(table_va),
                "rawHex": cmd["rawHex"],
            }
            refs.append(ref)
            table = tables.setdefault(
                table_va,
                {"tableVa": table_va, "tableVaHex": hex32(table_va), "refs": [], "entries": []},
            )
            table["refs"].append(ref)

    for table_va, table in tables.items():
        for index in range(16):
            entry_va = read_u32(exe, sections, table_va + index * 4)
            if entry_va is None or not is_va(sections, entry_va):
                break
            entry = {
                "index": index,
                "indexHex": f"0x{index:02x}",
                "entryVa": entry_va,
                "entryVaHex": hex32(entry_va),
            }
            table["entries"].append(entry)
            entries.setdefault(entry_va, {"entryVa": entry_va, "entryVaHex": hex32(entry_va), "tables": []})
            entries[entry_va]["tables"].append(
                {"tableVa": table_va, "tableVaHex": hex32(table_va), "index": index, "indexHex": f"0x{index:02x}"}
            )
    return {"refs": refs, "tables": tables, "entries": entries}


def scan_targets_for_tile_writes(
    exe: bytes,
    sections: list[dict[str, Any]],
    targets: dict[int, dict[str, Any]],
    source_kind: str,
) -> dict[str, Any]:
    rows: list[dict[str, Any]] = []
    boundary_hits: list[dict[str, Any]] = []
    raw_hits: list[dict[str, Any]] = []
    for target_va, meta in sorted(targets.items()):
        walked = walk_script(exe, sections, target_va)
        raw_candidates = raw_tile_write_candidates(exe, sections, target_va)
        if walked["tileWrites"] or raw_candidates:
            row = {
                "sourceKind": source_kind,
                "targetVa": target_va,
                "targetVaHex": hex32(target_va),
                "decodedCommandCount": walked["decodedCommandCount"],
                "commandBoundaryTileWrites": walked["tileWrites"],
                "rawTileWriteCandidates": raw_candidates[:12],
                "firstCommands": walked["commands"][:10],
                "meta": meta,
            }
            rows.append(row)
            for hit in walked["tileWrites"]:
                boundary_hits.append({**hit, "sourceKind": source_kind, "targetVaHex": hex32(target_va)})
            for hit in raw_candidates:
                raw_hits.append({**hit, "sourceKind": source_kind, "targetVaHex": hex32(target_va)})
    return {
        "targetsWithCandidates": rows,
        "commandBoundaryTileWrites": boundary_hits,
        "rawTileWriteCandidates": raw_hits,
    }


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 []

    initializer_scan = scan_initializers(exe, sections)
    opcode20 = collect_opcode20_targets(exe, sections, scripts)
    opcode18 = collect_opcode18_state_tables(exe, sections, scripts)
    opcode20_scan = scan_targets_for_tile_writes(exe, sections, opcode20["targets"], "opcode20-object+0x64")
    opcode18_scan = scan_targets_for_tile_writes(exe, sections, opcode18["entries"], "opcode18-state-entry")

    summary = {
        "strictSourceScriptCount": len(scripts),
        "initializerCount": initializer_scan["initializerCount"],
        "initializerField40VaWriteCount": initializer_scan["fieldWriteCounts"]["+0x40"],
        "initializerField64VaWriteCount": initializer_scan["fieldWriteCounts"]["+0x64"],
        "initializerFieldEcVaWriteCount": initializer_scan["fieldWriteCounts"]["+0xec"],
        "opcode20AttachRefCount": len(opcode20["refs"]),
        "opcode20AttachTargetCount": len(opcode20["targets"]),
        "opcode20TargetsWithTileWriteCount": len(opcode20_scan["targetsWithCandidates"]),
        "opcode20CommandBoundaryTileWriteCount": len(opcode20_scan["commandBoundaryTileWrites"]),
        "opcode20RawTileWriteCandidateCount": len(opcode20_scan["rawTileWriteCandidates"]),
        "opcode18StateTableRefCount": len(opcode18["refs"]),
        "opcode18StateTableCount": len(opcode18["tables"]),
        "opcode18StateEntryTargetCount": len(opcode18["entries"]),
        "opcode18StateEntriesWithTileWriteCount": len(opcode18_scan["targetsWithCandidates"]),
        "opcode18CommandBoundaryTileWriteCount": len(opcode18_scan["commandBoundaryTileWrites"]),
        "opcode18RawTileWriteCandidateCount": len(opcode18_scan["rawTileWriteCandidates"]),
        "frameScriptTileWriteProducerFound": bool(
            opcode20_scan["commandBoundaryTileWrites"] or opcode18_scan["commandBoundaryTileWrites"]
        ),
        "rawCandidatesPromoted": False,
        "decision": (
            "Runtime-installed object +0x64 frame scripts are grounded, but neither opcode 0x20 attach targets "
            "nor opcode 0x18 state-table entries contain command-boundary live map tile writes."
        ),
    }

    return {
        "kind": "hwanse-map-animation-frame-script-tile-write-review",
        "status": "object-frame-script-tile-write-not-found",
        "source": [
            "Hwanse2.exe",
            "out/active_object_script_inventory.json",
            "tools/build_map_animation_frame_script_tile_write_review.py",
        ],
        "summary": summary,
        "initializerScan": initializer_scan,
        "opcode20AttachRefs": opcode20["refs"][:120],
        "opcode20AttachTargets": list(opcode20["targets"].values())[:120],
        "opcode20TileWriteScan": opcode20_scan,
        "opcode18StateTableRefs": opcode18["refs"][:120],
        "opcode18StateTables": list(opcode18["tables"].values())[:120],
        "opcode18StateEntries": list(opcode18["entries"].values())[:120],
        "opcode18TileWriteScan": opcode18_scan,
        "nextFrontier": [
            "Strict +0xec scripts and runtime-installed +0x64 frame scripts are both negative for live tile writes.",
            "The remaining static producer is likely a non-object VM/root or a specialized map animation loop outside the active-object frame-script path.",
            "If this path remains blocked, continue from the animated-map load/update dispatcher instead of promoting the frame-script route.",
        ],
    }


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    cards = [
        ("status", report["status"]),
        ("initializers", summary["initializerCount"]),
        ("init +0x40/+0x64/+0xec", f"{summary['initializerField40VaWriteCount']} / {summary['initializerField64VaWriteCount']} / {summary['initializerFieldEcVaWriteCount']}"),
        ("0x20 targets", summary["opcode20AttachTargetCount"]),
        ("0x20 boundary writes", summary["opcode20CommandBoundaryTileWriteCount"]),
        ("0x18 tables", summary["opcode18StateTableCount"]),
        ("0x18 entries", summary["opcode18StateEntryTargetCount"]),
        ("0x18 boundary writes", summary["opcode18CommandBoundaryTileWriteCount"]),
        ("producer found", summary["frameScriptTileWriteProducerFound"]),
    ]
    card_html = "".join(f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>" for k, v in cards)

    def refs_rows(refs: list[dict[str, Any]], target_key: str, target_label: str) -> str:
        return "".join(
            "<tr>"
            f"<td><code>{h(row.get('sourceScriptVaHex'))}</code></td>"
            f"<td><code>{h(row.get('commandVaHex'))}</code></td>"
            f"<td><code>{h(row.get(target_key))}</code></td>"
            f"<td><code>{h(row.get('rawHex'))}</code></td>"
            "</tr>"
            for row in refs
        ) or f"<tr><td colspan='4'>No {h(target_label)} refs.</td></tr>"

    def candidate_rows(scan: dict[str, Any]) -> str:
        rows = []
        for row in scan["targetsWithCandidates"][:80]:
            rows.append(
                "<tr>"
                f"<td><code>{h(row['targetVaHex'])}</code><br>{h(row['sourceKind'])}</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>"
            )
        return "".join(rows) or "<tr><td colspan='4'>No command-boundary or raw tile-write candidates.</td></tr>"

    table_rows = []
    for table in report["opcode18StateTables"][:60]:
        entries = ", ".join(f"{item['indexHex']}:{item['entryVaHex']}" for item in table.get("entries", [])[:8])
        table_rows.append(
            "<tr>"
            f"<td><code>{h(table['tableVaHex'])}</code></td>"
            f"<td>{h(len(table.get('refs', [])))}</td>"
            f"<td>{h(len(table.get('entries', [])))}</td>"
            f"<td><code>{h(entries)}</code></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 Frame 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(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: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">strict +0xec scripts</a>
    <a class="chip" href="map_animation_execution_boundary_review.html">tick route</a>
  </div>
  <h1>Map Animation Frame Script Tile Write Review</h1>
  <p>{h(summary["decision"])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Initializer Field Writes</h2>
    <p>Object initializers write <code>+0xec</code> scripts but do not directly write <code>+0x40</code> or <code>+0x64</code> script pointers.</p>
  </section>
  <section>
    <h2>Opcode 0x20 Attach Targets</h2>
    <table><thead><tr><th>source script</th><th>command</th><th>target</th><th>raw</th></tr></thead><tbody>{refs_rows(report["opcode20AttachRefs"], "targetVaHex", "0x20")}</tbody></table>
  </section>
  <section>
    <h2>Opcode 0x20 Target Tile-Write Scan</h2>
    <table><thead><tr><th>target</th><th>boundary writes</th><th>raw candidates</th><th>first commands</th></tr></thead><tbody>{candidate_rows(report["opcode20TileWriteScan"])}</tbody></table>
  </section>
  <section>
    <h2>Opcode 0x18 State Tables</h2>
    <table><thead><tr><th>table</th><th>refs</th><th>entries</th><th>entry preview</th></tr></thead><tbody>{''.join(table_rows)}</tbody></table>
  </section>
  <section>
    <h2>Opcode 0x18 Entry Tile-Write Scan</h2>
    <table><thead><tr><th>target</th><th>boundary writes</th><th>raw candidates</th><th>first commands</th></tr></thead><tbody>{candidate_rows(report["opcode18TileWriteScan"])}</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_FRAME_SCRIPT_TILE_WRITE_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_MAP_ANIMATION_FRAME_SCRIPT_TILE_WRITE_REVIEW, null, 2);
</script>
</body>
</html>
"""


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


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