from __future__ import annotations

import random
from dataclasses import replace
from pathlib import Path

import pyray as rl

from grim import music as grim_music
from grim.audio import AudioState, init_audio_state, play_music, shutdown_audio, update_audio
from grim.config import CrimsonConfig
from grim.console import ConsoleState
from grim.fonts.small import SmallFontData, draw_small_text, load_small_font, measure_small_text_width
from grim.geom import Vec2
from grim.view import ViewContext

from ..game_modes import GameMode
from ..game_world import GameWorld
from ..original.capture import CAPTURE_BOOTSTRAP_EVENT_KIND
from ..quests import quest_by_level
from ..quests.runtime import build_quest_spawn_table
from ..quests.types import QuestContext
from ..replay import (
    Replay,
    UnknownEvent,
    apply_replay_bootstrap,
    load_replay_file,
    unpack_tick_inputs,
    warn_on_game_version_mismatch,
)
from ..sim.driver.replay_events import apply_replay_tick_events, partition_tick_events
from ..sim.driver.setup import build_damage_scale_by_type, status_from_snapshot
from ..sim.sessions import QuestDeterministicSession, RushDeterministicSession, SurvivalDeterministicSession
from ..terrain_assets import TerrainTextureId, terrain_texture_by_id
from ..ui.hud import (
    HUD_AMMO_BASE_POS,
    HUD_AMMO_TEXT_OFFSET,
    HudAssets,
    HudState,
    draw_hud_overlay,
    hud_flags_for_game_mode,
    hud_ui_scale,
    load_hud_assets,
)
from ..weapon_runtime import weapon_assign_player
from ..weapons import WeaponId

RUSH_WEAPON_ID = WeaponId.ASSAULT_RIFLE
_PLAYBACK_SPEED_STEPS: tuple[float, ...] = (0.25, 0.5, 1.0, 2.0, 4.0, 8.0)
_DEFAULT_SPEED_INDEX = 2
_SKIP_SHORT_SECONDS = 5.0
_SKIP_LONG_SECONDS = 30.0
_REPLAY_WIDGET_PANEL_SIZE = Vec2(182.0, 53.0)
_REPLAY_WIDGET_ICON_SIZE = Vec2(32.0, 32.0)
_REPLAY_WIDGET_BAR_HEIGHT = 4.0
_REPLAY_WIDGET_X_SHIFT = 10.0
_REPLAY_WIDGET_TEXT_LINE1_Y = HUD_AMMO_BASE_POS[1] + HUD_AMMO_TEXT_OFFSET[1]
_REPLAY_WIDGET_PANEL_TO_LINE1_Y = -7.0
_REPLAY_WIDGET_PANEL_OFFSET_X = 0.0
_REPLAY_WIDGET_PANEL_OFFSET_Y = 0.0
_REPLAY_WIDGET_CLOCK_OFFSET_X = 0.0
_REPLAY_WIDGET_CLOCK_OFFSET_Y = 0.0
_REPLAY_WIDGET_TEXT_OFFSET_X = 0.0
_REPLAY_WIDGET_TEXT_OFFSET_Y = 0.0
_REPLAY_WIDGET_BAR_OFFSET_X = 0.0
_REPLAY_WIDGET_BAR_OFFSET_Y = 0.0


class ReplayPlaybackMode:
    def __init__(
        self,
        ctx: ViewContext,
        *,
        replay_path: Path,
        config: CrimsonConfig,
        console: ConsoleState,
        max_ticks: int | None = None,
        strict_events: bool = True,
        trace_rng: bool = False,
    ) -> None:
        self._ctx = ctx
        self._replay_path = Path(replay_path)
        self._config = config
        self._console = console
        self._max_ticks = (max(0, int(max_ticks)) if max_ticks is not None else None)
        self._strict_events = bool(strict_events)
        self._trace_rng = bool(trace_rng)

        self.close_requested = False

        self._replay: Replay | None = None
        self._world: GameWorld | None = None
        self._events_by_tick: dict[int, list[object]] = {}
        self._defer_menu_open = False
        self._damage_scale_by_type = build_damage_scale_by_type()
        self._small: SmallFontData | None = None
        self._hud_assets: HudAssets | None = None
        self._hud_state = HudState()

        self._tick_rate = 60
        self._dt_frame = 1.0 / 60.0
        self._dt_accum = 0.0
        self._tick_index = 0
        self._finished = False
        self._terminal_events_applied = False
        self._paused = False
        self._speed_index = _DEFAULT_SPEED_INDEX

        self._survival: SurvivalDeterministicSession | None = None
        self._rush: RushDeterministicSession | None = None
        self._quest: QuestDeterministicSession | None = None
        self._quest_total_spawn_count = 0
        self._quest_spawn_timeline_ms = 0.0

        self._audio: AudioState | None = None
        self._audio_rng: random.Random | None = None

    @property
    def tick_index(self) -> int:
        return int(self._tick_index)

    @property
    def finished(self) -> bool:
        return bool(self._finished)

    @staticmethod
    def _format_time_text(seconds: float) -> str:
        total_seconds = max(0, int(seconds))
        minutes = total_seconds // 60
        rem_seconds = total_seconds % 60
        return f"{minutes}:{rem_seconds:02d}"

    def _replay_progress_ratio(self) -> float:
        replay = self._replay
        if replay is None:
            return 0.0
        total_ticks = len(replay.inputs)
        if total_ticks <= 0:
            return 1.0
        ratio = float(self._tick_index) / float(total_ticks)
        if ratio < 0.0:
            return 0.0
        if ratio > 1.0:
            return 1.0
        return ratio

    def _register_replay_audio_commands(self) -> None:
        console = self._console

        def cmd_snd_add_game_tune(args: list[str]) -> None:
            if len(args) != 1:
                console.log.log("snd_addGameTune <tuneName.ogg>")
                return
            audio = self._audio
            if audio is None:
                return
            rel_path = f"music/{args[0]}"
            result = grim_music.load_music_track(audio.music, self._ctx.assets_dir, rel_path, console=console)
            if result is None:
                return
            track_key, _track_id = result
            grim_music.queue_track(audio.music, track_key)

        console.register_command("snd_addGameTune", cmd_snd_add_game_tune)

    def _load_game_tune_queue(self) -> None:
        if self._audio is None:
            return
        self._console.exec_line("exec music/game_tunes.txt")

    def _replay_widget_metrics(self) -> tuple[float, float, float, float, float, float]:
        screen_w = float(rl.get_screen_width())
        screen_h = float(rl.get_screen_height())
        scale = hud_ui_scale(screen_w, screen_h)

        panel_w = _REPLAY_WIDGET_PANEL_SIZE.x * scale
        panel_h = _REPLAY_WIDGET_PANEL_SIZE.y * scale
        panel_x = screen_w - panel_w - float(_REPLAY_WIDGET_X_SHIFT) * scale
        line1_y = float(_REPLAY_WIDGET_TEXT_LINE1_Y) * scale
        panel_y = max(2.0 * scale, line1_y + _REPLAY_WIDGET_PANEL_TO_LINE1_Y * scale)
        return scale, panel_x, panel_y, panel_w, panel_h, line1_y

    def _draw_replay_widget(self) -> None:
        replay = self._replay
        if replay is None:
            return

        scale, panel_x, panel_y, panel_w, panel_h, line1_y = self._replay_widget_metrics()
        panel_x += float(_REPLAY_WIDGET_PANEL_OFFSET_X) * scale
        panel_y += float(_REPLAY_WIDGET_PANEL_OFFSET_Y) * scale

        assets = self._hud_assets

        icon_w = _REPLAY_WIDGET_ICON_SIZE.x * scale
        icon_h = _REPLAY_WIDGET_ICON_SIZE.y * scale
        icon_x = panel_x + 2.0 * scale + float(_REPLAY_WIDGET_CLOCK_OFFSET_X) * scale
        icon_y = panel_y + 8.0 * scale + float(_REPLAY_WIDGET_CLOCK_OFFSET_Y) * scale

        if assets is not None and assets.clock_table is not None:
            src = rl.Rectangle(0.0, 0.0, float(assets.clock_table.width), float(assets.clock_table.height))
            dst = rl.Rectangle(icon_x, icon_y, icon_w, icon_h)
            rl.draw_texture_pro(assets.clock_table, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.Color(255, 255, 255, 230))

        elapsed_seconds = float(self._tick_index) / float(self._tick_rate)

        if assets is not None and assets.clock_pointer is not None:
            src = rl.Rectangle(0.0, 0.0, float(assets.clock_pointer.width), float(assets.clock_pointer.height))
            center_x = icon_x + icon_w * 0.5
            center_y = icon_y + icon_h * 0.5
            dst = rl.Rectangle(center_x, center_y, icon_w, icon_h)
            origin = rl.Vector2(icon_w * 0.5, icon_h * 0.5)
            rotation = max(0.0, float(elapsed_seconds)) * 6.0
            rl.draw_texture_pro(
                assets.clock_pointer,
                src,
                dst,
                origin,
                rotation,
                rl.Color(255, 255, 255, 220),
            )

        total_ticks = len(replay.inputs)
        total_seconds = float(total_ticks) / float(self._tick_rate)
        progress_ratio = self._replay_progress_ratio()

        text_x = icon_x + icon_w + 6.0 * scale + float(_REPLAY_WIDGET_TEXT_OFFSET_X) * scale
        line1_y = line1_y + float(_REPLAY_WIDGET_TEXT_OFFSET_Y) * scale
        text_scale = 1.0
        status = "PAUSE" if self._paused else "REPLAY"
        status_color = rl.Color(245, 210, 120, 230) if self._paused else rl.Color(230, 230, 230, 220)
        self._draw_ui_text(
            f"{status} {self._playback_speed():.2f}x",
            Vec2(text_x, line1_y),
            status_color,
            scale=text_scale,
        )

        elapsed_text = self._format_time_text(elapsed_seconds)
        total_text = self._format_time_text(total_seconds)
        elapsed_w = self._measure_ui_text_width(elapsed_text, scale=text_scale)
        total_w = self._measure_ui_text_width(total_text, scale=text_scale)
        line2_y = line1_y + 18.0 * scale

        right_limit = panel_x + panel_w - 4.0 * scale + float(_REPLAY_WIDGET_TEXT_OFFSET_X) * scale
        total_x = right_limit - total_w
        bar_x_base = text_x + elapsed_w + 6.0 * scale
        bar_w = max(8.0 * scale, total_x - 6.0 * scale - bar_x_base)
        bar_x = bar_x_base + float(_REPLAY_WIDGET_BAR_OFFSET_X) * scale
        bar_y = line2_y + 5.0 * scale + float(_REPLAY_WIDGET_BAR_OFFSET_Y) * scale
        bar_h = _REPLAY_WIDGET_BAR_HEIGHT * scale
        rl.draw_rectangle(int(bar_x), int(bar_y), int(bar_w), int(bar_h), rl.Color(46, 67, 96, 150))
        fill_w = bar_w * progress_ratio
        if fill_w > 0.0:
            rl.draw_rectangle(int(bar_x), int(bar_y), int(fill_w), int(bar_h), rl.Color(70, 130, 220, 225))

        self._draw_ui_text(
            elapsed_text,
            Vec2(text_x, line2_y),
            rl.Color(220, 220, 220, 210),
            scale=text_scale,
        )
        self._draw_ui_text(
            total_text,
            Vec2(total_x, line2_y),
            rl.Color(220, 220, 220, 210),
            scale=text_scale,
        )

    def open(self) -> None:
        self._small = load_small_font(self._ctx.assets_dir)
        self._hud_assets = load_hud_assets(self._ctx.assets_dir)
        self._hud_state = HudState()

        replay = load_replay_file(self._replay_path)
        self._replay = replay
        warn_on_game_version_mismatch(replay, action="playback")

        tick_rate = int(replay.header.tick_rate)
        if tick_rate <= 0:
            raise ValueError(f"invalid tick_rate: {tick_rate}")
        self._tick_rate = tick_rate
        self._dt_frame = 1.0 / float(tick_rate)
        self._dt_accum = 0.0
        self._tick_index = 0
        self._finished = False
        self._terminal_events_applied = False
        self._paused = False
        self._speed_index = _DEFAULT_SPEED_INDEX

        events_by_tick: dict[int, list[object]] = {}
        for event in replay.events:
            events_by_tick.setdefault(int(event.tick_index), []).append(event)
        self._events_by_tick = events_by_tick
        self._defer_menu_open = any(
            isinstance(event, UnknownEvent) and str(event.kind) == CAPTURE_BOOTSTRAP_EVENT_KIND
            for event in replay.events
        )

        world_size = float(replay.header.world_size)
        audio = init_audio_state(self._config, self._ctx.assets_dir, self._console)
        audio_rng = random.Random(int(replay.header.seed) & 0xFFFFFFFF)
        self._audio = audio
        self._audio_rng = audio_rng
        self._register_replay_audio_commands()
        self._load_game_tune_queue()

        world = GameWorld(
            assets_dir=self._ctx.assets_dir,
            world_size=world_size,
            demo_mode_active=False,
            difficulty_level=int(replay.header.difficulty_level),
            hardcore=bool(replay.header.hardcore),
            preserve_bugs=bool(replay.header.preserve_bugs),
            texture_cache=None,
            config=self._config,
            audio=audio,
            audio_rng=audio_rng,
        )
        seed_for_reset = int(replay.header.seed)
        if str(replay.header.bootstrap_kind) != "none":
            seed_for_reset = int(replay.header.bootstrap_seed)
        world.reset(seed=int(seed_for_reset), player_count=int(replay.header.player_count))
        world.open()
        world.state.status = status_from_snapshot(
            quest_unlock_index=int(replay.header.status.quest_unlock_index),
            quest_unlock_index_full=int(replay.header.status.quest_unlock_index_full),
            weapon_usage_counts=replay.header.status.weapon_usage_counts,
        )
        bootstrap = apply_replay_bootstrap(
            replay.header,
            rng=world.state.rng,
            world_size=float(world_size),
            strict=True,
        )
        if bootstrap is not None:
            world.apply_bootstrap_terrain(
                terrain_ids=bootstrap.terrain.terrain_ids,
                seed=int(bootstrap.terrain.terrain_seed),
                layers=3,
            )

        self._world = world

        if int(replay.header.game_mode_id) == int(GameMode.SURVIVAL):
            self._survival = SurvivalDeterministicSession(
                world=world.world_state,
                world_size=float(world.world_size),
                damage_scale_by_type=self._damage_scale_by_type,
                fx_queue=world.fx_queue,
                fx_queue_rotated=world.fx_queue_rotated,
                detail_preset=int(replay.header.detail_preset),
                fx_toggle=int(replay.header.fx_toggle),
                game_tune_started=bool(world._game_tune_started),
                clear_fx_queues_each_tick=False,
            )
            self._rush = None
            self._quest = None
            self._quest_total_spawn_count = 0
            self._quest_spawn_timeline_ms = 0.0
        elif int(replay.header.game_mode_id) == int(GameMode.QUESTS):
            self._survival = None
            self._rush = None
            from ..sim.driver.replay_runner import _resolve_quest_level  # local import to keep view import-light

            quest_level = _resolve_quest_level(replay)
            quest = quest_by_level(quest_level) if quest_level else None
            if quest is None:
                raise ValueError(f"unsupported quest replay: unknown quest_level={quest_level!r}")

            world.state.quest_stage_major, world.state.quest_stage_minor = quest.level_key

            base_id, overlay_id, detail_id = quest.terrain_ids or (
                TerrainTextureId.Q1_BASE,
                TerrainTextureId.Q1_OVERLAY,
                TerrainTextureId.Q1_BASE,
            )
            base = terrain_texture_by_id(int(base_id))
            overlay = terrain_texture_by_id(int(overlay_id))
            detail = terrain_texture_by_id(int(detail_id))
            if base is not None and overlay is not None:
                base_key, base_path = base
                overlay_key, overlay_path = overlay
                detail_key = detail[0] if detail is not None else None
                detail_path = detail[1] if detail is not None else None
                world.set_terrain(
                    base_key=base_key,
                    overlay_key=overlay_key,
                    base_path=base_path,
                    overlay_path=overlay_path,
                    detail_key=detail_key,
                    detail_path=detail_path,
                )

            start_weapon_id = max(1, int(quest.start_weapon_id))
            for player in world.players:
                weapon_assign_player(player, start_weapon_id)

            ctx = QuestContext(
                width=int(world.world_size),
                height=int(world.world_size),
                player_count=len(world.players),
            )
            spawn_entries = build_quest_spawn_table(
                quest,
                ctx,
                seed=int(replay.header.seed),
                hardcore=bool(replay.header.hardcore),
                full_version=True,
            )
            self._quest_total_spawn_count = int(sum(int(entry.count) for entry in spawn_entries))
            self._quest_spawn_timeline_ms = 0.0
            self._quest = QuestDeterministicSession(
                world=world.world_state,
                world_size=float(world.world_size),
                damage_scale_by_type=self._damage_scale_by_type,
                fx_queue=world.fx_queue,
                fx_queue_rotated=world.fx_queue_rotated,
                spawn_entries=tuple(spawn_entries),
                detail_preset=int(replay.header.detail_preset),
                fx_toggle=int(replay.header.fx_toggle),
                clear_fx_queues_each_tick=False,
            )
        elif int(replay.header.game_mode_id) == int(GameMode.RUSH):
            if any(
                not (isinstance(event, UnknownEvent) and str(event.kind) == CAPTURE_BOOTSTRAP_EVENT_KIND)
                for event in replay.events
            ):
                raise ValueError("rush replay does not support events")
            self._survival = None
            self._rush = RushDeterministicSession(
                world=world.world_state,
                world_size=float(world.world_size),
                damage_scale_by_type=self._damage_scale_by_type,
                fx_queue=world.fx_queue,
                fx_queue_rotated=world.fx_queue_rotated,
                detail_preset=int(replay.header.detail_preset),
                fx_toggle=int(replay.header.fx_toggle),
                game_tune_started=bool(world._game_tune_started),
                clear_fx_queues_each_tick=False,
                enforce_loadout=self._enforce_rush_loadout,
            )
            self._quest = None
            self._enforce_rush_loadout()
            self._quest_total_spawn_count = 0
            self._quest_spawn_timeline_ms = 0.0
        else:
            raise ValueError(f"unsupported replay game_mode_id: {int(replay.header.game_mode_id)}")

    def close(self) -> None:
        if self._small is not None:
            rl.unload_texture(self._small.texture)
            self._small = None
        self._hud_assets = None
        world = self._world
        self._world = None
        if world is not None:
            world.close()
        if self._audio is not None:
            shutdown_audio(self._audio)
            self._audio = None
            self._audio_rng = None

    def _draw_ui_text(self, text: str, pos: Vec2, color: rl.Color, *, scale: float = 1.0) -> None:
        if self._small is not None:
            draw_small_text(self._small, text, pos, scale, color)
        else:
            rl.draw_text(text, int(pos.x), int(pos.y), int(20 * scale), color)

    def _measure_ui_text_width(self, text: str, *, scale: float = 1.0) -> float:
        if self._small is not None:
            return float(measure_small_text_width(self._small, text, scale))
        return float(len(text)) * 8.0 * float(scale)

    def _enforce_rush_loadout(self) -> None:
        world = self._world
        if world is None:
            return
        for player in world.players:
            if int(player.weapon_id) != int(RUSH_WEAPON_ID):
                weapon_assign_player(player, int(RUSH_WEAPON_ID))
            player.ammo = float(max(0, int(player.clip_size)))

    def _apply_tick_events(self, events: list[object], *, tick_index: int, dt_frame: float) -> None:
        replay = self._replay
        world = self._world
        if replay is None or world is None:
            return
        bootstrap_elapsed_ms = apply_replay_tick_events(
            events,
            tick_index=int(tick_index),
            dt_frame=float(dt_frame),
            world=world.world_state,
            game_mode_id=int(replay.header.game_mode_id),
            strict_events=bool(self._strict_events),
        )
        if bootstrap_elapsed_ms is not None:
            world._elapsed_ms = float(bootstrap_elapsed_ms)

    def _tick_survival(self, *, tick_index: int, dt_frame: float) -> float:
        replay = self._replay
        world = self._world
        session = self._survival
        if replay is None or world is None or session is None:
            return 0.0

        tick_events = self._events_by_tick.get(int(tick_index), [])
        pre_step_events, post_step_events = partition_tick_events(
            tick_events,
            defer_menu_open=bool(self._defer_menu_open),
        )
        self._apply_tick_events(pre_step_events, tick_index=tick_index, dt_frame=dt_frame)

        player_inputs = unpack_tick_inputs(replay.inputs[int(tick_index)])
        tick = session.step_tick(
            dt_frame=float(dt_frame),
            inputs=player_inputs,
            trace_rng=bool(self._trace_rng),
        )
        if post_step_events:
            self._apply_tick_events(post_step_events, tick_index=tick_index, dt_frame=dt_frame)
        world.apply_step_result(
            tick.step,
            game_tune_started=bool(session.game_tune_started),
            apply_audio=True,
            update_camera=False,
        )

        return float(tick.step.dt_sim)

    def _tick_rush(self, *, tick_index: int, dt_frame: float) -> float:
        replay = self._replay
        world = self._world
        session = self._rush
        if replay is None or world is None or session is None:
            return 0.0

        self._apply_tick_events(self._events_by_tick.get(int(tick_index), []), tick_index=tick_index, dt_frame=dt_frame)

        player_inputs = [
            replace(inp, reload_pressed=False) for inp in unpack_tick_inputs(replay.inputs[int(tick_index)])
        ]
        tick = session.step_tick(
            dt_frame=float(dt_frame),
            inputs=player_inputs,
            trace_rng=bool(self._trace_rng),
        )
        world.apply_step_result(
            tick.step,
            game_tune_started=bool(session.game_tune_started),
            apply_audio=True,
            update_camera=False,
        )
        return float(tick.step.dt_sim)

    def _tick_quest(self, *, tick_index: int, dt_frame: float) -> float:
        replay = self._replay
        world = self._world
        session = self._quest
        if replay is None or world is None or session is None:
            return 0.0

        self._apply_tick_events(self._events_by_tick.get(int(tick_index), []), tick_index=tick_index, dt_frame=dt_frame)

        player_inputs = unpack_tick_inputs(replay.inputs[int(tick_index)])
        tick = session.step_tick(
            dt_frame=float(dt_frame),
            inputs=player_inputs,
            trace_rng=bool(self._trace_rng),
        )
        world.apply_step_result(
            tick.step,
            game_tune_started=False,
            apply_audio=True,
            update_camera=False,
        )
        self._quest_spawn_timeline_ms = float(tick.spawn_timeline_ms)
        if tick.play_hit_sfx:
            world.audio_router.play_sfx("sfx_questhit")
        if tick.play_completion_music and world.audio is not None:
            play_music(world.audio, "crimsonquest")
            playback = world.audio.music.playbacks.get("crimsonquest")
            if playback is not None:
                playback.volume = 0.0
                try:
                    rl.set_music_volume(playback.music, 0.0)
                except RuntimeError:
                    playback.volume = 0.0
        return float(tick.step.dt_sim)

    def _tick_one(self) -> None:
        replay = self._replay
        world = self._world
        if replay is None or world is None:
            self._finished = True
            return

        tick_index = int(self._tick_index)
        if tick_index >= len(replay.inputs):
            if (not self._terminal_events_applied) and tick_index == len(replay.inputs):
                self._apply_tick_events(
                    self._events_by_tick.get(int(tick_index), []),
                    tick_index=tick_index,
                    dt_frame=float(self._dt_frame),
                )
                self._terminal_events_applied = True
            self._finished = True
            return
        if self._max_ticks is not None and tick_index >= int(self._max_ticks):
            self._finished = True
            return

        dt_frame = float(self._dt_frame)
        if self._survival is not None:
            dt_sim = self._tick_survival(tick_index=tick_index, dt_frame=dt_frame)
        elif self._quest is not None:
            dt_sim = self._tick_quest(tick_index=tick_index, dt_frame=dt_frame)
        elif self._rush is not None:
            dt_sim = self._tick_rush(tick_index=tick_index, dt_frame=dt_frame)
        else:  # pragma: no cover
            self._finished = True
            return

        world.update_camera(float(dt_sim))

        self._tick_index += 1

    def _playback_speed(self) -> float:
        return float(_PLAYBACK_SPEED_STEPS[int(self._speed_index)])

    def _change_speed(self, delta: int) -> None:
        idx = int(self._speed_index) + int(delta)
        idx = max(0, min(idx, len(_PLAYBACK_SPEED_STEPS) - 1))
        self._speed_index = idx

    def _skip_forward_seconds(self, seconds: float) -> None:
        replay = self._replay
        if replay is None or self._finished:
            return
        ticks = int(round(float(seconds) * float(self._tick_rate)))
        if ticks <= 0:
            return
        target = min(len(replay.inputs), int(self._tick_index) + int(ticks))
        world = self._world
        prev_sfx_enabled: bool | None = None
        if world is not None and world.audio_router is not None:
            prev_sfx_enabled = bool(world.audio_router.sfx_enabled)
            world.audio_router.sfx_enabled = False
        try:
            while self._tick_index < target and not self._finished:
                self._tick_one()
                # Gameplay clears decal queues during render (`draw()` -> `_bake_fx_queues`).
                # Fast-seek runs many ticks without drawing, so clear explicitly to avoid
                # queue saturation changing simulation-side corpse/death flow.
                if world is not None:
                    fx_queue = getattr(world, "fx_queue", None)
                    if fx_queue is not None:
                        fx_queue.clear()
                    fx_queue_rotated = getattr(world, "fx_queue_rotated", None)
                    if fx_queue_rotated is not None:
                        fx_queue_rotated.clear()
        finally:
            if prev_sfx_enabled is not None and world is not None and world.audio_router is not None:
                world.audio_router.sfx_enabled = bool(prev_sfx_enabled)
        # Avoid accidental overshoot from stale accumulated frame time after seek.
        self._dt_accum = 0.0

    def update(self, dt: float) -> None:
        if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
            self.close_requested = True
            return
        if rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
            self._paused = not bool(self._paused)
        if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT_BRACKET):
            self._change_speed(-1)
        if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT_BRACKET):
            self._change_speed(1)
        if rl.is_key_pressed(rl.KeyboardKey.KEY_ONE):
            self._speed_index = _DEFAULT_SPEED_INDEX
        if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT):
            self._skip_forward_seconds(_SKIP_SHORT_SECONDS)
        if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_DOWN):
            self._skip_forward_seconds(_SKIP_LONG_SECONDS)

        if not self._finished and (not self._paused):
            dt = float(dt)
            if dt < 0.0:
                dt = 0.0
            if dt > 0.1:
                dt = 0.1
            self._dt_accum += dt * self._playback_speed()

            while self._dt_accum + 1e-9 >= self._dt_frame and not self._finished:
                self._tick_one()
                self._dt_accum -= self._dt_frame

        if self._audio is not None:
            update_audio(self._audio, float(dt))

        # `GameWorld.open()` schedules terrain generation, but our playback loop
        # steps `WorldState` directly (bypassing `GameWorld.update()`), so we
        # must process pending ground work explicitly.
        world = self._world
        if world is not None and world.ground is not None:
            world.ground.process_pending()

    def draw(self) -> None:
        world = self._world
        if world is not None:
            world.draw(draw_aim_indicators=True)
        else:
            rl.clear_background(rl.BLACK)

        replay = self._replay
        if world is not None and replay is not None and self._hud_assets is not None and world.players:
            hud_flags = hud_flags_for_game_mode(int(replay.header.game_mode_id))
            quest_progress_ratio: float | None = None
            if int(replay.header.game_mode_id) == int(GameMode.QUESTS):
                total = int(self._quest_total_spawn_count)
                kills = int(world.creatures.kill_count)
                quest_progress_ratio = float(kills) / float(total) if total > 0 else None
            draw_hud_overlay(
                self._hud_assets,
                state=self._hud_state,
                player=world.players[0],
                players=world.players,
                bonus_hud=world.state.bonus_hud,
                elapsed_ms=float(
                    self._quest_spawn_timeline_ms
                    if int(replay.header.game_mode_id) == int(GameMode.QUESTS)
                    else world._elapsed_ms,
                ),
                font=self._small,
                frame_dt_ms=float(max(0.0, rl.get_frame_time()) * 1000.0),
                show_health=bool(hud_flags.show_health),
                show_weapon=bool(hud_flags.show_weapon),
                show_xp=bool(hud_flags.show_xp),
                show_time=bool(hud_flags.show_time),
                show_quest_hud=bool(hud_flags.show_quest_hud),
                quest_progress_ratio=quest_progress_ratio,
                small_indicators=False,
                preserve_bugs=bool(world.preserve_bugs),
            )

        self._draw_replay_widget()
