#!/usr/bin/env python3
"""Summarize the original executable's GetTickCount-driven frame timing loop."""
from __future__ import annotations

import argparse
import html
import json
import math
import struct
from pathlib import Path

from probe_exe_scene_tables import read_sections, va_to_offset
from summarize_exe_imports import parse_imports


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

GET_TICK_IAT = 0x005A0464
TIMING_INIT_CALL = 0x00401946
TIMING_LOOP_CALL = 0x0040197C
TIMING_INTERPOLATION_CALL = 0x00401A18
FRAME_MS_WRITE = 0x0040195B
FRAME_INTERVAL_GLOBAL = 0x0059E37C
FRAME_COUNT_GLOBAL = 0x0059E380
ELAPSED_MS_GLOBAL = 0x0059E384
LAST_TICK_GLOBAL = 0x0059E38C
UPDATE_CALL = 0x004019F2
FRAME_CAP_COMPARE_A = 0x004019D9
FRAME_CAP_COMPARE_B = 0x00401A37
EXPECTED_FRAME_MS = 0x30
EXPECTED_FRAME_CAP = 3
PLAYER_WALK_FRAMES = 5
PLAYER_MOVING_FRAMES = 4
PLAYER_TILE_STEP_COMMANDS = 1


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


def read_at(exe: bytes, sections: list[dict], va: int, size: int) -> bytes:
    offset = va_to_offset(sections, va)
    if offset is None:
        raise ValueError(f"VA {hex32(va)} is outside raw sections")
    return exe[offset: offset + size]


def read_u8(exe: bytes, sections: list[dict], va: int) -> int:
    return read_at(exe, sections, va, 1)[0]


def read_u32(exe: bytes, sections: list[dict], va: int) -> int:
    return struct.unpack("<I", read_at(exe, sections, va, 4))[0]


def rel32_target(exe: bytes, sections: list[dict], va: int) -> int:
    opcode = read_u8(exe, sections, va)
    if opcode != 0xE8:
        raise ValueError(f"expected near call at {hex32(va)}")
    rel = struct.unpack("<i", read_at(exe, sections, va + 1, 4))[0]
    return va + 5 + rel


def indirect_call_target(exe: bytes, sections: list[dict], va: int) -> int:
    body = read_at(exe, sections, va, 6)
    if body[:2] != b"\xff\x15":
        raise ValueError(f"expected indirect call at {hex32(va)}")
    return struct.unpack("<I", body[2:])[0]


def find_import(imports: dict, name: str) -> dict:
    for dll in imports.get("imports") or []:
        for fn in dll.get("functions") or []:
            if fn.get("name") == name:
                return {"dll": dll.get("dll"), **fn}
    raise ValueError(f"import {name} not found")


def build_summary(exe_path: Path = EXE) -> dict:
    exe = exe_path.read_bytes()
    sections = read_sections(exe)
    imports = parse_imports(exe_path)
    get_tick = find_import(imports, "GetTickCount")
    call_sites = [TIMING_INIT_CALL, TIMING_LOOP_CALL, TIMING_INTERPOLATION_CALL]
    call_targets = [indirect_call_target(exe, sections, va) for va in call_sites]
    frame_write = read_at(exe, sections, FRAME_MS_WRITE, 10)
    if frame_write[:2] != b"\xc7\x05":
        raise ValueError(f"expected frame interval write at {hex32(FRAME_MS_WRITE)}")
    frame_global = struct.unpack("<I", frame_write[2:6])[0]
    frame_ms = struct.unpack("<I", frame_write[6:10])[0]
    cap_a = read_u8(exe, sections, FRAME_CAP_COMPARE_A + 2)
    cap_b = read_u8(exe, sections, FRAME_CAP_COMPARE_B + 2)
    update_target = rel32_target(exe, sections, UPDATE_CALL)
    nominal_fps = 1000 / frame_ms if frame_ms else 0
    historical_four_frame_walk_ms = frame_ms * PLAYER_MOVING_FRAMES
    current_tile_step_ms = frame_ms * PLAYER_TILE_STEP_COMMANDS
    summary = {
        "source": str(exe_path),
        "timingApi": {
            "dll": get_tick.get("dll"),
            "name": get_tick.get("name"),
            "iatVaHex": hex32(get_tick.get("iatVa")),
            "directTextCallRefs": [hex32(va) for va in call_sites],
            "allCallTargetsMatchIat": all(target == get_tick.get("iatVa") == GET_TICK_IAT for target in call_targets),
        },
        "loop": {
            "setupFunctionVaHex": "0x00401850",
            "timingStartVaHex": hex32(TIMING_INIT_CALL),
            "waitLoopVaHex": "0x00401965",
            "frameIntervalWriteVaHex": hex32(FRAME_MS_WRITE),
            "frameIntervalGlobalHex": hex32(frame_global),
            "frameIntervalMs": frame_ms,
            "lastTickGlobalHex": hex32(LAST_TICK_GLOBAL),
            "elapsedGlobalHex": hex32(ELAPSED_MS_GLOBAL),
            "frameCountGlobalHex": hex32(FRAME_COUNT_GLOBAL),
            "frameCap": min(cap_a, cap_b),
            "frameCapCompareVas": [hex32(FRAME_CAP_COMPARE_A), hex32(FRAME_CAP_COMPARE_B)],
            "updateCallVaHex": hex32(UPDATE_CALL),
            "updateTargetVaHex": hex32(update_target),
            "nominalFps": round(nominal_fps, 3),
            "catchUpWindowMs": frame_ms * min(cap_a, cap_b),
        },
        "webMapping": {
            "playerWalkFrames": PLAYER_WALK_FRAMES,
            "playerMovingFrames": PLAYER_MOVING_FRAMES,
            "historicalFourMovingFrameStepMs": historical_four_frame_walk_ms,
            "playerTileStepCommands": PLAYER_TILE_STEP_COMMANDS,
            "currentWebDefaultTileStepMs": current_tile_step_ms,
            "currentWebDefaultTileStepSeconds": round(current_tile_step_ms / 1000, 3),
            "mappingClass": "actor-controller-tile-mutation-derived",
            "exactActorConsumerStillInferred": False,
        },
        "verification": {
            "frameIntervalMatchesExpected": frame_ms == EXPECTED_FRAME_MS,
            "frameIntervalGlobalMatchesExpected": frame_global == FRAME_INTERVAL_GLOBAL,
            "frameCapMatchesExpected": cap_a == EXPECTED_FRAME_CAP and cap_b == EXPECTED_FRAME_CAP,
            "getTickIatMatchesExpected": get_tick.get("iatVa") == GET_TICK_IAT,
        },
        "promotionStatus": "movement-timing-grounded",
        "conclusion": (
            "The original executable uses KERNEL32!GetTickCount around the main update loop and stores a "
            "0x30 ms frame interval at 0x0059e37c. The loop waits until elapsed time covers the current frame "
            "count, computes a catch-up count, caps it at 3, and calls 0x00411476 with that count. The browser "
            "runtime therefore uses 48 ms as the original timing tick and, with the actor-controller tile "
            "mutation evidence from runtime_movement, maps one actor-controller pass to one tile step. "
            "The old four-moving-frame 192 ms browser default is retained only as historical context."
        ),
    }
    return summary


def markdown(summary: dict) -> str:
    timing = summary["timingApi"]
    loop = summary["loop"]
    mapping = summary["webMapping"]
    lines = [
        "# Runtime Timing",
        "",
        f"- timing API: `{timing['dll']}!{timing['name']}` IAT `{timing['iatVaHex']}`",
        f"- GetTickCount refs: {', '.join(f'`{ref}`' for ref in timing['directTextCallRefs'])}",
        f"- frame interval: `{loop['frameIntervalMs']}` ms from `{loop['frameIntervalWriteVaHex']}` -> `{loop['frameIntervalGlobalHex']}`",
        f"- nominal tick rate: `{loop['nominalFps']}` fps",
        f"- frame count cap: `{loop['frameCap']}`",
        f"- update call: `{loop['updateCallVaHex']}` -> `{loop['updateTargetVaHex']}`",
        f"- web tile step: `{mapping['currentWebDefaultTileStepMs']}` ms ({mapping['playerTileStepCommands']} actor-controller command * {loop['frameIntervalMs']} ms)",
        f"- historical four-frame step: `{mapping['historicalFourMovingFrameStepMs']}` ms ({mapping['playerMovingFrames']} moving frames * {loop['frameIntervalMs']} ms)",
        f"- actor movement consumer still inferred: {mapping['exactActorConsumerStillInferred']}",
        f"- status: `{summary['promotionStatus']}`",
        "",
        summary["conclusion"],
        "",
        "## Verification",
        "",
        "| check | value |",
        "| --- | --- |",
    ]
    for key, value in summary["verification"].items():
        lines.append(f"| {key} | {value} |")
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    timing = summary["timingApi"]
    loop = summary["loop"]
    mapping = summary["webMapping"]
    checks = "\n".join(
        f"<tr><td>{html.escape(key)}</td><td>{html.escape(str(value))}</td></tr>"
        for key, value in summary["verification"].items()
    )
    return "\n".join([
        "<!doctype html><meta charset='utf-8'>",
        "<title>Runtime Timing</title>",
        "<style>body{font-family:system-ui,sans-serif;margin:24px;line-height:1.45;max-width:980px}table{border-collapse:collapse}th,td{border:1px solid #ddd;padding:6px 8px;text-align:left}th{background:#f4f4f4}code{white-space:nowrap}</style>",
        "<h1>Runtime Timing</h1>",
        f"<p><b>Timing API:</b> <code>{html.escape(timing['dll'])}!{html.escape(timing['name'])}</code> IAT <code>{timing['iatVaHex']}</code></p>",
        f"<p><b>GetTickCount refs:</b> {', '.join(f'<code>{html.escape(str(ref))}</code>' for ref in timing['directTextCallRefs'])}</p>",
        f"<p><b>Frame interval:</b> {loop['frameIntervalMs']} ms at <code>{loop['frameIntervalGlobalHex']}</code>; "
        f"<b>nominal tick:</b> {loop['nominalFps']} fps; <b>catch-up cap:</b> {loop['frameCap']}</p>",
        f"<p><b>Web tile step:</b> {mapping['currentWebDefaultTileStepMs']} ms from {mapping['playerTileStepCommands']} actor-controller command * {loop['frameIntervalMs']} ms.</p>",
        f"<p><b>Historical four-frame step:</b> {mapping['historicalFourMovingFrameStepMs']} ms from {mapping['playerMovingFrames']} moving frames * {loop['frameIntervalMs']} ms.</p>",
        f"<p>actor movement consumer still inferred: {mapping['exactActorConsumerStillInferred']}</p>",
        f"<p>{html.escape(summary['conclusion'])}</p>",
        "<h2>Verification</h2>",
        "<table><thead><tr><th>check</th><th>value</th></tr></thead><tbody>",
        checks,
        "</tbody></table>",
    ])


def write_outputs(summary: dict, out_dir: Path) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "runtime_timing.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "runtime_timing.html").write_text(html_page(summary), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(args.exe)
    write_outputs(summary, args.out_dir)
    print(f"wrote runtime timing -> {args.out_dir / 'runtime_timing.html'}")


if __name__ == "__main__":
    main()
