from __future__ import annotations

import datetime as dt
import hashlib
import random
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Protocol, cast

import pyray as rl

from grim.assets import PaqTextureCache
from grim.audio import AudioState
from grim.config import (
    CrimsonConfig,
)
from grim.console import ConsoleState
from grim.geom import Vec2
from grim.math import clamp
from grim.view import ViewContext

from ..debug import debug_enabled
from ..game_modes import GameMode
from ..gameplay import survival_check_level_up
from ..input_codes import (
    config_keybinds_for_player,
    input_code_is_down_for_player,
    input_code_is_pressed_for_player,
    input_primary_just_pressed,
)
from ..net.debug_log import lan_debug_log
from ..net.protocol import STATE_HASH_PERIOD_TICKS, PerkMenuClose, PerkMenuOpen, PerkPick, TickFrame
from ..perks.selection import perk_selection_pick
from ..perks.state import CreatureForPerks
from ..replay import ReplayHeader, ReplayRecorder, ReplayStatusSnapshot, dump_replay
from ..replay.checkpoints import (
    FORMAT_VERSION as CHECKPOINTS_FORMAT_VERSION,
)
from ..replay.checkpoints import (
    ReplayCheckpoint,
    ReplayCheckpoints,
    build_checkpoint,
    default_checkpoints_path,
    dump_checkpoints_file,
    resolve_checkpoint_sample_rate,
)
from ..replay.input_codec import pack_player_input, unpack_player_input
from ..replay.types import WEAPON_USAGE_COUNT
from ..sim.bootstrap import run_terrain_bootstrap
from ..sim.clock import FixedStepClock
from ..sim.input import PlayerInput
from ..sim.sessions import DeterministicSessionTick, SurvivalDeterministicSession
from ..ui.cursor import draw_menu_cursor
from ..ui.hud import draw_hud_overlay, hud_flags_for_game_mode
from ..ui.perk_menu import PERK_MENU_TRANSITION_MS, load_perk_menu_assets
from ..weapon_runtime import weapon_assign_player
from ..weapons import WEAPON_BY_ID
from .base_gameplay_mode import BaseGameplayMode, DeterministicSessionLike
from .components.highscore_record_builder import build_highscore_record_for_game_over
from .components.perk_menu_controller import PerkMenuContext, PerkMenuController
from .components.perk_prompt_ui import PERK_PROMPT_MAX_TIMER_MS, PerkPromptUi

WORLD_SIZE = 1024.0

UI_TEXT_SCALE = 1.0
UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
UI_HINT_COLOR = rl.Color(140, 140, 140, 255)
UI_SPONSOR_COLOR = rl.Color(255, 255, 255, int(255 * 0.5))
UI_ERROR_COLOR = rl.Color(240, 80, 80, 255)

_DEBUG_WEAPON_IDS = tuple(sorted(WEAPON_BY_ID))


@dataclass(slots=True)
class _SurvivalState:
    elapsed_ms: float = 0.0
    stage: int = 0
    spawn_cooldown: float = 0.0


class SurvivalSessionLike(DeterministicSessionLike, Protocol):
    elapsed_ms: float
    stage: int
    spawn_cooldown_ms: float


class SurvivalMode(BaseGameplayMode):
    def __init__(
        self,
        ctx: ViewContext,
        *,
        texture_cache: PaqTextureCache | None = None,
        config: CrimsonConfig | None = None,
        console: ConsoleState | None = None,
        audio: AudioState | None = None,
        audio_rng: random.Random | None = None,
    ) -> None:
        super().__init__(
            ctx,
            world_size=WORLD_SIZE,
            default_game_mode_id=int(GameMode.SURVIVAL),
            demo_mode_active=False,
            difficulty_level=0,
            hardcore=False,
            texture_cache=texture_cache,
            config=config,
            console=console,
            audio=audio,
            audio_rng=audio_rng,
        )
        self._survival = _SurvivalState()

        self._perk_prompt_timer_ms = 0.0
        self._perk_prompt_hover = False
        self._perk_prompt_pulse = 0.0
        self._perk_menu = PerkMenuController(on_close=self._reset_perk_prompt, on_pick=self._record_perk_pick)
        self._hud_fade_ms = PERK_MENU_TRANSITION_MS
        self._perk_menu_assets = None
        self._cursor_time = 0.0
        self._sim_clock = FixedStepClock(tick_rate=60)
        self._lan_capture_clock = FixedStepClock(tick_rate=60)
        self._replay_recorder: ReplayRecorder | None = None
        self._replay_checkpoints: list[ReplayCheckpoint] = []
        self._replay_checkpoints_sample_rate: int = 60
        self._replay_checkpoints_last_tick: int | None = None
        self._sim_session: SurvivalSessionLike | None = None
        self._lan_last_tick_index: int = -1
        self._lan_perk_events: list[PerkMenuOpen | PerkMenuClose | PerkPick] = []
        self._lan_perk_close_suppress: bool = False

    def _reset_perk_prompt(self) -> None:
        if bool(self._lan_enabled) and str(self._lan_role) == "host":
            runtime = self._lan_runtime
            tick_index = max(0, int(self._lan_last_tick_index))
            if bool(self._lan_perk_close_suppress):
                self._lan_perk_close_suppress = False
            elif runtime is not None:
                runtime.broadcast_perk_menu_close(tick_index=int(tick_index), player_index=0)

        if int(self.state.perk_selection.pending_count) > 0:
            # Reset the prompt swing so each pending perk replays the intro.
            self._perk_prompt_timer_ms = 0.0
            self._perk_prompt_hover = False
            self._perk_prompt_pulse = 0.0

    def _record_perk_pick(self, choice_index: int) -> None:
        if bool(self._lan_enabled) and str(self._lan_role) == "host":
            runtime = self._lan_runtime
            if runtime is not None:
                tick_index = max(0, int(self._lan_last_tick_index))
                self._lan_perk_close_suppress = True
                runtime.broadcast_perk_pick(
                    tick_index=int(tick_index),
                    player_index=0,
                    choice_index=int(choice_index),
                )

        recorder = self._replay_recorder
        if recorder is not None:
            recorder.record_perk_pick(player_index=0, choice_index=int(choice_index))

    def _record_replay_checkpoint(
        self,
        tick_index: int,
        *,
        force: bool = False,
        rng_marks: dict[str, int] | None = None,
        deaths: Sequence[object] | None = None,
        events: object | None = None,
        command_hash: str | None = None,
    ) -> None:
        recorder = self._replay_recorder
        if recorder is None:
            return
        if tick_index < 0:
            return
        if not force and (tick_index % int(self._replay_checkpoints_sample_rate or 1)) != 0:
            return
        if self._replay_checkpoints_last_tick == int(tick_index):
            return
        self._replay_checkpoints.append(
            build_checkpoint(
                tick_index=int(tick_index),
                world=self.world.world_state,
                elapsed_ms=float(self._survival.elapsed_ms),
                rng_marks=rng_marks,
                deaths=deaths,
                events=events,
                command_hash=command_hash,
            ),
        )
        self._replay_checkpoints_last_tick = int(tick_index)

    def _save_replay(self) -> None:
        recorder = self._replay_recorder
        if recorder is None:
            return
        replay = recorder.finish()
        self._record_replay_checkpoint(max(0, recorder.tick_index - 1), force=True)
        terminal_tick = int(recorder.tick_index)
        if any(int(event.tick_index) == terminal_tick for event in replay.events):
            self._record_replay_checkpoint(terminal_tick, force=True)
        data = dump_replay(replay)
        digest = hashlib.sha256(data).hexdigest()
        stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
        replay_dir = self._base_dir / "replays"
        replay_dir.mkdir(parents=True, exist_ok=True)
        score = int(self.player.experience)
        base_name = f"survival_{stamp}_score{score}"
        path = replay_dir / f"{base_name}.crd"
        counter = 1
        while path.exists():
            path = replay_dir / f"{base_name}_{counter}.crd"
            counter += 1
        path.write_bytes(data)
        checkpoints_path = default_checkpoints_path(path)
        dump_checkpoints_file(
            checkpoints_path,
            ReplayCheckpoints(
                version=CHECKPOINTS_FORMAT_VERSION,
                replay_sha256=digest,
                sample_rate=int(self._replay_checkpoints_sample_rate or 0),
                checkpoints=list(self._replay_checkpoints),
            ),
        )
        self._replay_recorder = None
        self._replay_checkpoints.clear()
        self._replay_checkpoints_last_tick = None
        if self._console is not None:
            self._console.log.log(f"replay: saved {path}")
            self._console.log.log(f"replay: saved {checkpoints_path}")
            self._console.log.flush()

    def _perk_menu_context(self) -> PerkMenuContext:
        fx_toggle = self.config.fx_toggle
        fx_detail = self.config.fx_detail(level=0, default=False)
        players = self.world.players
        return PerkMenuContext(
            state=self.state,
            perk_state=self.state.perk_selection,
            players=players,
            creatures=cast("list[CreatureForPerks]", self.creatures.entries),
            player=self.player,
            game_mode=int(GameMode.SURVIVAL),
            player_count=len(players),
            fx_toggle=fx_toggle,
            fx_detail=fx_detail,
            font=self._small,
            assets=self._perk_menu_assets,
            mouse=self._ui_mouse_pos(),
            play_sfx=self.world.audio_router.play_sfx,
        )

    def _wrap_ui_text(self, text: str, *, max_width: float, scale: float = UI_TEXT_SCALE) -> list[str]:
        lines: list[str] = []
        for raw in text.splitlines() or [""]:
            para = raw.strip()
            if not para:
                lines.append("")
                continue
            current = ""
            for word in para.split():
                candidate = word if not current else f"{current} {word}"
                if current and self._ui_text_width(candidate, scale) > max_width:
                    lines.append(current)
                    current = word
                else:
                    current = candidate
            if current:
                lines.append(current)
        return lines

    def _camera_world_to_screen(self, pos: Vec2) -> Vec2:
        return self.world.world_to_screen(pos)

    def _camera_screen_to_world(self, pos: Vec2) -> Vec2:
        return self.world.screen_to_world(pos)

    def open(self) -> None:
        super().open()

        self._perk_menu_assets = load_perk_menu_assets(self._assets_root)
        self._perk_menu.reset()
        self._cursor_time = 0.0
        self._cursor_pulse_time = 0.0
        self._sim_clock.reset()
        self._lan_capture_clock.reset()
        self._survival = _SurvivalState()
        self._lan_last_tick_index = -1
        self._lan_perk_events.clear()
        self._lan_perk_close_suppress = False

        status = self.state.status
        base_status = self.save_status
        sim_unlock_index = int(status.quest_unlock_index) if status is not None else 0
        sim_unlock_index_full = int(status.quest_unlock_index_full) if status is not None else 0
        status_unlock_index = int(base_status.quest_unlock_index) if base_status is not None else int(sim_unlock_index)
        status_unlock_index_full = (
            int(base_status.quest_unlock_index_full)
            if base_status is not None
            else int(sim_unlock_index_full)
        )
        quest_unlock_index = int(sim_unlock_index)
        bootstrap = run_terrain_bootstrap(
            self.state.rng,
            quest_unlock_index=int(quest_unlock_index),
            width=int(self.world.world_size),
            height=int(self.world.world_size),
            layers=3,
        )
        lan_debug_log(
            "terrain_bootstrap",
            mode="SurvivalMode",
            lan_enabled=bool(self._lan_enabled),
            lan_role=str(self._lan_role),
            status_quest_unlock_index=int(status_unlock_index),
            status_quest_unlock_index_full=int(status_unlock_index_full),
            sim_quest_unlock_index=int(sim_unlock_index),
            sim_quest_unlock_index_full=int(sim_unlock_index_full),
            quest_unlock_index=int(quest_unlock_index),
            seed_before=int(bootstrap.seed_before),
            seed_after=int(bootstrap.seed_after),
            selection_draws=int(bootstrap.selection_draws),
            stamping_draws=int(bootstrap.stamping_draws),
            terrain_base=int(bootstrap.terrain_ids[0]),
            terrain_overlay=int(bootstrap.terrain_ids[1]),
            terrain_detail=int(bootstrap.terrain_ids[2]),
            terrain_seed=int(bootstrap.terrain_seed),
        )
        self.world.apply_bootstrap_terrain(terrain_ids=bootstrap.terrain_ids, seed=bootstrap.terrain_seed, layers=3)

        self._sim_session = SurvivalDeterministicSession(
            world=self.world.world_state,
            world_size=float(self.world.world_size),
            damage_scale_by_type=self.world._damage_scale_by_type,
            fx_queue=self.world.fx_queue,
            fx_queue_rotated=self.world.fx_queue_rotated,
            detail_preset=5,
            fx_toggle=0,
            game_tune_started=bool(self.world._game_tune_started),
            clear_fx_queues_each_tick=False,
        )

        self._perk_prompt_timer_ms = 0.0
        self._perk_prompt_hover = False
        self._perk_prompt_pulse = 0.0
        self._hud_fade_ms = PERK_MENU_TRANSITION_MS
        weapon_usage_counts: tuple[int, ...] = ()
        if status is not None:
            raw_counts = status.data.get("weapon_usage_counts")
            if isinstance(raw_counts, list):
                coerced: list[int] = []
                for value in raw_counts[:WEAPON_USAGE_COUNT]:
                    try:
                        coerced.append(int(value) & 0xFFFFFFFF)
                    except (TypeError, ValueError, OverflowError):
                        coerced.append(0)
                weapon_usage_counts = tuple(coerced)
        if len(weapon_usage_counts) != WEAPON_USAGE_COUNT:
            weapon_usage_counts = tuple(weapon_usage_counts) + (0,) * max(
                0, WEAPON_USAGE_COUNT - len(weapon_usage_counts),
            )
            weapon_usage_counts = weapon_usage_counts[:WEAPON_USAGE_COUNT]
        status_snapshot = ReplayStatusSnapshot(
            quest_unlock_index=int(status.quest_unlock_index) if status is not None else 0,
            quest_unlock_index_full=int(status.quest_unlock_index_full)
            if status is not None
            else 0,
            weapon_usage_counts=weapon_usage_counts,
        )
        record_replay = (not bool(self._lan_enabled)) or str(self._lan_role) == "host"
        if record_replay:
            self._replay_recorder = ReplayRecorder(
                ReplayHeader(
                    game_mode_id=int(GameMode.SURVIVAL),
                    seed=int(self.state.rng.state),
                    bootstrap_kind="terrain_v1",
                    bootstrap_seed=int(self._bootstrap_seed),
                    tick_rate=int(self._sim_clock.tick_rate),
                    difficulty_level=int(self.world.difficulty_level),
                    hardcore=bool(self.world.hardcore),
                    preserve_bugs=bool(self.world.preserve_bugs),
                    detail_preset=int(self._deterministic_detail_preset()),
                    fx_toggle=int(self._deterministic_fx_toggle()),
                    world_size=float(self.world.world_size),
                    player_count=len(self.world.players),
                    status=status_snapshot,
                ),
            )
            tick_rate = int(self._replay_recorder.header.tick_rate)
            self._replay_checkpoints_sample_rate = resolve_checkpoint_sample_rate(tick_rate)
        else:
            self._replay_recorder = None
        self._replay_checkpoints.clear()
        self._replay_checkpoints_last_tick = None

    def close(self) -> None:
        if self._perk_menu_assets is not None:
            self._perk_menu_assets = None
        self._replay_recorder = None
        self._replay_checkpoints.clear()
        self._replay_checkpoints_last_tick = None
        self._sim_session = None
        self._lan_last_tick_index = -1
        self._lan_perk_events.clear()
        self._lan_perk_close_suppress = False
        super().close()

    def _handle_input(self) -> None:
        if self._game_over_active:
            if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
                self._action = "back_to_menu"
                self.close_requested = True
            return
        if self._perk_menu.open and rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
            if bool(self._lan_enabled) and str(self._lan_role) == "join":
                return
            self.world.audio_router.play_sfx("sfx_ui_buttonclick")
            self._perk_menu.close()
            return

        if (not bool(self._lan_enabled)) and rl.is_key_pressed(rl.KeyboardKey.KEY_TAB):
            self._paused = not self._paused

        if debug_enabled() and (not self._perk_menu.open):
            if rl.is_key_pressed(rl.KeyboardKey.KEY_F2):
                self.state.debug_god_mode = not bool(self.state.debug_god_mode)
                self.world.audio_router.play_sfx("sfx_ui_buttonclick")
            if rl.is_key_pressed(rl.KeyboardKey.KEY_F3):
                self.state.perk_selection.pending_count += 1
                self.state.perk_selection.choices_dirty = True
                self.world.audio_router.play_sfx("sfx_ui_levelup")
            if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT_BRACKET):
                self._debug_cycle_weapon(-1)
            if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT_BRACKET):
                self._debug_cycle_weapon(1)
            if rl.is_key_pressed(rl.KeyboardKey.KEY_X):
                self.player.experience += 5000
                survival_check_level_up(self.player, self.state.perk_selection)

        if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
            self._action = "open_pause_menu"
            return

    def _debug_cycle_weapon(self, delta: int) -> None:
        weapon_ids = _DEBUG_WEAPON_IDS
        if not weapon_ids:
            return
        current = int(self.player.weapon_id)
        try:
            idx = weapon_ids.index(current)
        except ValueError:
            idx = 0
        weapon_id = int(weapon_ids[(idx + int(delta)) % len(weapon_ids)])
        weapon_assign_player(self.player, weapon_id, state=self.state)

    def _death_transition_ready(self) -> bool:
        dead_players = 0
        for player in self.world.players:
            if float(player.health) > 0.0:
                return False
            dead_players += 1
            if float(player.death_timer) >= 0.0:
                return False
        return dead_players > 0

    def _enter_game_over(self) -> None:
        if self._game_over_active:
            return
        game_mode_id = self.config.game_mode
        record = build_highscore_record_for_game_over(
            state=self.state,
            player=self.player,
            survival_elapsed_ms=int(self._survival.elapsed_ms),
            creature_kill_count=int(self.creatures.kill_count),
            game_mode_id=game_mode_id,
        )
        self._game_over_record = record
        self._game_over_ui.open()
        self._game_over_active = True
        self._perk_menu.close()
        self._save_replay()

    def update(self, dt: float) -> None:
        self._update_audio(dt)

        dt_frame, dt_ui_ms = self._tick_frame(dt)
        self._cursor_time += dt_frame
        self._handle_input()
        if self._action == "open_pause_menu":
            return

        if self._game_over_active:
            self._update_game_over_ui(dt)
            return

        if bool(self._lan_enabled) and self._lan_runtime is not None:
            self._update_lan_match(dt_frame=dt_frame, dt_ui_ms=dt_ui_ms)
            return

        any_alive = any(player.health > 0.0 for player in self.world.players)
        perk_pending = int(self.state.perk_selection.pending_count) > 0 and any_alive

        self._perk_prompt_hover = False
        perk_ctx = self._perk_menu_context()
        if self._perk_menu.open:
            self._perk_menu.handle_input(perk_ctx, dt_frame=dt_frame, dt_ui_ms=dt_ui_ms)

        perk_menu_active = self._perk_menu.active
        any_alive = self._any_player_alive()

        if (not perk_menu_active) and perk_pending and (not self._paused):
            label = PerkPromptUi.label(self.config, pending_count=int(self.state.perk_selection.pending_count))
            if label:
                rect = PerkPromptUi.rect(
                    label,
                    ui_text_width=self._ui_text_width,
                    ui_line_height=self._ui_line_height,
                    assets=self._perk_menu_assets,
                    scale=UI_TEXT_SCALE,
                )
                mouse = self._ui_mouse_pos()
                self._perk_prompt_hover = rect.contains(mouse)

            player0_binds = config_keybinds_for_player(self.config, player_index=0)
            fire_key = 0x100
            if len(player0_binds) >= 5:
                fire_key = int(player0_binds[4])

            pick_key = self.config.keybind_pick_perk

            if input_code_is_pressed_for_player(pick_key, player_index=0) and (
                not input_code_is_down_for_player(fire_key, player_index=0)
            ):
                self._perk_prompt_pulse = 1000.0
                if self._replay_recorder is not None:
                    self._record_replay_checkpoint(max(0, self._replay_recorder.tick_index - 1), force=True)
                opened = self._perk_menu.open_if_available(perk_ctx)
                if opened and self._replay_recorder is not None:
                    self._replay_recorder.record_perk_menu_open(player_index=0)
            elif self._perk_prompt_hover and input_primary_just_pressed(
                self.config,
                player_count=len(self.world.players),
            ):
                self._perk_prompt_pulse = 1000.0
                if self._replay_recorder is not None:
                    self._record_replay_checkpoint(max(0, self._replay_recorder.tick_index - 1), force=True)
                opened = self._perk_menu.open_if_available(perk_ctx)
                if opened and self._replay_recorder is not None:
                    self._replay_recorder.record_perk_menu_open(player_index=0)

        if not self._paused and not self._game_over_active:
            pulse_delta = dt_ui_ms * (6.0 if self._perk_prompt_hover else -2.0)
            self._perk_prompt_pulse = clamp(self._perk_prompt_pulse + pulse_delta, 0.0, 1000.0)

        sim_active = (not self._paused) and (not perk_menu_active)

        prompt_active = perk_pending and (not perk_menu_active) and (not self._paused)
        if prompt_active:
            self._perk_prompt_timer_ms = clamp(self._perk_prompt_timer_ms + dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)
        else:
            self._perk_prompt_timer_ms = clamp(self._perk_prompt_timer_ms - dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)

        self._perk_menu.tick_timeline(dt_ui_ms)
        if self._perk_menu.active:
            self._hud_fade_ms = 0.0
        else:
            self._hud_fade_ms = clamp(self._hud_fade_ms + dt_ui_ms, 0.0, PERK_MENU_TRANSITION_MS)

        self._update_lan_wait_gate_debug_override()
        if self._lan_wait_gate_active():
            self._sim_clock.reset()
            return

        if not sim_active:
            self._sim_clock.reset()
            if self._death_transition_ready():
                self._enter_game_over()
            return

        ticks_to_run = self._sim_clock.advance(dt_frame)
        if ticks_to_run <= 0:
            return

        dt_tick = float(self._sim_clock.dt_tick)
        input_frame = self._build_local_inputs(dt_frame=dt_frame)
        session = self._sim_session
        if session is None:
            return

        def _on_tick(tick: DeterministicSessionTick, tick_index: int | None) -> bool:
            self._survival.elapsed_ms = float(session.elapsed_ms)
            self._survival.stage = int(session.stage)
            self._survival.spawn_cooldown = float(session.spawn_cooldown_ms)
            world_events = tick.step.events

            if tick_index is not None:
                self._record_replay_checkpoint(
                    int(tick_index),
                    rng_marks=tick.rng_marks,
                    deaths=world_events.deaths,
                    events=world_events,
                    command_hash=str(tick.step.command_hash),
                )

            if self._death_transition_ready():
                self._enter_game_over()
                return True
            return False

        self._run_deterministic_session_ticks(
            ticks_to_run=int(ticks_to_run),
            dt_tick=dt_tick,
            input_frame=input_frame,
            session=session,
            recorder=self._replay_recorder,
            on_tick=_on_tick,
        )

    def _update_lan_match(self, *, dt_frame: float, dt_ui_ms: float) -> None:
        runtime = self._lan_runtime
        if runtime is None:
            return
        session = self._sim_session
        if session is None:
            return

        runtime.update()
        role = str(self._lan_role)
        self._consume_net_runtime_recovery(mode_name="survival")
        if str(runtime.error or ""):
            self.close_requested = True
            return

        if self.world.audio_router is not None:
            self.world.audio_router.audio = self.world.audio
            self.world.audio_router.audio_rng = self.world.audio_rng
            self.world.audio_router.demo_mode_active = self.world.demo_mode_active
        if self.world.ground is not None:
            self.world._sync_ground_settings()
            self.world.ground.process_pending()
        self._trace_lan_terrain_generation()
        if bool(self._lan_terrain_generation_pending()):
            self._lan_capture_clock.reset()
            return

        if role == "host" and (not bool(runtime.host_remote_inputs_ready())):
            return

        if bool(self._paused):
            self._sim_clock.reset()
            if self._death_transition_ready():
                self._enter_game_over()
            return

        session.detail_preset = int(self._deterministic_detail_preset())
        session.fx_toggle = int(self._deterministic_fx_toggle())

        dt_tick = float(self._lan_capture_clock.dt_tick)

        # Drain and apply host-authored perk events (clients only).
        if role == "join":
            while True:
                perk_event = runtime.pop_perk_event()
                if perk_event is None:
                    break
                self._lan_perk_events.append(perk_event)

        perk_ctx = self._perk_menu_context()

        def _perk_event_sort_key(ev: PerkMenuOpen | PerkMenuClose | PerkPick) -> tuple[int, int]:
            tick_index = int(ev.tick_index)
            order = 2
            if isinstance(ev, PerkMenuOpen):
                order = 0
            elif isinstance(ev, PerkMenuClose):
                order = 1
            return (tick_index, order)

        def _apply_due_perk_events() -> None:
            if role != "join":
                return
            if not self._lan_perk_events:
                return
            self._lan_perk_events.sort(key=_perk_event_sort_key)
            remaining: list[PerkMenuOpen | PerkMenuClose | PerkPick] = []
            for event in self._lan_perk_events:
                event_tick = int(event.tick_index)
                if event_tick < 0:
                    continue
                if event_tick > int(self._lan_last_tick_index):
                    remaining.append(event)
                    continue
                player_index = int(event.player_index)
                if int(player_index) != 0:
                    continue
                if isinstance(event, PerkMenuOpen):
                    opened = self._perk_menu.open_if_available(perk_ctx)
                    if not opened:
                        lan_debug_log(
                            "lan_sanity_mismatch",
                            role="join",
                            kind="perk_menu_open",
                            tick_index=int(event_tick),
                            pending_count=int(self.state.perk_selection.pending_count),
                        )
                    continue
                if isinstance(event, PerkMenuClose):
                    self._perk_menu.close()
                    continue
                if isinstance(event, PerkPick):
                    choice_index = int(event.choice_index)
                    picked = perk_selection_pick(
                        perk_ctx.state,
                        perk_ctx.players,
                        perk_ctx.perk_state,
                        int(choice_index),
                        game_mode=int(GameMode.SURVIVAL),
                        player_count=int(perk_ctx.player_count),
                        dt=float(dt_tick),
                        creatures=perk_ctx.creatures,
                    )
                    if picked is None:
                        lan_debug_log(
                            "lan_sanity_mismatch",
                            role="join",
                            kind="perk_pick",
                            tick_index=int(event_tick),
                            pending_count=int(self.state.perk_selection.pending_count),
                            choice_index=int(choice_index),
                        )
                    elif self.world.audio_router is not None:
                        self.world.audio_router.play_sfx("sfx_ui_bonus")
                    self._perk_menu.close()
            self._lan_perk_events = remaining

        _apply_due_perk_events()

        any_alive = self._any_player_alive()
        perk_pending = int(self.state.perk_selection.pending_count) > 0 and any_alive

        self._perk_prompt_hover = False
        if self._perk_menu.open and role == "host":
            # Keep perk application dt consistent across peers.
            self._perk_menu.handle_input(perk_ctx, dt_frame=float(dt_tick), dt_ui_ms=dt_ui_ms)

        perk_menu_active = self._perk_menu.active
        if role == "host" and (not perk_menu_active) and perk_pending and (not self._paused):
            label = PerkPromptUi.label(self.config, pending_count=int(self.state.perk_selection.pending_count))
            if label:
                rect = PerkPromptUi.rect(
                    label,
                    ui_text_width=self._ui_text_width,
                    ui_line_height=self._ui_line_height,
                    assets=self._perk_menu_assets,
                    scale=UI_TEXT_SCALE,
                )
                mouse = self._ui_mouse_pos()
                self._perk_prompt_hover = rect.contains(mouse)

            player0_binds = config_keybinds_for_player(self.config, player_index=0)
            fire_key = 0x100
            if len(player0_binds) >= 5:
                fire_key = int(player0_binds[4])

            pick_key = self.config.keybind_pick_perk

            def _try_open_perk_menu() -> None:
                self._perk_prompt_pulse = 1000.0
                recorder = self._replay_recorder
                if recorder is not None:
                    self._record_replay_checkpoint(max(0, recorder.tick_index - 1), force=True)
                opened = self._perk_menu.open_if_available(perk_ctx)
                if not opened:
                    return
                tick_index = max(0, int(self._lan_last_tick_index))
                runtime.broadcast_perk_menu_open(tick_index=int(tick_index), player_index=0)
                if recorder is not None:
                    recorder.record_perk_menu_open(player_index=0)

            if input_code_is_pressed_for_player(pick_key, player_index=0) and (
                not input_code_is_down_for_player(fire_key, player_index=0)
            ):
                _try_open_perk_menu()
            elif self._perk_prompt_hover and input_primary_just_pressed(
                self.config,
                player_count=len(self.world.players),
            ):
                _try_open_perk_menu()

        if not self._paused and not self._game_over_active:
            pulse_delta = dt_ui_ms * (6.0 if self._perk_prompt_hover else -2.0)
            self._perk_prompt_pulse = clamp(self._perk_prompt_pulse + pulse_delta, 0.0, 1000.0)

        perk_menu_active = self._perk_menu.active
        prompt_active = perk_pending and (not perk_menu_active) and (not self._paused)
        if prompt_active:
            self._perk_prompt_timer_ms = clamp(self._perk_prompt_timer_ms + dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)
        else:
            self._perk_prompt_timer_ms = clamp(self._perk_prompt_timer_ms - dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)

        self._perk_menu.tick_timeline(dt_ui_ms)
        if self._perk_menu.active:
            self._hud_fade_ms = 0.0
        else:
            self._hud_fade_ms = clamp(self._hud_fade_ms + dt_ui_ms, 0.0, PERK_MENU_TRANSITION_MS)

        if self._perk_menu.active:
            self._lan_capture_clock.reset()
            if self._death_transition_ready():
                self._enter_game_over()
            return

        def _consume_lan_frames() -> bool:
            while True:
                if self._perk_menu.active:
                    return False
                frame = runtime.pop_tick_frame()
                if frame is None:
                    return False

                packed_inputs = list(frame.frame_inputs)
                player_inputs = [unpack_player_input(packed) for packed in packed_inputs]
                recorder = self._replay_recorder
                if recorder is not None:
                    tick_index = recorder.record_tick(player_inputs)
                else:
                    tick_index = None

                tick = session.step_tick(
                    dt_frame=float(dt_tick),
                    inputs=player_inputs,
                )

                remote_command_hash = str(frame.command_hash or "")
                remote_state_hash = str(frame.state_hash or "")
                local_command_hash = str(tick.step.command_hash)
                tick_elapsed_ms = float(session.elapsed_ms)
                local_state_hash = ""
                if role == "join":
                    if remote_command_hash and remote_command_hash != local_command_hash:
                        runtime.note_desync(
                            kind="command_hash",
                            tick_index=int(frame.tick_index),
                            expected=str(remote_command_hash),
                            actual=str(local_command_hash),
                        )
                    if remote_state_hash:
                        local_state_hash = str(
                            build_checkpoint(
                                tick_index=int(frame.tick_index),
                                world=self.world.world_state,
                                elapsed_ms=float(tick_elapsed_ms),
                                creature_count_override=int(tick.creature_count_world_step),
                            ).state_hash,
                        )
                        if local_state_hash != remote_state_hash:
                            runtime.note_desync(
                                kind="state_hash",
                                tick_index=int(frame.tick_index),
                                expected=str(remote_state_hash),
                                actual=str(local_state_hash),
                            )

                state_hash = ""
                if role == "host":
                    tick_i = int(frame.tick_index)
                    if int(tick_i) < 5 or (int(tick_i) % int(STATE_HASH_PERIOD_TICKS)) == 0:
                        state_hash = str(
                            build_checkpoint(
                                tick_index=int(frame.tick_index),
                                world=self.world.world_state,
                                elapsed_ms=float(tick_elapsed_ms),
                                creature_count_override=int(tick.creature_count_world_step),
                            ).state_hash,
                        )
                self.world.apply_step_result(
                    tick.step,
                    game_tune_started=bool(session.game_tune_started),
                    apply_audio=True,
                    update_camera=True,
                )
                session_elapsed_ms = float(session.elapsed_ms)
                session_stage = int(session.stage)
                session_spawn_cooldown_ms = float(session.spawn_cooldown_ms)
                self._survival.elapsed_ms = session_elapsed_ms
                self._survival.stage = session_stage
                self._survival.spawn_cooldown = session_spawn_cooldown_ms
                world_events = tick.step.events

                self._lan_last_tick_index = int(frame.tick_index)
                self._store_net_runtime_snapshot(
                    mode_name="survival",
                    tick_index=int(frame.tick_index),
                    session_state={
                        "elapsed_ms": float(session_elapsed_ms),
                        "stage": int(session_stage),
                        "spawn_cooldown_ms": float(session_spawn_cooldown_ms),
                    },
                    mode_state={
                        "survival_elapsed_ms": float(self._survival.elapsed_ms),
                        "survival_stage": int(self._survival.stage),
                        "survival_spawn_cooldown": float(self._survival.spawn_cooldown),
                        "perk_pending_count": int(self.state.perk_selection.pending_count),
                    },
                )
                _apply_due_perk_events()
                if self._perk_menu.active:
                    return False

                if tick_index is not None:
                    self._record_replay_checkpoint(
                        int(tick_index),
                        rng_marks=tick.rng_marks,
                        deaths=world_events.deaths,
                        events=world_events,
                        command_hash=str(tick.step.command_hash),
                    )

                if role == "host":
                    runtime.broadcast_tick_frame(
                        TickFrame(
                            tick_index=int(frame.tick_index),
                            frame_inputs=list(frame.frame_inputs),
                            command_hash=str(local_command_hash),
                            state_hash=str(state_hash),
                        ),
                    )

                if self._death_transition_ready():
                    self._enter_game_over()
                    return True

        if role == "join":
            if _consume_lan_frames():
                return
            if self._perk_menu.active:
                self._lan_capture_clock.reset()
                return

        ticks_to_capture = self._lan_capture_clock.advance(dt_frame)
        if ticks_to_capture > 0:
            input_frame = self._build_local_inputs(dt_frame=dt_frame)
            # In LAN sessions each peer is a single local player, so always sample
            # inputs using the configured Player 1 bindings (index 0). The network
            # slot mapping is handled by the lockstep runtime.
            local_input_index = 0
            for tick_offset in range(int(ticks_to_capture)):
                inputs = input_frame if tick_offset == 0 else self._clear_local_input_edges(input_frame)
                local_input = PlayerInput()
                if 0 <= local_input_index < len(inputs):
                    local_input = inputs[local_input_index]
                runtime.queue_local_input(pack_player_input(local_input))
        # Pump networking again after queuing local inputs so the host can emit frames
        # in the same render frame (reduces perceived host-side input latency).
        runtime.update()

        if role == "join":
            while True:
                perk_event = runtime.pop_perk_event()
                if perk_event is None:
                    break
                self._lan_perk_events.append(perk_event)
        _apply_due_perk_events()
        if self._perk_menu.active:
            self._lan_capture_clock.reset()
            return

        _consume_lan_frames()

    def _draw_perk_prompt(self) -> None:
        if self._game_over_active:
            return
        if self._perk_menu.active:
            return
        if not any(player.health > 0.0 for player in self.world.players):
            return
        pending_count = int(self.state.perk_selection.pending_count)
        if pending_count <= 0:
            return
        label = PerkPromptUi.label(self.config, pending_count=pending_count)
        if not label:
            return
        PerkPromptUi.draw(
            font=self._small,
            assets=self._perk_menu_assets,
            label=label,
            timer_ms=float(self._perk_prompt_timer_ms),
            pulse=float(self._perk_prompt_pulse),
            ui_text_width=self._ui_text_width,
            text_color=UI_TEXT_COLOR,
            scale=UI_TEXT_SCALE,
        )

    def _draw_game_cursor(self) -> None:
        mouse_pos = self._ui_mouse
        cursor_tex = self._perk_menu_assets.cursor if self._perk_menu_assets is not None else None
        draw_menu_cursor(
            self.world.particles_texture,
            cursor_tex,
            pos=mouse_pos,
            pulse_time=float(self._cursor_pulse_time),
        )

    def draw(self) -> None:
        perk_menu_active = self._perk_menu.active
        self.world.draw(
            draw_aim_indicators=(not self._game_over_active) and (not perk_menu_active),
            entity_alpha=self._world_entity_alpha(),
        )
        self._draw_screen_fade()

        hud_bottom = 0.0
        if (not self._game_over_active) and (not perk_menu_active) and self._hud_assets is not None:
            hud_alpha = clamp(self._hud_fade_ms / PERK_MENU_TRANSITION_MS, 0.0, 1.0)
            hud_flags = hud_flags_for_game_mode(self._config_game_mode_id())
            self._draw_target_health_bar(alpha=hud_alpha)
            hud_bottom = draw_hud_overlay(
                self._hud_assets,
                state=self._hud_state,
                player=self.player,
                players=self.world.players,
                bonus_hud=self.state.bonus_hud,
                elapsed_ms=self._survival.elapsed_ms,
                score=self.player.experience,
                font=self._small,
                alpha=hud_alpha,
                frame_dt_ms=self._last_dt_ms,
                show_health=hud_flags.show_health,
                show_weapon=hud_flags.show_weapon,
                show_xp=hud_flags.show_xp,
                show_time=hud_flags.show_time,
                show_quest_hud=hud_flags.show_quest_hud,
                small_indicators=self._hud_small_indicators(),
                preserve_bugs=bool(self.world.preserve_bugs),
            )

        if debug_enabled() and (not self._game_over_active) and (not perk_menu_active):
            # Minimal debug text.
            x = 18.0
            y = max(18.0, hud_bottom + 10.0)
            line = float(self._ui_line_height())
            self._draw_ui_text(
                f"survival: t={self._survival.elapsed_ms / 1000.0:6.1f}s  stage={self._survival.stage}",
                Vec2(x, y),
                UI_TEXT_COLOR,
            )
            self._draw_ui_text(
                f"xp={self.player.experience}  level={self.player.level}  kills={self.creatures.kill_count}",
                Vec2(x, y + line),
                UI_HINT_COLOR,
            )
            god = "on" if self.state.debug_god_mode else "off"
            self._draw_ui_text(
                f"debug: [/] weapon  F3 perk+1  F2 god={god}  X xp+5000",
                Vec2(x, y + line * 2.0),
                UI_HINT_COLOR,
                scale=0.9,
            )
            y_extra = y + line * 3.0
            if self._paused:
                self._draw_ui_text("paused (TAB)", Vec2(x, y_extra), UI_HINT_COLOR)
                y_extra += line
            if self.player.health <= 0.0:
                self._draw_ui_text("game over", Vec2(x, y_extra), UI_ERROR_COLOR)
                y_extra += line
            self._draw_lan_debug_info(x=x, y=y_extra, line_h=line)
        self._draw_perk_prompt()
        if not self._game_over_active:
            self._perk_menu.draw(self._perk_menu_context())
        if (not self._game_over_active) and perk_menu_active:
            self._draw_game_cursor()

        if self._game_over_active and self._game_over_record is not None:
            self._game_over_ui.draw(
                record=self._game_over_record,
                banner_kind=self._game_over_banner,
                hud_assets=self._hud_assets,
                mouse=self._ui_mouse_pos(),
            )
        self._draw_lan_wait_overlay()
