from __future__ import annotations

import math
from typing import Protocol

import msgspec

from grim.assets import RuntimeResources, TextureId
from grim.color import RGBA
from grim.fonts.small import SmallFontData, draw_small_text
from grim.geom import Vec2
from grim.raylib_api import rl

from ..bonuses.hud import BonusHudState
from ..game_modes import GameMode
from ..gameplay import survival_level_threshold
from ..sim.state_types import PlayerState
from ..weapons import WEAPON_BY_ID, WeaponId, weapon_display_name

HUD_TEXT_COLOR = rl.Color(220, 220, 220, 255)
HUD_HINT_COLOR = rl.Color(170, 170, 180, 255)
HUD_ACCENT_COLOR = rl.Color(240, 200, 80, 255)

HUD_BASE_WIDTH = 1024.0
HUD_BASE_HEIGHT = 768.0

HUD_TOP_BAR_ALPHA = 0.7
HUD_ICON_ALPHA = 0.8
HUD_PANEL_ALPHA = 0.9
HUD_HEALTH_BG_ALPHA = 0.5
HUD_AMMO_DIM_ALPHA = 0.3

HUD_TOP_BAR_POS = (0.0, 0.0)
HUD_TOP_BAR_SIZE = (512.0, 64.0)
HUD_HEART_CENTER = (27.0, 21.0)
HUD_HEALTH_BAR_POS = (64.0, 16.0)
HUD_HEALTH_BAR_SIZE = (120.0, 9.0)
HUD_WEAPON_ICON_POS = (220.0, 2.0)
HUD_WEAPON_ICON_SIZE = (64.0, 32.0)
HUD_CLOCK_POS = (220.0, 2.0)
HUD_CLOCK_SIZE = (32.0, 32.0)
HUD_CLOCK_ALPHA = 0.9
HUD_AMMO_BASE_POS = (300.0, 10.0)
HUD_AMMO_BAR_SIZE = (6.0, 16.0)
HUD_AMMO_BAR_STEP = 6.0
HUD_AMMO_BAR_LIMIT = 30
HUD_AMMO_BAR_CLAMP = 20
HUD_AMMO_TEXT_OFFSET = (8.0, 1.0)
HUD_SURV_PANEL_POS = (-68.0, 60.0)
HUD_SURV_PANEL_SIZE = (182.0, 53.0)
HUD_SURV_XP_LABEL_POS = (4.0, 78.0)
HUD_SURV_XP_VALUE_POS = (26.0, 74.0)
HUD_SURV_LVL_VALUE_POS = (85.0, 79.0)
HUD_SURV_PROGRESS_POS = (26.0, 91.0)
HUD_SURV_PROGRESS_WIDTH = 54.0
HUD_BONUS_BASE_Y = 121.0
HUD_BONUS_ICON_SIZE = 32.0
HUD_BONUS_TEXT_OFFSET = (36.0, 6.0)
HUD_BONUS_SPACING = 52.0
HUD_BONUS_PANEL_OFFSET_Y = -11.0
HUD_XP_BAR_RGBA = RGBA(0.1, 0.3, 0.6, 1.0)
HUD_QUEST_LEFT_Y_SHIFT = 80.0


class SmallFontLike(Protocol):
    @property
    def cell_size(self) -> int: ...


class HudRenderFlags(msgspec.Struct, frozen=True):
    show_health: bool
    show_weapon: bool
    show_xp: bool
    show_time: bool
    show_quest_hud: bool


class HudRenderContext(msgspec.Struct, frozen=True):
    resources: RuntimeResources
    state: HudState
    font: SmallFontData | None = None
    alpha: float = 1.0
    show_health: bool = True
    show_weapon: bool = True
    show_xp: bool = True
    show_time: bool = False
    show_quest_hud: bool = False
    small_indicators: bool = False


class HudState(msgspec.Struct):
    survival_xp_smoothed: int = 0
    preserve_bugs: bool = False

    def smooth_xp(self, target: int, frame_dt_ms: float) -> int:
        target = int(target)
        if target <= 0:
            self.survival_xp_smoothed = 0
            return 0

        smoothed = int(self.survival_xp_smoothed)
        if smoothed == target:
            return smoothed

        step = max(1, int(frame_dt_ms) // 2)
        diff = abs(smoothed - target)
        if diff > 1000:
            step *= diff // 100

        if smoothed < target:
            smoothed += step
            if smoothed > target:
                smoothed = target
        else:
            smoothed -= step
            if smoothed < target:
                smoothed = target

        self.survival_xp_smoothed = smoothed
        return smoothed


class HudLayout(msgspec.Struct, frozen=True):
    scale: float
    text_scale: float
    line_h: float
    hud_y_shift: float


def hud_flags_for_game_mode(game_mode_id: GameMode) -> HudRenderFlags:
    """Match `hud_update_and_render` (0x0041ca90) flag mapping."""

    match game_mode_id:
        case GameMode.QUESTS:
            return HudRenderFlags(
                show_health=True,
                show_weapon=True,
                show_xp=True,
                show_time=False,
                show_quest_hud=True,
            )
        case GameMode.SURVIVAL:
            return HudRenderFlags(
                show_health=True,
                show_weapon=True,
                show_xp=True,
                show_time=False,
                show_quest_hud=False,
            )
        case GameMode.RUSH:
            return HudRenderFlags(
                show_health=True,
                show_weapon=False,
                show_xp=False,
                show_time=True,
                show_quest_hud=False,
            )
        case GameMode.TYPO:
            return HudRenderFlags(
                show_health=True,
                show_weapon=False,
                show_xp=True,
                show_time=True,
                show_quest_hud=False,
            )
        case _:
            return HudRenderFlags(
                show_health=False,
                show_weapon=False,
                show_xp=False,
                show_time=False,
                show_quest_hud=False,
            )


def hud_ui_scale(screen_w: float, screen_h: float) -> float:
    scale = min(screen_w / HUD_BASE_WIDTH, screen_h / HUD_BASE_HEIGHT)
    if scale < 0.75:
        return 0.75
    if scale > 1.5:
        return 1.5
    return float(scale)


def hud_layout(screen_w: float, screen_h: float, *, font: SmallFontLike | None, show_quest_hud: bool) -> HudLayout:
    scale = hud_ui_scale(float(screen_w), float(screen_h))
    text_scale = 1.0 * scale
    line_h = float(font.cell_size) * text_scale if font is not None else 18.0 * text_scale
    hud_y_shift = HUD_QUEST_LEFT_Y_SHIFT if show_quest_hud else 0.0
    return HudLayout(scale=scale, text_scale=text_scale, line_h=line_h, hud_y_shift=hud_y_shift)

def _draw_text(font: SmallFontData | None, text: str, pos: Vec2, scale: float, color: rl.Color) -> None:
    if font is not None:
        draw_small_text(font, text, pos, color)
    else:
        rl.draw_text(text, int(pos.x), int(pos.y), int(18 * scale), color)


def _with_alpha(color: rl.Color, alpha: float) -> rl.Color:
    alpha = max(0.0, min(1.0, float(alpha)))
    return rl.Color(color.r, color.g, color.b, int(color.a * alpha))


def _quest_panel_slide_x(time_ms: float) -> float:
    time_ms = float(time_ms)
    if time_ms < 1000.0:
        return (1000.0 - time_ms) * -0.128
    return 0.0


def _survival_xp_progress_ratio(*, xp: int, level: int) -> float:
    level = max(1, int(level))
    prev_threshold = 0 if level <= 1 else survival_level_threshold(level - 1)
    next_threshold = survival_level_threshold(level)
    if next_threshold <= prev_threshold:
        return 0.0
    return (int(xp) - prev_threshold) / float(next_threshold - prev_threshold)


def _draw_progress_bar(pos: Vec2, width: float, ratio: float, rgba: RGBA, scale: float) -> None:
    ratio = max(0.0, min(1.0, float(ratio)))
    width = max(0.0, float(width))
    if width <= 0.0:
        return
    rgba = rgba.clamped()
    bar_h = 4.0 * scale
    inner_h = 2.0 * scale
    bg_color = rl.Color(
        int(255 * rgba.r * 0.6),
        int(255 * rgba.g * 0.6),
        int(255 * rgba.b * 0.6),
        int(255 * rgba.a * 0.4),
    )
    fg_color = rl.Color(
        int(255 * rgba.r),
        int(255 * rgba.g),
        int(255 * rgba.b),
        int(255 * rgba.a),
    )
    rl.draw_rectangle(int(pos.x), int(pos.y), int(width), int(bar_h), bg_color)
    inner_w = max(0.0, (width - 2.0 * scale) * ratio)
    rl.draw_rectangle(int(pos.x + scale), int(pos.y + scale), int(inner_w), int(inner_h), fg_color)


def draw_target_health_bar(*, pos: Vec2, width: float, ratio: float, alpha: float = 1.0, scale: float = 1.0) -> None:
    ratio = max(0.0, min(1.0, float(ratio)))
    alpha = max(0.0, min(1.0, float(alpha)))
    scale = max(0.1, float(scale))

    # Matches `hud_update_and_render` (0x0041ca90): color shifts from red->green as ratio increases.
    r = (1.0 - ratio) * 0.9 + 0.1
    g = ratio * 0.9 + 0.1
    rgba = RGBA(r, g, 0.7, 0.2 * alpha)
    _draw_progress_bar(pos, float(width), ratio, rgba, scale)


def _weapon_icon_index(weapon_id: int) -> int | None:
    icon_index = int(WEAPON_BY_ID[WeaponId(weapon_id)].icon_index)
    if icon_index < 0 or icon_index > 31:
        return None
    return icon_index


def _weapon_ammo_class(weapon_id: int) -> int:
    value = WEAPON_BY_ID[WeaponId(weapon_id)].ammo_class
    return int(value) if value is not None else 0


def _weapon_icon_src(texture: rl.Texture, icon_index: int) -> rl.Rectangle:
    grid = 8
    cell_w = float(texture.width) / grid
    cell_h = float(texture.height) / grid
    frame = int(icon_index) * 2
    col = frame % grid
    row = frame // grid
    return rl.Rectangle(float(col * cell_w), float(row * cell_h), float(cell_w * 2), float(cell_h))


def _bonus_icon_src(texture: rl.Texture, icon_id: int) -> rl.Rectangle:
    grid = 4
    cell_w = float(texture.width) / grid
    cell_h = float(texture.height) / grid
    col = int(icon_id) % grid
    row = int(icon_id) // grid
    return rl.Rectangle(float(col * cell_w), float(row * cell_h), float(cell_w), float(cell_h))


def draw_hud_overlay(
    context: HudRenderContext,
    *,
    player: PlayerState,
    players: list[PlayerState] | None = None,
    bonus_hud: BonusHudState | None = None,
    elapsed_ms: float = 0.0,
    score: int | None = None,
    frame_dt_ms: float | None = None,
    quest_progress_ratio: float | None = None,
) -> float:
    resources = context.resources
    state = context.state
    font = context.font
    alpha = float(context.alpha)
    show_health = bool(context.show_health)
    show_weapon = bool(context.show_weapon)
    show_xp = bool(context.show_xp)
    show_time = bool(context.show_time)
    show_quest_hud = bool(context.show_quest_hud)
    small_indicators = bool(context.small_indicators)
    game_top = resources.texture(TextureId.UI_GAME_TOP)
    life_heart = resources.texture(TextureId.UI_LIFE_HEART)
    ind_life = resources.texture(TextureId.UI_IND_LIFE)
    ind_panel = resources.texture(TextureId.UI_IND_PANEL)
    ind_bullet = resources.texture(TextureId.UI_IND_BULLET)
    ind_fire = resources.texture(TextureId.UI_IND_FIRE)
    ind_rocket = resources.texture(TextureId.UI_IND_ROCKET)
    ind_electric = resources.texture(TextureId.UI_IND_ELECTRIC)
    wicons = resources.texture(TextureId.UI_WICONS)
    clock_table = resources.texture(TextureId.UI_CLOCK_TABLE)
    clock_pointer = resources.texture(TextureId.UI_CLOCK_POINTER)
    bonuses_texture = resources.texture(TextureId.BONUSES)

    if frame_dt_ms is None:
        frame_dt_ms = max(0.0, float(rl.get_frame_time()) * 1000.0)
    hud_players = list(players) if players is not None else [player]
    if not hud_players:
        hud_players = [player]
    player_count = len(hud_players)

    screen_w = float(rl.get_screen_width())
    screen_h = float(rl.get_screen_height())
    layout = hud_layout(screen_w, screen_h, font=font, show_quest_hud=show_quest_hud)
    scale = layout.scale
    text_scale = layout.text_scale
    line_h = layout.line_h

    def ui(value: float) -> float:
        return value * scale

    max_y = 0.0
    alpha = max(0.0, min(1.0, float(alpha)))
    text_color = _with_alpha(HUD_TEXT_COLOR, alpha)
    panel_text_color = _with_alpha(HUD_TEXT_COLOR, alpha * HUD_PANEL_ALPHA)
    hud_y_shift = layout.hud_y_shift

    # Top bar background.
    src = rl.Rectangle(0.0, 0.0, float(game_top.width), float(game_top.height))
    dst = rl.Rectangle(
        ui(HUD_TOP_BAR_POS[0]),
        ui(HUD_TOP_BAR_POS[1]),
        ui(HUD_TOP_BAR_SIZE[0]),
        ui(HUD_TOP_BAR_SIZE[1]),
    )
    top_alpha = alpha * HUD_TOP_BAR_ALPHA
    rl.draw_texture_pro(
        game_top,
        src,
        dst,
        rl.Vector2(0.0, 0.0),
        0.0,
        rl.Color(255, 255, 255, int(255 * top_alpha)),
    )
    max_y = max(max_y, dst.y + dst.height)

    # Pulsing heart.
    if show_health:
        t = max(0.0, elapsed_ms) / 1000.0
        src = rl.Rectangle(0.0, 0.0, float(life_heart.width), float(life_heart.height))
        if player_count == 1:
            heart_center_base = Vec2(*HUD_HEART_CENTER)
            heart_step = Vec2()
            heart_scale = 1.0
        else:
            heart_center_base = Vec2(27.0, 12.0)
            heart_step = Vec2(0.0, 15.0)
            heart_scale = 0.5
        player0_low_health = player_count > 0 and float(hud_players[0].health) < 30.0

        for idx, hud_player in enumerate(hud_players):
            pulse_speed = 5.0 if hud_player.health < 30.0 else 2.0
            if bool(state.preserve_bugs) and player_count > 1 and idx > 0 and player0_low_health:
                # Native 2-player HUD uses player 1 low-health pulse speed as a
                # shared baseline for later player heart pulses.
                pulse_speed = 5.0
            phase = float(idx) * (math.pi * 0.5)
            pulse = ((math.sin(t * pulse_speed + phase) ** 4) * 4.0 + 14.0) * heart_scale
            size = pulse * 2.0
            center = heart_center_base + heart_step * float(idx)
            dst = rl.Rectangle(
                ui(center.x - pulse),
                ui(center.y - pulse),
                ui(size),
                ui(size),
            )
            rl.draw_texture_pro(
                life_heart,
                src,
                dst,
                rl.Vector2(0.0, 0.0),
                0.0,
                rl.Color(255, 255, 255, int(255 * alpha * HUD_ICON_ALPHA)),
            )
            max_y = max(max_y, dst.y + dst.height)

    # Health bar.
    if show_health:
        bar_base_pos = Vec2(*HUD_HEALTH_BAR_POS)
        bar_size = Vec2(*HUD_HEALTH_BAR_SIZE)
        bg_src = rl.Rectangle(0.0, 0.0, float(ind_life.width), float(ind_life.height))
        if player_count > 1:
            bar_base_pos = Vec2(bar_base_pos.x, 6.0)

        for idx, hud_player in enumerate(hud_players):
            bar_pos = bar_base_pos.offset(dy=float(idx) * 16.0 if player_count > 1 else 0.0)
            bg_dst = rl.Rectangle(ui(bar_pos.x), ui(bar_pos.y), ui(bar_size.x), ui(bar_size.y))
            rl.draw_texture_pro(
                ind_life,
                bg_src,
                bg_dst,
                rl.Vector2(0.0, 0.0),
                0.0,
                rl.Color(255, 255, 255, int(255 * alpha * HUD_HEALTH_BG_ALPHA)),
            )
            health_ratio = max(0.0, min(1.0, hud_player.health / 100.0))
            if health_ratio > 0.0:
                fill_w = bar_size.x * health_ratio
                fill_dst = rl.Rectangle(ui(bar_pos.x), ui(bar_pos.y), ui(fill_w), ui(bar_size.y))
                fill_src = rl.Rectangle(
                    0.0,
                    0.0,
                    float(ind_life.width) * health_ratio,
                    float(ind_life.height),
                )
                rl.draw_texture_pro(
                    ind_life,
                    fill_src,
                    fill_dst,
                    rl.Vector2(0.0, 0.0),
                    0.0,
                    rl.Color(255, 255, 255, int(255 * alpha * HUD_ICON_ALPHA)),
                )
            max_y = max(max_y, bg_dst.y + bg_dst.height)

    # Weapon icon.
    if show_weapon:
        if player_count == 1:
            icon_base_pos = Vec2(*HUD_WEAPON_ICON_POS)
            icon_size = Vec2(*HUD_WEAPON_ICON_SIZE)
            icon_step = Vec2()
        else:
            icon_base_pos = Vec2(220.0, 4.0)
            icon_size = Vec2(32.0, 16.0)
            icon_step = Vec2(0.0, 16.0)

        for idx, hud_player in enumerate(hud_players):
            icon_index = _weapon_icon_index(hud_player.weapon.weapon_id)
            if icon_index is None:
                continue
            src = _weapon_icon_src(wicons, icon_index)
            icon_pos = icon_base_pos + icon_step * float(idx)
            dst = rl.Rectangle(
                ui(icon_pos.x),
                ui(icon_pos.y),
                ui(icon_size.x),
                ui(icon_size.y),
            )
            rl.draw_texture_pro(
                wicons,
                src,
                dst,
                rl.Vector2(0.0, 0.0),
                0.0,
                rl.Color(255, 255, 255, int(255 * alpha * HUD_ICON_ALPHA)),
            )
            max_y = max(max_y, dst.y + dst.height)

    # Ammo bars.
    if show_weapon:
        if player_count == 1:
            ammo_base_pos = Vec2(*HUD_AMMO_BASE_POS)
            ammo_step = Vec2()
        else:
            ammo_base_pos = Vec2(290.0, 4.0)
            ammo_step = Vec2(0.0, 14.0)

        base_alpha = alpha * HUD_ICON_ALPHA
        for player_idx, hud_player in enumerate(hud_players):
            ammo_class = _weapon_ammo_class(hud_player.weapon.weapon_id)
            if ammo_class == 1:
                ammo_tex = ind_fire
            elif ammo_class == 2:
                ammo_tex = ind_rocket
            elif ammo_class == 0:
                ammo_tex = ind_bullet
            else:
                ammo_tex = ind_electric

            player_ammo_base = ammo_base_pos + ammo_step * float(player_idx)
            bars = max(0, int(hud_player.weapon.clip_size))
            if bars > HUD_AMMO_BAR_LIMIT:
                bars = HUD_AMMO_BAR_CLAMP
            ammo_count = max(0, int(hud_player.weapon.ammo))
            for idx in range(bars):
                bar_alpha = base_alpha if idx < ammo_count else base_alpha * HUD_AMMO_DIM_ALPHA
                bar_pos = player_ammo_base.offset(dx=float(idx) * HUD_AMMO_BAR_STEP)
                dst = rl.Rectangle(
                    ui(bar_pos.x),
                    ui(bar_pos.y),
                    ui(HUD_AMMO_BAR_SIZE[0]),
                    ui(HUD_AMMO_BAR_SIZE[1]),
                )
                src = rl.Rectangle(0.0, 0.0, float(ammo_tex.width), float(ammo_tex.height))
                rl.draw_texture_pro(
                    ammo_tex,
                    src,
                    dst,
                    rl.Vector2(0.0, 0.0),
                    0.0,
                    rl.Color(255, 255, 255, int(255 * bar_alpha)),
                )
                max_y = max(max_y, dst.y + dst.height)
            if ammo_count > bars:
                extra = ammo_count - bars
                text_pos = player_ammo_base + Vec2(
                    bars * HUD_AMMO_BAR_STEP + HUD_AMMO_TEXT_OFFSET[0],
                    HUD_AMMO_TEXT_OFFSET[1],
                )
                _draw_text(font, f"+ {extra}", Vec2(ui(text_pos.x), ui(text_pos.y)), text_scale, text_color)

    # Quest HUD panels (mm:ss timer + progress).
    if show_quest_hud:
        time_ms = max(0.0, float(elapsed_ms))
        slide_x = _quest_panel_slide_x(time_ms)

        quest_panel_alpha = alpha * 0.7
        quest_text_color = _with_alpha(HUD_TEXT_COLOR, quest_panel_alpha)

        src = rl.Rectangle(0.0, 0.0, float(ind_panel.width), float(ind_panel.height))

        # Sliding top panel (first second).
        slide_panel_pos = Vec2(slide_x - 90.0, 67.0)
        slide_panel_size = Vec2(182.0, 53.0)
        dst = rl.Rectangle(
            ui(slide_panel_pos.x),
            ui(slide_panel_pos.y),
            ui(slide_panel_size.x),
            ui(slide_panel_size.y),
        )
        rl.draw_texture_pro(
            ind_panel,
            src,
            dst,
            rl.Vector2(0.0, 0.0),
            0.0,
            rl.Color(255, 255, 255, int(255 * quest_panel_alpha)),
        )
        max_y = max(max_y, dst.y + dst.height)

        # Static progress panel.
        progress_panel_pos = Vec2(-80.0, 107.0)
        progress_panel_size = Vec2(182.0, 53.0)
        dst = rl.Rectangle(
            ui(progress_panel_pos.x),
            ui(progress_panel_pos.y),
            ui(progress_panel_size.x),
            ui(progress_panel_size.y),
        )
        rl.draw_texture_pro(
            ind_panel,
            src,
            dst,
            rl.Vector2(0.0, 0.0),
            0.0,
            rl.Color(255, 255, 255, int(255 * quest_panel_alpha)),
        )
        max_y = max(max_y, dst.y + dst.height)

        # Clock table + pointer inside the sliding panel.
        clock_alpha = alpha * HUD_CLOCK_ALPHA
        clock_table_pos = Vec2(slide_x + 2.0, 78.0)
        clock_size = Vec2(32.0, 32.0)
        dst = rl.Rectangle(ui(clock_table_pos.x), ui(clock_table_pos.y), ui(clock_size.x), ui(clock_size.y))
        src = rl.Rectangle(0.0, 0.0, float(clock_table.width), float(clock_table.height))
        rl.draw_texture_pro(
            clock_table,
            src,
            dst,
            rl.Vector2(0.0, 0.0),
            0.0,
            rl.Color(255, 255, 255, int(255 * clock_alpha)),
        )

        # NOTE: Raylib's draw_texture_pro uses dst.x/y as the rotation origin position;
        # offset by half-size so the 32x32 quad stays aligned with the table.
        clock_pointer_pos = Vec2(slide_x + 18.0, 94.0)
        clock_size = Vec2(32.0, 32.0)
        dst = rl.Rectangle(ui(clock_pointer_pos.x), ui(clock_pointer_pos.y), ui(clock_size.x), ui(clock_size.y))
        src = rl.Rectangle(0.0, 0.0, float(clock_pointer.width), float(clock_pointer.height))
        rotation = time_ms / 1000.0 * 6.0
        origin = rl.Vector2(ui(16.0), ui(16.0))
        rl.draw_texture_pro(
            clock_pointer,
            src,
            dst,
            origin,
            rotation,
            rl.Color(255, 255, 255, int(255 * clock_alpha)),
        )

        total_seconds = max(0, int(time_ms) // 1000)
        minutes = total_seconds // 60
        seconds = total_seconds % 60
        time_pos = Vec2(slide_x + 32.0, 86.0)
        _draw_text(font, f"{minutes}:{seconds:02d}", Vec2(ui(time_pos.x), ui(time_pos.y)), text_scale, quest_text_color)

        progress_label_pos = Vec2(18.0, 122.0)
        _draw_text(
            font,
            "Progress",
            Vec2(ui(progress_label_pos.x), ui(progress_label_pos.y)),
            text_scale,
            quest_text_color,
        )

        if quest_progress_ratio is not None:
            ratio = max(0.0, min(1.0, float(quest_progress_ratio)))
            quest_bar_rgba = RGBA(0.2, 0.8, 0.3, alpha * 0.8)
            progress_bar_pos = Vec2(10.0, 139.0)
            _draw_progress_bar(
                Vec2(ui(progress_bar_pos.x), ui(progress_bar_pos.y)),
                ui(70.0),
                ratio,
                quest_bar_rgba,
                scale,
            )

    # Survival XP panel.
    xp_target = int(player.experience if score is None else score)
    xp_display = state.smooth_xp(xp_target, frame_dt_ms) if show_xp else xp_target
    if show_xp:
        panel_pos = Vec2(*HUD_SURV_PANEL_POS).offset(dy=hud_y_shift)
        panel_size = Vec2(*HUD_SURV_PANEL_SIZE)
        dst = rl.Rectangle(ui(panel_pos.x), ui(panel_pos.y), ui(panel_size.x), ui(panel_size.y))
        src = rl.Rectangle(0.0, 0.0, float(ind_panel.width), float(ind_panel.height))
        rl.draw_texture_pro(
            ind_panel,
            src,
            dst,
            rl.Vector2(0.0, 0.0),
            0.0,
            rl.Color(255, 255, 255, int(255 * alpha * HUD_PANEL_ALPHA)),
        )
        max_y = max(max_y, dst.y + dst.height)

    if show_xp:
        xp_label_pos = Vec2(*HUD_SURV_XP_LABEL_POS).offset(dy=hud_y_shift)
        xp_value_pos = Vec2(*HUD_SURV_XP_VALUE_POS).offset(dy=hud_y_shift)
        lvl_value_pos = Vec2(*HUD_SURV_LVL_VALUE_POS).offset(dy=hud_y_shift)
        _draw_text(
            font,
            "Xp",
            Vec2(ui(xp_label_pos.x), ui(xp_label_pos.y)),
            text_scale,
            panel_text_color,
        )
        _draw_text(
            font,
            f"{xp_display}",
            Vec2(ui(xp_value_pos.x), ui(xp_value_pos.y)),
            text_scale,
            panel_text_color,
        )
        _draw_text(
            font,
            f"{int(player.level)}",
            Vec2(ui(lvl_value_pos.x), ui(lvl_value_pos.y)),
            text_scale,
            panel_text_color,
        )

        progress_ratio = _survival_xp_progress_ratio(xp=xp_target, level=int(player.level))
        progress_pos = Vec2(*HUD_SURV_PROGRESS_POS).offset(dy=hud_y_shift)
        bar_rgba = HUD_XP_BAR_RGBA.scaled_alpha(alpha)
        _draw_progress_bar(
            Vec2(ui(progress_pos.x), ui(progress_pos.y)),
            ui(HUD_SURV_PROGRESS_WIDTH),
            progress_ratio,
            bar_rgba,
            scale,
        )
        max_y = max(max_y, ui(progress_pos.y + 4.0))

    # Mode time clock/text (rush/typo-style HUD).
    if show_time:
        time_ms = max(0.0, float(elapsed_ms))
        clock_pos = Vec2(*HUD_CLOCK_POS)
        clock_size = Vec2(*HUD_CLOCK_SIZE)
        dst = rl.Rectangle(
            ui(clock_pos.x),
            ui(clock_pos.y),
            ui(clock_size.x),
            ui(clock_size.y),
        )
        src = rl.Rectangle(0.0, 0.0, float(clock_table.width), float(clock_table.height))
        rl.draw_texture_pro(
            clock_table,
            src,
            dst,
            rl.Vector2(0.0, 0.0),
            0.0,
            rl.Color(255, 255, 255, int(255 * alpha * HUD_CLOCK_ALPHA)),
        )
        max_y = max(max_y, dst.y + dst.height)
        # NOTE: Raylib's draw_texture_pro uses dst.x/y as the rotation origin position;
        # offset by half-size so the 32x32 quad stays aligned with the table.
        clock_center = clock_pos + clock_size * 0.5
        dst = rl.Rectangle(
            ui(clock_center.x),
            ui(clock_center.y),
            ui(clock_size.x),
            ui(clock_size.y),
        )
        src = rl.Rectangle(0.0, 0.0, float(clock_pointer.width), float(clock_pointer.height))
        rotation = time_ms / 1000.0 * 6.0
        origin = rl.Vector2(ui(clock_size.x * 0.5), ui(clock_size.y * 0.5))
        rl.draw_texture_pro(
            clock_pointer,
            src,
            dst,
            origin,
            rotation,
            rl.Color(255, 255, 255, int(255 * alpha * HUD_CLOCK_ALPHA)),
        )
        total_seconds = max(0, int(time_ms) // 1000)
        time_text = f"{total_seconds} seconds"
        _draw_text(font, time_text, Vec2(ui(255.0), ui(10.0)), text_scale, text_color)
        max_y = max(max_y, ui(10.0 + line_h))

    # Bonus HUD slots (icon + timers), slide in/out from the left.
    bonus_bottom_y = float(HUD_BONUS_BASE_Y + hud_y_shift)
    if bonus_hud is not None:
        bonus_y = float(HUD_BONUS_BASE_Y + hud_y_shift)
        bonus_panel_alpha = alpha * 0.7
        bonus_text_color = _with_alpha(HUD_TEXT_COLOR, bonus_panel_alpha)
        bar_rgba = HUD_XP_BAR_RGBA.with_alpha(bonus_panel_alpha)

        slots = bonus_hud.slots[:16]
        for slot in slots:
            if not slot.active:
                continue

            if slot.slide_x < -184.0:
                bonus_y += HUD_BONUS_SPACING
                continue
            slot_pos = Vec2(slot.slide_x, bonus_y)

            has_alt = slot.timer_ref_alt is not None and player_count > 1
            timer = float(slot.timer_value)
            timer_alt = float(slot.timer_value_alt) if has_alt else 0.0

            # Slot panel.
            if not small_indicators:
                panel_pos = slot_pos.offset(dy=HUD_BONUS_PANEL_OFFSET_Y)
                panel_size = Vec2(182.0, 53.0)
            else:
                panel_pos = slot_pos + Vec2(-96.0, 5.0)
                panel_size = Vec2(182.0, 26.5)

            src = rl.Rectangle(0.0, 0.0, float(ind_panel.width), float(ind_panel.height))
            dst = rl.Rectangle(ui(panel_pos.x), ui(panel_pos.y), ui(panel_size.x), ui(panel_size.y))
            rl.draw_texture_pro(
                ind_panel,
                src,
                dst,
                rl.Vector2(0.0, 0.0),
                0.0,
                rl.Color(255, 255, 255, int(255 * bonus_panel_alpha)),
            )
            max_y = max(max_y, dst.y + dst.height)

            # Slot icon.
            if slot.icon_id >= 0:
                src = _bonus_icon_src(bonuses_texture, slot.icon_id)
                icon_pos = slot_pos.offset(dx=-1.0)
                dst = rl.Rectangle(
                    ui(icon_pos.x),
                    ui(icon_pos.y),
                    ui(HUD_BONUS_ICON_SIZE),
                    ui(HUD_BONUS_ICON_SIZE),
                )
                rl.draw_texture_pro(
                    bonuses_texture,
                    src,
                    dst,
                    rl.Vector2(0.0, 0.0),
                    0.0,
                    rl.Color(255, 255, 255, int(255 * alpha)),
                )
                max_y = max(max_y, dst.y + dst.height)

            # Slot timer bars.
            if not small_indicators:
                if not has_alt:
                    timer_pos = slot_pos + Vec2(36.0, 21.0)
                    _draw_progress_bar(
                        Vec2(ui(timer_pos.x), ui(timer_pos.y)),
                        ui(100.0),
                        timer * 0.05,
                        bar_rgba,
                        scale,
                    )
                    label_pos = slot_pos + Vec2(36.0, 6.0)
                    _draw_text(
                        font,
                        slot.label,
                        Vec2(ui(label_pos.x), ui(label_pos.y)),
                        text_scale,
                        bonus_text_color,
                    )
                else:
                    timer0_pos = slot_pos + Vec2(36.0, 17.0)
                    _draw_progress_bar(
                        Vec2(ui(timer0_pos.x), ui(timer0_pos.y)),
                        ui(100.0),
                        timer * 0.05,
                        bar_rgba,
                        scale,
                    )
                    timer1_pos = slot_pos + Vec2(36.0, 23.0)
                    _draw_progress_bar(
                        Vec2(ui(timer1_pos.x), ui(timer1_pos.y)),
                        ui(100.0),
                        timer_alt * 0.05,
                        bar_rgba,
                        scale,
                    )
                    label_pos = slot_pos + Vec2(36.0, 2.0)
                    _draw_text(
                        font,
                        slot.label,
                        Vec2(ui(label_pos.x), ui(label_pos.y)),
                        text_scale,
                        bonus_text_color,
                    )
            else:
                if not has_alt:
                    timer_pos = slot_pos + Vec2(36.0, 17.0)
                    _draw_progress_bar(
                        Vec2(ui(timer_pos.x), ui(timer_pos.y)),
                        ui(32.0),
                        timer * 0.05,
                        bar_rgba,
                        scale,
                    )
                else:
                    timer0_pos = slot_pos + Vec2(36.0, 13.0)
                    _draw_progress_bar(
                        Vec2(ui(timer0_pos.x), ui(timer0_pos.y)),
                        ui(32.0),
                        timer * 0.05,
                        bar_rgba,
                        scale,
                    )
                    timer1_pos = slot_pos + Vec2(36.0, 19.0)
                    _draw_progress_bar(
                        Vec2(ui(timer1_pos.x), ui(timer1_pos.y)),
                        ui(32.0),
                        timer_alt * 0.05,
                        bar_rgba,
                        scale,
                    )

            bonus_y += HUD_BONUS_SPACING
            max_y = max(max_y, ui(bonus_y))
        bonus_bottom_y = bonus_y

    # Weapon aux timer overlay (weapon name popup).
    aux_panel_base_pos = Vec2(-12.0, float(bonus_bottom_y) - 17.0)
    aux_icon_base_pos = Vec2(105.0, float(bonus_bottom_y) - 5.0)
    aux_text_base_pos = Vec2(8.0, float(bonus_bottom_y) + 1.0)
    aux_step = Vec2(0.0, 32.0)
    for idx, hud_player in enumerate(hud_players):
        aux_timer = float(hud_player.aux_timer)
        if aux_timer <= 0.0:
            continue

        fade = 2.0 - aux_timer if aux_timer > 1.0 else aux_timer
        fade = max(0.0, min(1.0, fade)) * alpha
        if fade <= 1e-3:
            continue

        panel_alpha = fade * 0.8
        text_alpha = fade

        panel_pos = aux_panel_base_pos + aux_step * float(idx)
        panel_size = Vec2(182.0, 53.0)

        src = rl.Rectangle(0.0, 0.0, float(ind_panel.width), float(ind_panel.height))
        dst = rl.Rectangle(ui(panel_pos.x), ui(panel_pos.y), ui(panel_size.x), ui(panel_size.y))
        rl.draw_texture_pro(
            ind_panel,
            src,
            dst,
            rl.Vector2(0.0, 0.0),
            0.0,
            rl.Color(255, 255, 255, int(255 * panel_alpha)),
        )
        max_y = max(max_y, dst.y + dst.height)

        icon_index = _weapon_icon_index(hud_player.weapon.weapon_id)
        if icon_index is not None:
            src = _weapon_icon_src(wicons, icon_index)
            icon_pos = aux_icon_base_pos + aux_step * float(idx)
            dst = rl.Rectangle(ui(icon_pos.x), ui(icon_pos.y), ui(60.0), ui(30.0))
            rl.draw_texture_pro(
                wicons,
                src,
                dst,
                rl.Vector2(0.0, 0.0),
                0.0,
                rl.Color(255, 255, 255, int(255 * panel_alpha)),
            )
            max_y = max(max_y, dst.y + dst.height)

        weapon_name = weapon_display_name(
            hud_player.weapon.weapon_id,
            preserve_bugs=bool(state.preserve_bugs),
        )
        weapon_color = _with_alpha(HUD_TEXT_COLOR, text_alpha)
        text_pos = aux_text_base_pos + aux_step * float(idx)
        _draw_text(
            font,
            weapon_name,
            Vec2(ui(text_pos.x), ui(text_pos.y)),
            text_scale,
            weapon_color,
        )

    return max_y
