#!/usr/bin/env python3
"""Check for a specialized map-animation loop outside known VM tile writes.

At this point the generic map animation evidence has split into two facts:

* layer1 bit 0x40 is consumed by the redraw path; and
* VM tile-write opcodes exist, but their known command roots do not bind to
  the visible fire/waterfall animation.

This report checks the remaining static possibility before moving to runtime:
that a nearby tick/draw callback performs a direct arithmetic tile replacement
or a special draw-time transform without using the generic 0x58/0x6b/0x76 VM
commands.

The promotion rule is intentionally strict.  Constants such as 40/80/120 are
not evidence by themselves.  A candidate must touch the live map buffers or
layer1 0x40 state in a relevant execution window to be more than a weak clue.
"""
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"

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


WINDOWS = [
    {
        "key": "fieldTickRoute",
        "name": "field tick/update route",
        "va": 0x00411476,
        "endVa": 0x0041157D,
        "role": "Per-frame field update route before redraw.",
    },
    {
        "key": "inputStateUpdate",
        "name": "input/state update callee",
        "va": 0x00422D74,
        "endVa": 0x00422E80,
        "role": "First per-frame update call from field tick.",
    },
    {
        "key": "secondaryUpdate",
        "name": "secondary per-frame update callee",
        "va": 0x00435C04,
        "endVa": 0x00435D20,
        "role": "Second per-frame update call from field tick.",
    },
    {
        "key": "activeObjectDelayedScriptRoute",
        "name": "active object delayed script route",
        "va": 0x00432FF0,
        "endVa": 0x00433130,
        "role": "Third per-frame update call; can run object scripts indirectly.",
    },
    {
        "key": "viewportDirtyScan",
        "name": "viewport dirty scan",
        "va": 0x00425403,
        "endVa": 0x00425620,
        "role": "Scans visible layer1 flags and marks dirty cells.",
    },
    {
        "key": "dirtyRowRenderer",
        "name": "dirty row renderer",
        "va": 0x00425163,
        "endVa": 0x0042530F,
        "role": "Reads live layer0 and draws dirty rows.",
    },
    {
        "key": "genericDrawWrapper",
        "name": "generic draw wrapper",
        "va": 0x004175D3,
        "endVa": 0x00417660,
        "role": "Receives packed tile descriptor from renderer.",
    },
    {
        "key": "drawObjectDispatcher",
        "name": "draw object dispatcher",
        "va": 0x0041747B,
        "endVa": 0x004175B6,
        "role": "Dispatches a draw object to a loaded surface.",
    },
    {
        "key": "tileIdToSourceRectConverter",
        "name": "tile id to source rect converter",
        "va": 0x004199A0,
        "endVa": 0x00419B80,
        "role": "Converts tile id to source rect by fixed grid math.",
    },
    {
        "key": "mapLoaderCopy",
        "name": "map loader live-buffer copy",
        "va": 0x0042449C,
        "endVa": 0x00424600,
        "role": "Initial map layer copy into live layer buffers.",
    },
]

LIVE_GLOBALS = {
    "liveLayer0Grid": 0x00595AF0,
    "liveLayer1FlagGrid": 0x0058D7D0,
    "dirtyWorkGrid": 0x005957D0,
    "mapWidth": 0x00595ADA,
    "mapHeight": 0x00595ADC,
    "cameraCurrentX": 0x004576DC,
    "cameraCurrentY": 0x004576DE,
    "cameraPreviousX": 0x00595ADE,
    "cameraPreviousY": 0x00595AE0,
}

# Values that would make sense for a simple source-tile shift hypothesis.
# They are weak clues unless live map-buffer evidence is nearby.
SHIFT_CONSTANTS = {
    0x0003: "small frame/count immediate",
    0x0004: "small frame/count immediate",
    0x0028: "40 tileset row shift candidate",
    0x0050: "80 / 2-row shift candidate",
    0x0078: "120 / 3-row shift candidate",
    0x00A0: "160 / 4-row shift candidate",
    0x0190: "400 map1_01a fire neighborhood candidate",
}

KNOWN_NON_PRODUCER_RANGES = [
    ("map-loader", 0x0042449C, 0x00424600),
    ("redraw-consumer", 0x00424920, 0x00425620),
    ("actor-collision", 0x0043022D, 0x00432020),
    ("vm-live-tile-writer", 0x00407000, 0x0040B000),
]


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


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


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


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 section_for_va(sections: list[dict[str, Any]], va: int) -> str | None:
    for section in sections:
        start = int(section["va"])
        end = start + int(section["raw_size"])
        if start <= va < end:
            return str(section["name"])
    return None


def range_label(va: int) -> str:
    for label, start, end in KNOWN_NON_PRODUCER_RANGES:
        if start <= va < end:
            return label
    return "other"


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


def find_direct_refs(data: bytes, start_va: int, targets: dict[str, int]) -> dict[str, list[dict[str, str]]]:
    rows: dict[str, list[dict[str, str]]] = {}
    for key, target in targets.items():
        needle = struct.pack("<I", target)
        hits: list[dict[str, str]] = []
        pos = data.find(needle)
        while pos != -1:
            hits.append(
                {
                    "vaHex": hx(start_va + pos) or "",
                    "context": data[max(0, pos - 10) : min(len(data), pos + 14)].hex(" "),
                }
            )
            pos = data.find(needle, pos + 1)
        rows[key] = hits
    return rows


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


def count_tile_state_refs(refs: dict[str, list[dict[str, str]]]) -> int:
    return len(refs.get("liveLayer0Grid", [])) + len(refs.get("liveLayer1FlagGrid", []))


def find_direct_calls(data: bytes, start_va: int) -> list[dict[str, Any]]:
    calls: list[dict[str, Any]] = []
    for pos in range(0, max(0, len(data) - 4)):
        if data[pos] != 0xE8:
            continue
        rel = struct.unpack_from("<i", data, pos + 1)[0]
        call_va = start_va + pos
        target_va = call_va + 5 + rel
        if 0x00400000 <= target_va < 0x00600000:
            calls.append(
                {
                    "callVa": call_va,
                    "callVaHex": hx(call_va),
                    "targetVa": target_va,
                    "targetVaHex": hx(target_va),
                }
            )
    return calls


def find_shift_constants(data: bytes, start_va: int) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    seen: set[tuple[int, int, str]] = set()
    for value, meaning in SHIFT_CONSTANTS.items():
        needles = [
            ("u8", bytes([value]) if 0 <= value <= 0xFF else b""),
            ("u16", struct.pack("<H", value)),
            ("u32", struct.pack("<I", value)),
        ]
        for encoding, needle in needles:
            if not needle:
                continue
            pos = data.find(needle)
            while pos != -1:
                key = (pos, value, encoding)
                if key not in seen:
                    seen.add(key)
                    rows.append(
                        {
                            "va": start_va + pos,
                            "vaHex": hx(start_va + pos),
                            "value": value,
                            "valueHex": f"0x{value:04x}",
                            "encoding": encoding,
                            "meaning": meaning,
                            "context": data[max(0, pos - 10) : min(len(data), pos + len(needle) + 10)].hex(" "),
                        }
                    )
                pos = data.find(needle, pos + 1)
    return sorted(rows, key=lambda row: (int(row["va"]), row["encoding"], row["value"]))


def inspect_callee(exe: bytes, sections: list[dict[str, Any]], target_va: int, size: int = 0x320) -> dict[str, Any]:
    offset = va_to_offset(sections, target_va)
    if offset is None:
        return {
            "targetVa": target_va,
            "targetVaHex": hx(target_va),
            "section": None,
            "inspectable": False,
        }
    data = exe[offset : offset + size]
    refs = find_direct_refs(data, target_va, LIVE_GLOBALS)
    constants = find_shift_constants(data, target_va)
    strong = count_refs(refs)
    tile_state = count_tile_state_refs(refs)
    return {
        "targetVa": target_va,
        "targetVaHex": hx(target_va),
        "section": section_for_va(sections, target_va),
        "inspectable": True,
        "rangeLabel": range_label(target_va),
        "directLiveRefCount": strong,
        "tileStateRefCount": tile_state,
        "directLiveRefs": refs,
        "shiftConstantHitCount": len(constants),
        "shiftConstantHits": constants[:24],
        "strongSpecializedCandidate": tile_state > 0 and range_label(target_va) == "other",
        "byteSnippet": byte_snippet(exe, sections, target_va),
    }


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

    window_rows: list[dict[str, Any]] = []
    callee_map: dict[int, dict[str, Any]] = {}
    weak_constant_hits = 0
    live_ref_window_count = 0
    specialized_candidate_count = 0

    for window in WINDOWS:
        offset, data = range_bytes(exe, sections, int(window["va"]), int(window["endVa"]))
        refs = find_direct_refs(data, int(window["va"]), LIVE_GLOBALS)
        constants = find_shift_constants(data, int(window["va"]))
        calls = find_direct_calls(data, int(window["va"]))
        for call in calls:
            target_va = int(call["targetVa"])
            if target_va not in callee_map:
                callee_map[target_va] = inspect_callee(exe, sections, target_va)
        live_ref_count = count_refs(refs)
        tile_state_ref_count = count_tile_state_refs(refs)
        if live_ref_count:
            live_ref_window_count += 1
        weak_constant_hits += len(constants)
        row = {
            **window,
            "vaHex": hx(int(window["va"])),
            "endVaHex": hx(int(window["endVa"])),
            "section": section_for_va(sections, int(window["va"])),
            "directLiveRefCount": live_ref_count,
            "tileStateRefCount": tile_state_ref_count,
            "directLiveRefs": refs,
            "directCallCount": len(calls),
            "directCalls": calls,
            "shiftConstantHitCount": len(constants),
            "shiftConstantHits": constants[:32],
            "byteSnippet": data[:96].hex(" "),
        }
        window_rows.append(row)

    callee_rows = sorted(callee_map.values(), key=lambda row: int(row["targetVa"]))
    specialized_candidate_count = sum(1 for row in callee_rows if row.get("strongSpecializedCandidate"))
    callee_direct_refs = sum(int(row.get("directLiveRefCount", 0)) for row in callee_rows)
    callee_tile_state_refs = sum(int(row.get("tileStateRefCount", 0)) for row in callee_rows)
    callee_range_counts = Counter(str(row.get("rangeLabel")) for row in callee_rows)

    promoted = [
        row
        for row in callee_rows
        if row.get("strongSpecializedCandidate")
    ]

    status = (
        "specialized-map-animation-candidate-found"
        if promoted
        else "specialized-map-animation-loop-not-found"
    )

    decision = (
        "A direct callee outside known loader/redraw/collision/VM-writer ranges touches live map buffers; inspect it as a possible specialized producer."
        if promoted
        else (
            "No inspected tick/redraw/draw window, nor its direct callees, exposes a separate live-buffer/tile-shift producer. "
            "The static evidence still stops at redraw invalidation plus generic writer handlers; continue from the animated-map load/update dispatcher."
        )
    )

    return {
        "kind": "hwanse-map-animation-specialized-loop-boundary-review",
        "status": status,
        "source": [
            "Hwanse2.exe",
            "tools/build_map_animation_specialized_loop_boundary_review.py",
            "out/map_animation_redraw_consumer_review.json",
            "out/map_animation_draw_tile_transform_review.json",
            "out/map_animation_tick_route_review.json",
            "out/map_animation_non_object_tile_write_root_review.json",
        ],
        "summary": {
            "inspectedWindowCount": len(window_rows),
            "windowsWithDirectLiveRefs": live_ref_window_count,
            "weakShiftConstantHitCount": weak_constant_hits,
            "directCalleeCount": len(callee_rows),
            "calleeDirectLiveRefCount": callee_direct_refs,
            "calleeTileStateRefCount": callee_tile_state_refs,
            "calleeRangeCounts": dict(callee_range_counts),
            "strongSpecializedCandidateCount": specialized_candidate_count,
            "specializedVisibleMotionProducerFound": bool(promoted),
            "promotionRule": "only live layer0/layer1 tile-state refs in a relevant unknown callee can promote; dirty-grid/camera refs and constants alone are weak",
            "decision": decision,
        },
        "windows": window_rows,
        "directCallees": callee_rows,
        "promotedCandidates": promoted,
        "nextFrontier": [
            "If runtime is allowed, watch writes to live layer0 0x00595af0 during one tick on map1_01a or map1_02b.",
            "Record which instruction writes the changed animated coordinates; then map that writer back to static code.",
            "Do not keep promoting 0x40 redraw coverage or shift constants as visible animation proof without a producer.",
        ],
    }


def render_refs(refs: dict[str, list[dict[str, str]]]) -> str:
    parts = []
    for key, values in refs.items():
        if not values:
            continue
        listed = ", ".join(item["vaHex"] for item in values[:6])
        if len(values) > 6:
            listed += f" ... +{len(values) - 6}"
        parts.append(f"<b>{h(key)}</b>: {h(listed)}")
    return "<br>".join(parts) if parts else "<span class='muted'>none</span>"


def render_constants(rows: list[dict[str, Any]]) -> str:
    if not rows:
        return "<span class='muted'>none</span>"
    return "<br>".join(
        f"<code>{h(row['vaHex'])}</code> {h(row['valueHex'])}/{h(row['encoding'])}"
        for row in rows[:10]
    )


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    cards = [
        ("status", report["status"]),
        ("windows", summary["inspectedWindowCount"]),
        ("windows live refs", summary["windowsWithDirectLiveRefs"]),
        ("weak constants", summary["weakShiftConstantHitCount"]),
        ("direct callees", summary["directCalleeCount"]),
        ("callee live refs", summary["calleeDirectLiveRefCount"]),
        ("strong candidates", summary["strongSpecializedCandidateCount"]),
        ("producer", "found" if summary["specializedVisibleMotionProducerFound"] else "not found"),
    ]
    card_html = "".join(f"<div class='card'><b>{h(k)}</b><span>{h(v)}</span></div>" for k, v in cards)

    window_rows = []
    for row in report["windows"]:
        window_rows.append(
            "<tr>"
            f"<td><b>{h(row['name'])}</b><br><code>{h(row['vaHex'])}..{h(row['endVaHex'])}</code><br>{h(row['role'])}</td>"
            f"<td>{h(row['directLiveRefCount'])} total / {h(row.get('tileStateRefCount', 0))} tile-state<br>{render_refs(row['directLiveRefs'])}</td>"
            f"<td>{h(row['directCallCount'])}</td>"
            f"<td>{h(row['shiftConstantHitCount'])}<br>{render_constants(row['shiftConstantHits'])}</td>"
            "</tr>"
        )

    callee_rows = []
    for row in report["directCallees"]:
        callee_rows.append(
            "<tr>"
            f"<td><code>{h(row['targetVaHex'])}</code><br>{h(row.get('rangeLabel'))}</td>"
            f"<td>{h(row.get('directLiveRefCount', 0))} total / {h(row.get('tileStateRefCount', 0))} tile-state<br>{render_refs(row.get('directLiveRefs', {}))}</td>"
            f"<td>{h(row.get('shiftConstantHitCount', 0))}<br>{render_constants(row.get('shiftConstantHits', []))}</td>"
            f"<td>{'yes' if row.get('strongSpecializedCandidate') else 'no'}</td>"
            "</tr>"
        )

    promoted_rows = "".join(
        "<tr>"
        f"<td><code>{h(row['targetVaHex'])}</code></td>"
        f"<td>{render_refs(row.get('directLiveRefs', {}))}</td>"
        f"<td>{render_constants(row.get('shiftConstantHits', []))}</td>"
        "</tr>"
        for row in report["promotedCandidates"]
    ) or "<tr><td colspan='3' class='muted'>no promoted candidate</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 Specialized Loop Boundary 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:960px; 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; }}
    .muted {{ color:#94a3b8; }}
    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">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">tick route</a>
  </div>
  <h1>Map Animation Specialized Loop Boundary Review</h1>
  <p>{h(summary["decision"])}</p>
  <div class="summary">{card_html}</div>
  <section>
    <h2>Promoted Candidates</h2>
    <table>
      <thead><tr><th>callee</th><th>live refs</th><th>shift constants</th></tr></thead>
      <tbody>{promoted_rows}</tbody>
    </table>
  </section>
  <section>
    <h2>Inspected Windows</h2>
    <table>
      <thead><tr><th>window</th><th>direct live refs</th><th>direct calls</th><th>weak shift constants</th></tr></thead>
      <tbody>{''.join(window_rows)}</tbody>
    </table>
  </section>
  <section>
    <h2>Direct Callees</h2>
    <table>
      <thead><tr><th>callee</th><th>live refs</th><th>weak shift constants</th><th>strong candidate</th></tr></thead>
      <tbody>{''.join(callee_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_SPECIALIZED_LOOP_BOUNDARY_REVIEW = {payload};
document.getElementById('json').textContent = JSON.stringify(window.HWANSE_MAP_ANIMATION_SPECIALIZED_LOOP_BOUNDARY_REVIEW, null, 2);
</script>
</body>
</html>
"""


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


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