#!/usr/bin/env python3
"""Document the per-frame route left for map animation producers.

The redraw and draw-transform reports prove the consumer side:

* layer1 bit 0x40 marks cells that must be redrawn; and
* the ordinary tile draw path renders the current live layer0 tile id by fixed
  grid math.

This report follows the caller side around the field tick/update route.  The
important result is deliberately narrow: the main field tick route does not
directly write the live map buffers, but one of its per-frame calls can run
active-object script streams through the generic VM dispatcher.  Therefore the
remaining static target is the specific active-object script/root that executes
tile-write opcodes for animated maps.
"""
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


LANDMARKS = [
    {
        "key": "mainTimingLoopCaller",
        "name": "main timing loop caller",
        "va": 0x004019F2,
        "endVa": 0x00401A00,
        "role": "Calls the field tick/update route once per timing pass.",
    },
    {
        "key": "fieldTickRoute",
        "name": "field tick/update route",
        "va": 0x00411476,
        "endVa": 0x0041157D,
        "role": "Clamps the catch-up update count, runs per-frame update calls, then calls redraw passes.",
    },
    {
        "key": "inputStateUpdate",
        "name": "input/state update",
        "va": 0x00422D74,
        "endVa": 0x00422E80,
        "role": "One per-frame update call from the field tick route; input/state side, no direct live map-buffer refs in the inspected window.",
    },
    {
        "key": "unknownPerFrameUpdate",
        "name": "secondary per-frame update",
        "va": 0x00435C04,
        "endVa": 0x00435D20,
        "role": "One per-frame update call from the field tick route; no direct live map-buffer refs in the inspected window.",
    },
    {
        "key": "activeObjectDelayedScriptRoute",
        "name": "active object delayed script route",
        "va": 0x00432FF0,
        "endVa": 0x00433130,
        "role": "Walks active object lists; when a timer expires it runs the object's script through the generic script runner.",
    },
    {
        "key": "genericObjectScriptRunner",
        "name": "generic object script runner",
        "va": 0x00402321,
        "endVa": 0x00402370,
        "role": "Reads opcode byte from object+0x40 script pointer and dispatches through table 0x00440538.",
    },
]

LIVE_GLOBALS = {
    "liveLayer0Grid": 0x00595AF0,
    "liveLayer1FlagGrid": 0x0058D7D0,
    "dirtyWorkGrid": 0x005957D0,
    "mapWidth": 0x00595ADA,
    "mapHeight": 0x00595ADC,
}


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


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


def write_json(path: Path, payload: Any) -> None:
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def section_for_va(sections: list[dict[str, Any]], va: int) -> dict[str, Any] | None:
    for section in sections:
        start = int(section["va"])
        end = start + int(section["size"])
        if start <= va < end:
            return section
    return None


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


def range_bytes(exe: bytes, sections: list[dict[str, Any]], start_va: int, end_va: int) -> tuple[int, bytes]:
    start = va_to_offset(sections, start_va)
    end = va_to_offset(sections, end_va)
    if start is None or end is None or end < start:
        return -1, b""
    return start, exe[start:end]


def find_direct_call_xrefs(exe: bytes, sections: list[dict[str, Any]], target_va: int) -> list[dict[str, Any]]:
    refs: list[dict[str, Any]] = []
    text = next((section for section in sections if section.get("name") == ".text"), None)
    if text is None:
        return refs
    start_va = int(text["va"])
    start = int(text["raw"])
    end = start + int(text["raw_size"])
    data = exe[start:end]
    for idx in range(0, max(0, len(data) - 4)):
        if data[idx] != 0xE8:
            continue
        rel = struct.unpack_from("<i", data, idx + 1)[0]
        call_va = start_va + idx
        dest = call_va + 5 + rel
        if dest == target_va:
            refs.append({"callVa": call_va, "callVaHex": hx(call_va)})
    return refs


def find_pointer_xrefs(exe: bytes, sections: list[dict[str, Any]], target_va: int) -> list[dict[str, Any]]:
    needle = struct.pack("<I", target_va)
    refs: list[dict[str, Any]] = []
    for section in sections:
        raw_ptr = int(section["raw"])
        raw_size = int(section["raw_size"])
        va = int(section["va"])
        data = exe[raw_ptr : raw_ptr + raw_size]
        pos = data.find(needle)
        while pos != -1:
            ref_va = va + pos
            refs.append(
                {
                    "refVa": ref_va,
                    "refVaHex": hx(ref_va),
                    "section": section.get("name", ""),
                }
            )
            pos = data.find(needle, pos + 1)
    return refs


def find_direct_global_refs(exe: bytes, sections: list[dict[str, Any]], start_va: int, end_va: int) -> dict[str, list[str]]:
    _offset, data = range_bytes(exe, sections, start_va, end_va)
    refs: dict[str, list[str]] = {}
    for name, global_va in LIVE_GLOBALS.items():
        needle = struct.pack("<I", global_va)
        hits: list[str] = []
        pos = data.find(needle)
        while pos != -1:
            hits.append(hx(start_va + pos) or "")
            pos = data.find(needle, pos + 1)
        refs[name] = hits
    return refs


def count_refs(refs: dict[str, list[str]]) -> int:
    return sum(len(values) for values in refs.values())


def build_report() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)

    landmark_rows: list[dict[str, Any]] = []
    for row in LANDMARKS:
        refs = find_direct_global_refs(exe, sections, int(row["va"]), int(row["endVa"]))
        landmark_rows.append(
            {
                **row,
                "vaHex": hx(int(row["va"])),
                "endVaHex": hx(int(row["endVa"])),
                "section": (section_for_va(sections, int(row["va"])) or {}).get("name"),
                "directCallXrefs": find_direct_call_xrefs(exe, sections, int(row["va"])),
                "pointerXrefs": find_pointer_xrefs(exe, sections, int(row["va"])),
                "directLiveMapBufferRefs": refs,
                "directLiveMapBufferRefCount": count_refs(refs),
                "byteSnippet": byte_snippet(exe, sections, int(row["va"])),
            }
        )

    steps = [
        {
            "step": "main loop enters field tick route",
            "vaHex": "0x004019f2",
            "evidence": "A direct E8 call targets 0x00411476.",
            "meaning": "0x00411476 is a per-frame/timing route worth checking before redraw.",
        },
        {
            "step": "tick route clamps update count",
            "vaHex": "0x00411476",
            "evidence": "The route derives a catch-up update count from timing globals and clamps it to 1..3 before the update loop.",
            "meaning": "This is the stable tick side, not a one-off map loader.",
        },
        {
            "step": "per update calls",
            "vaHex": "0x004114e2",
            "evidence": "The route calls 0x00422d74, 0x00435c04, and 0x00432ff0 inside the update loop.",
            "meaning": "Any frame/tick tile mutation should be in one of these calls or deeper.",
        },
        {
            "step": "direct live map-buffer refs are absent in tick windows",
            "vaHex": "0x00411476",
            "evidence": "Exact immediate refs to 0x00595af0/0x0058d7d0/0x005957d0/width/height were scanned in the tick route and the three per-frame update windows.",
            "meaning": "The visible animation producer is not a direct write in these top-level windows.",
        },
        {
            "step": "active object delayed script path remains",
            "vaHex": "0x00432ff0",
            "evidence": "The active-object update path walks object lists, decrements object +0x62 timers, swaps object +0x40 with +0x64 on expiry, and calls 0x00402321.",
            "meaning": "Per-frame object scripts can execute VM commands indirectly from the tick route.",
        },
        {
            "step": "generic script runner dispatches by opcode",
            "vaHex": "0x00402321",
            "evidence": "The runner reads the script pointer at object +0x40, reads the opcode byte, and dispatches via handler table 0x00440538.",
            "meaning": "Known tile-write handlers 0x58/0x6b/0x76 can be reached if an animated-map object script contains those opcodes.",
        },
        {
            "step": "redraw follows updates",
            "vaHex": "0x004114f6",
            "evidence": "After the update loop, 0x00411476 calls the 0x00425403 dirty scan and redraw passes.",
            "meaning": "A script-driven live layer0 mutation before this point would be visible in the same frame redraw path.",
        },
    ]

    surfaces = [
        {
            "surface": "field tick route",
            "promotion": "grounded-route",
            "finding": "0x004019f2 calls 0x00411476; 0x00411476 runs up to three update iterations before redraw.",
            "gap": "No direct live layer0/layer1 buffer write was found in the top-level route window.",
        },
        {
            "surface": "per-frame update calls",
            "promotion": "negative-direct-buffer-scan",
            "finding": "0x00422d74, 0x00435c04, and 0x00432ff0 were checked for direct refs to the live map buffers.",
            "gap": "Direct refs are absent, so visible motion is either indirect VM/script execution or another specialized callback.",
        },
        {
            "surface": "active object delayed script route",
            "promotion": "grounded-indirect-candidate",
            "finding": "0x00432ff0 can run object +0x64 delayed scripts through generic runner 0x00402321.",
            "gap": "The specific animated-map object/root that installs a tile-write stream is still unbound.",
        },
        {
            "surface": "generic object script runner",
            "promotion": "grounded-dispatcher",
            "finding": "0x00402321 dispatches object scripts via handler table 0x00440538.",
            "gap": "Need to bind animated maps to an object script stream containing tile-write opcodes 0x58/0x6b/0x76 or equivalent.",
        },
    ]

    direct_ref_counts = {
        row["key"]: row["directLiveMapBufferRefCount"]
        for row in landmark_rows
        if row["key"]
        in {
            "fieldTickRoute",
            "inputStateUpdate",
            "unknownPerFrameUpdate",
            "activeObjectDelayedScriptRoute",
        }
    }

    return {
        "kind": "hwanse-map-animation-tick-route-review",
        "status": "tick-route-grounded-producer-still-indirect",
        "source": [
            "Hwanse2.exe",
            "tools/build_map_animation_tick_route_review.py",
            "out/map_animation_redraw_consumer_review.json",
            "out/map_animation_live_buffer_writer_review.json",
            "out/map_animation_object_relative_tile_write_review.json",
        ],
        "globals": {key: hx(value) for key, value in LIVE_GLOBALS.items()},
        "summary": {
            "mainTimingLoopCallVaHex": "0x004019f2",
            "fieldTickRouteVaHex": "0x00411476",
            "updateLoopMax": 3,
            "perFrameUpdateCalls": ["0x00422d74", "0x00435c04", "0x00432ff0"],
            "directLiveBufferRefsInTickRoute": direct_ref_counts.get("fieldTickRoute", 0),
            "directLiveBufferRefsInPerFrameUpdates": sum(
                direct_ref_counts.get(key, 0)
                for key in ("inputStateUpdate", "unknownPerFrameUpdate", "activeObjectDelayedScriptRoute")
            ),
            "genericObjectScriptRunnerGrounded": True,
            "genericObjectScriptRunnerVaHex": "0x00402321",
            "genericObjectScriptHandlerTableVaHex": "0x00440538",
            "activeObjectDelayedScriptRouteGrounded": True,
            "activeObjectDelayedScriptRunnerVaHex": "0x00432ff0",
            "visibleMotionProducerFound": False,
            "remainingCandidate": "active-object delayed script -> generic object script runner -> tile-write opcodes 0x58/0x6b/0x76",
            "decision": (
                "The field tick/update route is grounded, but it does not directly mutate live map buffers. "
                "The remaining static producer candidate is an active-object delayed script executed through 0x00402321."
            ),
        },
        "landmarks": landmark_rows,
        "steps": steps,
        "surfaces": surfaces,
        "nextFrontier": [
            "Inventory active-object script streams installed for animated maps and search them for 0x58/0x6b/0x76 tile-write commands.",
            "Bind an object +0x64 delayed script payload to map1_01a/map1_02b/map_c animated resources if possible.",
            "If no static root binds, continue from the higher-level animated-map load/update dispatcher instead of promoting this route.",
        ],
    }


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    cards = [
        ("status", report["status"]),
        ("tick route", summary["fieldTickRouteVaHex"]),
        ("updates/frame", summary["updateLoopMax"]),
        ("tick direct refs", summary["directLiveBufferRefsInTickRoute"]),
        ("update direct refs", summary["directLiveBufferRefsInPerFrameUpdates"]),
        ("script runner", summary["genericObjectScriptRunnerVaHex"]),
        ("producer", "found" if summary["visibleMotionProducerFound"] else "indirect candidate"),
    ]
    card_html = "".join(f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>" for k, v in cards)
    surface_rows = "".join(
        "<tr>"
        f"<td>{h(row['surface'])}</td>"
        f"<td>{h(row['promotion'])}</td>"
        f"<td>{h(row['finding'])}</td>"
        f"<td>{h(row['gap'])}</td>"
        "</tr>"
        for row in report["surfaces"]
    )
    landmark_rows = "".join(
        "<tr>"
        f"<td><b>{h(row['name'])}</b><br><code>{h(row['vaHex'])}..{h(row['endVaHex'])}</code></td>"
        f"<td>{h(row['role'])}</td>"
        f"<td>{h(row['directLiveMapBufferRefCount'])}</td>"
        f"<td>{h(', '.join(ref['callVaHex'] for ref in row['directCallXrefs']))}</td>"
        f"<td><code>{h(row['byteSnippet'])}</code></td>"
        "</tr>"
        for row in report["landmarks"]
    )
    step_rows = "".join(
        "<tr>"
        f"<td>{h(row['step'])}<br><code>{h(row['vaHex'])}</code></td>"
        f"<td>{h(row['evidence'])}</td>"
        f"<td>{h(row['meaning'])}</td>"
        "</tr>"
        for row in report["steps"]
    )
    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 Tick Route 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:1000px; 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">redraw consumer</a>
    <a class="chip" href="map_animation_execution_boundary_review.html">draw transform</a>
    <a class="chip" href="map_animation_execution_boundary_review.html">live writers</a>
    <a class="chip" href="map_animation_execution_boundary_review.html">object-relative writes</a>
  </div>
  <h1>Map Animation Tick Route Review</h1>
  <p>{h(summary["decision"])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Execution Steps</h2>
    <table>
      <thead><tr><th>step</th><th>evidence</th><th>meaning</th></tr></thead>
      <tbody>{step_rows}</tbody>
    </table>
  </section>
  <section>
    <h2>Surfaces</h2>
    <table>
      <thead><tr><th>surface</th><th>promotion</th><th>finding</th><th>gap</th></tr></thead>
      <tbody>{surface_rows}</tbody>
    </table>
  </section>
  <section>
    <h2>Landmarks</h2>
    <table>
      <thead><tr><th>landmark</th><th>role</th><th>direct live-buffer refs</th><th>direct callers</th><th>bytes</th></tr></thead>
      <tbody>{landmark_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_TICK_ROUTE_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_MAP_ANIMATION_TICK_ROUTE_REVIEW, null, 2);
</script>
</body>
</html>
"""


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


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