//! TRACES: UR-003, UR-004, UR-005, UR-010, UR-020, UR-021 | JA-022, JA-023, JA-024, JA-025, JA-026 | DR-001

// Cohesive command clusters live in their own submodules and are re-exported so
// the command names remain at `commands::player::*` (invoke_handler unchanged).
mod queue;
mod remote;
mod session;
mod settings;
mod timers;
pub use queue::*;
pub use remote::*;
pub use session::*;
pub use settings::*;
pub use timers::*;

use crate::utils::lock::MutexSafe;
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tauri::State;
use tokio::sync::Mutex as TokioMutex;

use super::DatabaseWrapper;
use crate::download::cache::{CacheConfig, SmartCache};
use crate::jellyfin::{JellyfinClient, JellyfinConfig};
use crate::player::{
    determine_video_seek_strategy, MediaItem, MediaSessionManager, MediaSource, MediaType,
    PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
};
use crate::repository::{
    types::{GetItemsOptions, ImageOptions, ImageType},
    MediaRepository,
};
use crate::settings::VideoSettings;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};

/// SmartCache wrapper for Tauri state management
pub struct SmartCacheWrapper(pub Mutex<SmartCache>);

/// Player state wrapper for Tauri.
///
/// Uses Arc to allow sharing with the MediaSession handler on Android
/// for lockscreen control integration.
///
/// @req: UR-005 - Control media playback
/// @req: DR-001 - Player state machine
pub struct PlayerStateWrapper(pub Arc<TokioMutex<PlayerController>>);

/// Media session manager wrapper for Tauri state management
///
/// @req: DR-009 - Audio player UI (mini player, full screen)
pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);

/// Video settings state wrapper for Tauri
///
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);

/// Response for player state queries
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerStatus {
    pub state: PlayerState,
    pub position: f64,
    pub duration: Option<f64>,
    pub volume: f32,
    pub muted: bool,
    pub shuffle: bool,
    pub repeat: RepeatMode,
    /// Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
    pub backend: VideoBackend,
    /// Whether frontend should render HTML5 video element
    pub use_html5_element: bool,

    // Merged fields (prefer remote session when available)
    /// Media item from either local queue or remote session
    pub merged_media: Option<MergedMediaItem>,
    /// Playing state from either local player or remote session
    pub merged_is_playing: bool,
    /// Volume from either local player or remote session (0-1 normalized)
    pub merged_volume: f32,
}

/// Lightweight media item for merged playback state
/// Converts from both local MediaItem and remote NowPlayingItem
#[derive(specta::Type, Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct MergedMediaItem {
    pub id: String,
    pub title: String,
    pub artist: Option<String>,
    pub album: Option<String>,
    pub album_id: Option<String>,
    pub duration: Option<f64>,
    pub primary_image_tag: Option<String>,
    /// Neutral image identifier — replaces `primary_image_tag` (same value).
    pub image_id: Option<String>,
    pub media_type: String,
}

// Convert from local MediaItem
impl From<&crate::player::MediaItem> for MergedMediaItem {
    fn from(item: &crate::player::MediaItem) -> Self {
        Self {
            id: item.id.clone(),
            title: item.title.clone(),
            artist: item.artist.clone(),
            album: item.album.clone(),
            album_id: item.album_id.clone(),
            duration: item.duration,
            primary_image_tag: item.primary_image_tag.clone(),
            image_id: item.primary_image_tag.clone(),
            media_type: match item.media_type {
                crate::player::MediaType::Audio => "audio".to_string(),
                crate::player::MediaType::Video => "video".to_string(),
            },
        }
    }
}

// Convert from remote NowPlayingItem
impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem {
    fn from(item: &crate::jellyfin::client::NowPlayingItem) -> Self {
        Self {
            id: item.id.clone().unwrap_or_default(),
            title: item.name.clone().unwrap_or_else(|| "Unknown".to_string()),
            artist: item
                .album_artist
                .clone()
                .or_else(|| item.artists.as_ref().and_then(|a| a.first().cloned())),
            album: item.album.clone(),
            album_id: item.album_id.clone(),
            duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0),
            primary_image_tag: item.primary_image_tag.clone(),
            image_id: item.primary_image_tag.clone(),
            media_type: item
                .item_type
                .clone()
                .unwrap_or_else(|| "audio".to_string())
                .to_lowercase(),
        }
    }
}

/// Response for queue queries
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct QueueStatus {
    pub items: Vec<MediaItem>,
    pub current_index: Option<usize>,
    pub shuffle: bool,
    pub repeat: RepeatMode,
    pub has_next: bool,
    pub has_previous: bool,
}

/// Backend type for video playback
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum VideoBackend {
    /// Native backend (ExoPlayer on Android, libmpv on Linux)
    Native,
    /// HTML5 video element fallback
    Html5,
}

/// Request to play a single video item
///
/// Simplified to video playback only. Audio playback uses player_play_tracks
/// to avoid Tauri Android serialization issues with complex objects.
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayItemRequest {
    pub id: String,
    pub title: String,
    pub stream_url: String,
    /// Video codec (e.g., "h264", "hevc") for video media
    pub video_codec: String,
    /// Whether the video requires server-side transcoding
    pub needs_transcoding: bool,
    /// Optional now-playing metadata. Used by the background-audio handoff so the
    /// lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
    /// existing video-only callers need not send them.
    #[serde(default)]
    pub artist: Option<String>,
    #[serde(default)]
    pub primary_image_tag: Option<String>,
    #[serde(default)]
    pub server_id: Option<String>,
    /// Total media duration (seconds). Threaded through the background-audio
    /// handoff so the lockscreen MediaSession advertises a real duration — a
    /// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
    #[serde(default)]
    pub duration_seconds: Option<f64>,
    /// Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
    /// background-audio handoff so an episode played as audio-only is still
    /// recognised as an episode by autoplay (UR-040) and advances to the next one.
    #[serde(default)]
    pub item_type: Option<String>,
    /// Series ID for TV episodes. Needed alongside `item_type` so the backend can
    /// look up the next episode when a background-audio track ends.
    #[serde(default)]
    pub series_id: Option<String>,
    /// Subtitle tracks to sideload, with URLs the frontend has already resolved.
    ///
    /// Only the native backends use these: on Android they become the
    /// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
    /// builds its own `<track>` children instead and ignores this list.
    ///
    /// **Order is the contract.** `player_set_subtitle_track(n)` reaches
    /// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
    /// *text track groups* — i.e. the position of the sideloaded configuration,
    /// not the Jellyfin stream index (which is kept on each entry for the UI's
    /// benefit). So `n` must be a position in this very array, and the array
    /// must not be reordered or filtered between building it and sending it.
    /// `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
    /// list that is sent here, for exactly this reason.
    ///
    /// Defaulted so the background-audio handoff and the autoplay/next-episode
    /// callers, which have no subtitles to offer, need not send the field.
    ///
    /// TRACES: UR-020 | IR-016, JA-008 | UT-145
    #[serde(default)]
    pub subtitles: Vec<crate::player::SubtitleTrack>,
}

/// Queue context for remote transfer - what type of queue is this?
#[derive(specta::Type, Debug, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum PlayQueueContext {
    /// Playing from a specific album
    Album {
        #[serde(rename = "albumId")]
        album_id: String,
        #[serde(rename = "albumName")]
        album_name: String,
    },
    /// Playing from a specific playlist
    Playlist {
        #[serde(rename = "playlistId")]
        playlist_id: String,
        #[serde(rename = "playlistName")]
        playlist_name: String,
    },
    /// Custom queue (search results, manual queue, etc.)
    Custom,
}

/// Request to play a queue of items
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayQueueRequest {
    pub items: Vec<PlayItemRequest>,
    pub start_index: usize,
    pub shuffle: bool,
    /// Optional context for the queue (album, playlist, or custom)
    /// Used for remote playback transfer
    #[serde(default)]
    pub context: Option<PlayQueueContext>,
}

/// Request to play a track from an album (backend fetches all tracks)
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayAlbumTrackRequest {
    pub album_id: String,
    pub album_name: String,
    pub track_id: String,
    pub shuffle: bool,
}

/// Request to play tracks by ID (backend fetches metadata)
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayTracksRequest {
    pub track_ids: Vec<String>,
    pub start_index: usize,
    pub shuffle: bool,
    pub context: PlayTracksContext,
    /// Position (seconds) to resume the starting track from. Used when taking
    /// over playback from a remote session so we don't restart from 0.
    #[serde(default)]
    pub start_position: Option<f64>,
}

/// Context information for track playback
#[derive(specta::Type, Debug, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum PlayTracksContext {
    Playlist {
        #[serde(rename = "playlistId")]
        playlist_id: String,
        #[serde(rename = "playlistName")]
        playlist_name: String,
    },
    Search {
        #[serde(rename = "searchQuery")]
        #[allow(dead_code)] // Used for deserialization, may be used later for analytics
        search_query: String,
    },
    Custom {
        #[serde(rename = "label")]
        #[allow(dead_code)] // Used for deserialization, may be used later for UI display
        label: Option<String>,
    },
}

/// Response for video seek operations
#[derive(specta::Type, Debug, Serialize)]
#[serde(tag = "strategy", rename_all = "camelCase")]
pub enum VideoSeekResponse {
    /// Use native seeking (HLS or direct stream)
    Native {
        /// Confirmed position after seek
        position: f64,
    },
    /// Reload stream from new position (transcoded non-HLS)
    ReloadStream {
        /// New stream URL starting at seek position
        new_url: String,
        /// Position offset to track (for display purposes)
        seek_offset: f64,
    },
}

/// Response for audio track switching operations
#[derive(specta::Type, Debug, Serialize)]
#[serde(tag = "strategy", rename_all = "camelCase")]
pub enum AudioTrackSwitchResponse {
    /// Native backend handled it (Android ExoPlayer)
    Native {
        /// Confirmation message
        success: bool,
    },
    /// HTML5 needs to reload stream with new audio track
    ReloadStream {
        /// New stream URL with selected audio track
        new_url: String,
        /// Current position to resume from
        position: f64,
    },
}

/// Helper function to create MediaItem from video request
///
/// PlayItemRequest is now video-only, so we create a video MediaItem.
/// Audio playback uses player_play_tracks which fetches full metadata from backend.
pub(super) async fn create_media_item(
    req: PlayItemRequest,
    db: Option<&DatabaseWrapper>,
) -> Result<MediaItem, String> {
    // For video-only requests, we use the item ID as the jellyfin ID
    let jellyfin_id = req.id.clone();

    // Check if item is downloaded locally
    let local_path = if let Some(db_wrapper) = db {
        check_for_local_download(db_wrapper, &jellyfin_id).await?
    } else {
        None
    };

    let source = if let Some(path) = local_path {
        MediaSource::Local {
            file_path: PathBuf::from(path),
            jellyfin_item_id: Some(jellyfin_id.clone()),
        }
    } else {
        MediaSource::Remote {
            stream_url: req.stream_url,
            jellyfin_item_id: jellyfin_id.clone(),
        }
    };

    Ok(MediaItem {
        id: req.id.clone(),
        title: req.title.clone(),
        name: Some(req.title.clone()),
        artist: None,            // Not available from video-only request
        album: None,             // Not available from video-only request
        album_name: None,        // Not available from video-only request
        album_id: None,          // Not available from video-only request
        artist_items: None,      // Not available from video-only request
        artists: None,           // Not available from video-only request
        primary_image_tag: None, // Not available from video-only request
        image_id: None,
        item_type: None,   // Not available from video-only request
        playlist_id: None, // Not available from video-only request
        duration: None,    // Not available from video-only request
        artwork_url: None, // Not available from video-only request
        media_type: crate::player::MediaType::Video, // Video-only request
        source,
        video_codec: Some(req.video_codec),
        needs_transcoding: req.needs_transcoding,
        video_width: None,  // Not available from video-only request
        video_height: None, // Not available from video-only request
        // Sideloaded subtitles, in the order the frontend sent them — that order
        // is what `player_set_subtitle_track(n)` indexes into on Android.
        // TRACES: UR-020 | IR-016 | UT-145
        subtitles: req.subtitles,
        series_id: None, // Not available from video-only request
        server_id: None, // Not available from video-only request
    })
}

/// Pick the source for an audio-only handoff.
///
/// A downloaded file wins over the audio-only stream URL. No transcode or audio
/// extraction is involved or wanted: the native backends already play a video
/// container without decoding its video — the Linux MPV backend is configured
/// with `video: no`, and ExoPlayer simply has no surface to render to when the
/// item is `MediaType::Audio`. Producing a separate audio-only file would cost
/// CPU and battery, need an encoder the project does not ship, and leave a
/// second artifact to keep in step with the first.
///
/// TRACES: UR-071 | DR-128 | UT-119
pub(super) fn background_audio_source(
    local_path: Option<String>,
    stream_url: String,
    item_id: &str,
) -> MediaSource {
    match local_path {
        Some(path) => MediaSource::Local {
            file_path: PathBuf::from(path),
            jellyfin_item_id: Some(item_id.to_string()),
        },
        None => MediaSource::Remote {
            stream_url,
            jellyfin_item_id: item_id.to_string(),
        },
    }
}

/// Resolve the on-disk file backing a completed download, if there is one.
///
/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
/// deletion, a cleared cache directory, a restored database). Every caller wants
/// "can I play this from disk right now", so existence is checked here rather
/// than trusted from the row.
///
/// Split out from [`check_for_local_download`] so the resolution is testable
/// without a `DatabaseWrapper`, and reusable by the video path.
///
/// TRACES: UR-071 | DR-123 | UT-116
pub(super) async fn resolve_local_media_path<S: DatabaseService>(
    db_service: &Arc<S>,
    item_id: &str,
) -> Result<Option<String>, String> {
    let query = Query::with_params(
        "SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1",
        vec![QueryParam::String(item_id.to_string())],
    );

    let path: Option<String> = db_service
        .query_optional(query, |row| row.get(0))
        .await
        .map_err(|e| e.to_string())?;

    match path {
        Some(ref file_path) if std::path::Path::new(file_path).exists() => Ok(path),
        Some(file_path) => {
            warn!(
                "[Player] Download entry exists in DB but file not found: {}",
                file_path
            );
            Ok(None)
        }
        None => Ok(None),
    }
}

/// Check if an item has a completed download
pub(super) async fn check_for_local_download(
    db: &DatabaseWrapper,
    item_id: &str,
) -> Result<Option<String>, String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    resolve_local_media_path(&db_service, item_id).await
}

/// The on-disk path for a downloaded item, for playback surfaces that resolve
/// their own source rather than going through the queue.
///
/// The video player is the reason this exists: audio has preferred local files
/// since queue construction, but video asks the repository for a stream URL and
/// never consults `downloads`, so a downloaded film was still streamed — costing
/// bandwidth that had already been spent and failing outright when offline.
///
/// Returns `None` when nothing is downloaded *or* the file is missing, so the
/// caller falls back to streaming.
///
/// TRACES: UR-071 | DR-123 | UT-116
#[tauri::command]
#[specta::specta]
pub async fn player_local_media_path(
    db: State<'_, DatabaseWrapper>,
    item_id: String,
) -> Result<Option<String>, String> {
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };

    resolve_local_media_path(&db_service, &item_id).await
}

/// Re-point queued streaming items at completed local downloads.
///
/// Sources are resolved once when the queue is built, so downloads that finish
/// while it plays (preloaded upcoming tracks) — or that existed before the
/// connection dropped — would otherwise keep streaming. Called before advancing
/// so the next track always prefers the on-disk copy.
///
/// Returns the number of items switched to a local source.
pub(super) async fn refresh_queue_local_sources(
    controller: &PlayerController,
    db: &DatabaseWrapper,
) -> Result<usize, String> {
    // Collect remote item IDs first; the queue lock must not be held across awaits.
    let remote_ids: Vec<String> = {
        let queue = controller.queue();
        let queue_lock = queue.lock().map_err(|e| e.to_string())?;
        queue_lock
            .items()
            .iter()
            .filter_map(|item| match &item.source {
                MediaSource::Remote {
                    jellyfin_item_id, ..
                } => Some(jellyfin_item_id.clone()),
                _ => None,
            })
            .collect()
    };

    if remote_ids.is_empty() {
        return Ok(0);
    }

    let mut local_paths: Vec<(String, String)> = Vec::new();
    for id in remote_ids {
        if let Some(path) = check_for_local_download(db, &id).await? {
            local_paths.push((id, path));
        }
    }

    if local_paths.is_empty() {
        return Ok(0);
    }

    let queue = controller.queue();
    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
    let mut switched = 0;
    for item in queue_lock.items_mut() {
        if let MediaSource::Remote {
            jellyfin_item_id, ..
        } = &item.source
        {
            if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) {
                info!(
                    "[Player] Switching queued track {} to local download: {}",
                    id, path
                );
                item.source = MediaSource::Local {
                    file_path: PathBuf::from(path),
                    jellyfin_item_id: Some(id.clone()),
                };
                switched += 1;
            }
        }
    }
    Ok(switched)
}

/// Play a single media item (audio or video)
///
/// Accepts a PlayItemRequest with all optional fields properly defaulted.
/// This avoids Tauri's Android serialization issues with complex objects.
///
/// @req: UR-003 - Play videos
/// @req: UR-004 - Play audio uninterrupted
/// @req: UR-005 - Control media playback (play operation)
/// @req: DR-009 - Audio player UI
#[tauri::command]
#[specta::specta]
pub async fn player_play_item(
    player: State<'_, PlayerStateWrapper>,
    session: State<'_, MediaSessionManagerWrapper>,
    db: State<'_, DatabaseWrapper>,
    item: PlayItemRequest,
) -> Result<PlayerStatus, String> {
    info!(
        "player_play_item called: {} - {}",
        item.title, item.stream_url
    );

    // Create media item, checking for local download first
    let media_item = create_media_item(item, Some(&db)).await?;

    // Start appropriate session based on media type
    {
        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
        match media_item.media_type {
            MediaType::Audio => {
                session_mgr.start_audio_session(media_item.clone());
            }
            MediaType::Video => {
                // For single video items, treat as Movie (no series_id available here)
                session_mgr.start_movie_session(media_item.clone());
            }
        }
    }

    let controller = player.0.lock().await;
    // On Linux, video plays in the WebKitGTK HTML5 <video> element (see
    // get_player_status -> use_html5_element). The MPV backend has no embedded
    // window, so loading the stream into it would only start a redundant decode
    // (and the frontend would immediately stop it). Only load into the native
    // backend on platforms that actually render video through it (e.g. Android).
    #[cfg(not(target_os = "linux"))]
    controller
        .play_item(media_item)
        .map_err(|e| e.to_string())?;
    #[cfg(target_os = "linux")]
    {
        // Keep the queue in sync for UI/remote-transfer without starting MPV.
        controller
            .set_current_item(media_item)
            .map_err(|e| e.to_string())?;
    }

    // Emit queue changed event
    controller.emit_queue_changed();

    // Emit session changed event
    if let Some(emitter) = controller.event_emitter() {
        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
        emitter.emit(PlayerStatusEvent::SessionChanged {
            session: session_mgr.current().clone(),
        });
    }

    Ok(get_player_status(&controller))
}

/// Enter background-audio mode: hand playback of the currently-watched video off
/// to the native ExoPlayer *audio* path so the audio keeps playing while the app
/// is backgrounded/locked, with no client-side video decode (UR-040).
///
/// `stream_url` MUST be an audio-only URL (see
/// `get_audio_only_stream_url_for_video`). The item is created as
/// `MediaType::Audio` so it starts an audio session and loads into the native
/// backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
/// frontend side, so exactly one audio source is ever active.
///
/// This deliberately goes through the queue-based `play_item` path (NOT a
/// side-channel) so end-of-track lands in `on_playback_ended`, which already
/// honors the sleep timer (Time/Episodes/EndOfTrack) and drives autoplay-next.
/// The sleep-timer state is intentionally left untouched by the handoff.
///
/// TRACES: UR-040 | DR-052 | UT-061, IT-013
#[tauri::command]
#[specta::specta]
pub async fn player_enter_background_audio(
    player: State<'_, PlayerStateWrapper>,
    session: State<'_, MediaSessionManagerWrapper>,
    db: State<'_, DatabaseWrapper>,
    item: PlayItemRequest,
    position_seconds: f64,
) -> Result<PlayerStatus, String> {
    info!(
        "player_enter_background_audio: {} @ {:.1}s",
        item.title, position_seconds
    );

    // Prefer the downloaded file over the audio-only stream URL the frontend
    // resolved. Handing the native backend a local video container yields
    // audio-only playback for free — no transcode, no second artifact.
    // TRACES: UR-071 | DR-128
    let local_path = check_for_local_download(&db, &item.id).await?;
    if local_path.is_some() {
        info!(
            "player_enter_background_audio: using downloaded file for {}",
            item.id
        );
    }
    let source = background_audio_source(local_path, item.stream_url, &item.id);

    // Build an AUDIO media item pointing at the audio-only stream. We do not use
    // create_media_item() because that hardcodes MediaType::Video; background
    // audio must be Audio so no video decode is started.
    let media_item = MediaItem {
        id: item.id.clone(),
        title: item.title.clone(),
        name: Some(item.title.clone()),
        artist: item.artist.clone(),
        album: None,
        album_name: None,
        album_id: None,
        artist_items: None,
        artists: None,
        primary_image_tag: item.primary_image_tag.clone(),
        image_id: item.primary_image_tag.clone(),
        // Carry episode identity so autoplay can advance to the next episode when
        // this audio-only handoff ends while backgrounded (UR-040).
        item_type: item.item_type.clone(),
        playlist_id: None,
        // Carry the real duration so the lockscreen MediaSession can draw a scrubber.
        duration: item.duration_seconds,
        artwork_url: None,
        media_type: MediaType::Audio,
        source,
        video_codec: None,
        needs_transcoding: false,
        video_width: None,
        video_height: None,
        subtitles: vec![],
        series_id: item.series_id.clone(),
        server_id: item.server_id.clone(),
    };

    {
        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
        session_mgr.start_audio_session(media_item.clone());
    }

    // Same base offset drives the lockscreen scrubber: ExoPlayer reports position
    // relative to the stream's StartTimeTicks zero, but the metadata duration is
    // absolute, so shift the reported position back to absolute for the scrubber.
    let _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0));

    let controller = player.0.lock().await;
    // Remember where the video was: the audio stream's zero == this position
    // (the URL was built with StartTimeTicks=position_seconds), so on exit we add
    // this base to the native player's relative position to get the absolute one.
    // The controller owns it so a backend-driven advance to the next episode
    // clears it along with the stream it described.
    controller.enter_background_audio(position_seconds);
    controller
        .play_item(media_item)
        .map_err(|e| e.to_string())?;
    // NOTE: do NOT seek here. The audio-only URL already starts at the handoff
    // position via StartTimeTicks; the stream's timeline begins at 0 == that
    // point, so an extra seek(position_seconds) would jump PAST the content.

    controller.emit_queue_changed();
    if let Some(emitter) = controller.event_emitter() {
        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
        emitter.emit(PlayerStatusEvent::SessionChanged {
            session: session_mgr.current().clone(),
        });
    }

    Ok(get_player_status(&controller))
}

/// Exit background-audio mode: stop the native audio player and return its final
/// position so the frontend can reload the WebView `<video>` there (UR-040).
///
/// Returns the position in seconds. The sleep timer is intentionally left
/// untouched — if it fired while backgrounded, playback is already stopped and
/// this simply reports the last position.
///
/// TRACES: UR-040 | DR-052 | UT-061, IT-013
#[tauri::command]
#[specta::specta]
pub async fn player_exit_background_audio(
    player: State<'_, PlayerStateWrapper>,
) -> Result<f64, String> {
    // Back to foreground playback: the lockscreen scrubber is absolute again.
    let _ = crate::player::set_lockscreen_position_offset(0.0);

    let controller = player.0.lock().await;
    // The base offset (handoff position) + native player's relative position =
    // the absolute position to resume the video at. Zero after a backend-driven
    // episode advance, whose stream already starts at its own zero.
    let base = controller.exit_background_audio();
    // Capture position into a `let` BEFORE stop() — never hold work across a lock
    // re-entrant call (deadlock discipline, CLAUDE.md).
    let relative = controller.position();
    controller.stop().map_err(|e| e.to_string())?;
    let absolute = base + relative;
    info!(
        "player_exit_background_audio: base={:.1}s + relative={:.1}s = {:.1}s",
        base, relative, absolute
    );
    Ok(absolute)
}

/// Play a queue of media items
///
/// @req: UR-004 - Play audio uninterrupted
/// @req: UR-005 - Control media playback (queue playback)
/// @req: UR-015 - View and manage current audio queue
/// @req: DR-005 - Queue manager with shuffle, repeat, history
#[tauri::command]
#[specta::specta]
pub async fn player_play_queue(
    player: State<'_, PlayerStateWrapper>,
    session: State<'_, MediaSessionManagerWrapper>,
    db: State<'_, DatabaseWrapper>,
    request: PlayQueueRequest,
) -> Result<PlayerStatus, String> {
    info!(
        "player_play_queue called: {} items, start_index: {}, shuffle: {}",
        request.items.len(),
        request.start_index,
        request.shuffle
    );

    // Handle shuffle first
    if request.shuffle {
        let controller = player.0.lock().await;
        controller.toggle_shuffle();
    }

    // Convert request context to internal QueueContext
    let queue_context = match request.context {
        Some(PlayQueueContext::Album {
            album_id,
            album_name,
        }) => QueueContext::Album {
            album_id,
            album_name,
        },
        Some(PlayQueueContext::Playlist {
            playlist_id,
            playlist_name,
        }) => QueueContext::Playlist {
            playlist_id,
            playlist_name,
        },
        Some(PlayQueueContext::Custom) | None => QueueContext::Custom,
    };

    // Create media items, checking for local downloads (must not hold locks during await)
    let mut items: Vec<MediaItem> = Vec::new();
    for req in request.items {
        items.push(create_media_item(req, Some(&db)).await?);
    }

    // Start appropriate session based on first item's media type
    if let Some(first_item) = items.get(request.start_index) {
        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
        match first_item.media_type {
            MediaType::Audio => {
                session_mgr.start_audio_session(first_item.clone());
            }
            MediaType::Video => {
                // Queue of videos treated as movie session (TV episodes use different flow)
                session_mgr.start_movie_session(first_item.clone());
            }
        }
    }

    // Now play the queue and get status
    let controller = player.0.lock().await;
    info!(
        "player_play_queue: Calling controller.play_queue with {} items at index {}",
        items.len(),
        request.start_index
    );
    controller
        .play_queue(items, request.start_index)
        .map_err(|e| e.to_string())?;

    // Set the queue context for remote transfer
    {
        let queue_arc = controller.queue();
        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
        queue.set_context(queue_context);
    }

    // Emit queue changed event
    controller.emit_queue_changed();

    // Emit session changed event
    if let Some(emitter) = controller.event_emitter() {
        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
        emitter.emit(PlayerStatusEvent::SessionChanged {
            session: session_mgr.current().clone(),
        });
    }

    Ok(get_player_status(&controller))
}

#[tauri::command]
#[specta::specta]
pub async fn player_play(
    player: State<'_, PlayerStateWrapper>,
    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
    // Check if we're in remote mode
    let mode = playback_mode.0.get_mode();

    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
        // Send play command to remote session - clone client before await
        let client = {
            let controller = player.0.lock().await;
            let client_arc = controller.jellyfin_client();
            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
            client_opt
                .as_ref()
                .ok_or("Jellyfin client not configured")?
                .clone()
        };
        client.send_session_command(session_id, "Unpause").await?;
    } else {
        // Local playback
        let controller = player.0.lock().await;
        controller.play().map_err(|e| e.to_string())?;
    }

    let controller = player.0.lock().await;
    Ok(get_player_status(&controller))
}

#[tauri::command]
#[specta::specta]
pub async fn player_pause(
    player: State<'_, PlayerStateWrapper>,
    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
    // Check if we're in remote mode
    let mode = playback_mode.0.get_mode();

    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
        // Send pause command to remote session - clone client before await
        let client = {
            let controller = player.0.lock().await;
            let client_arc = controller.jellyfin_client();
            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
            client_opt
                .as_ref()
                .ok_or("Jellyfin client not configured")?
                .clone()
        };
        client.send_session_command(session_id, "Pause").await?;
    } else {
        // Local playback
        let controller = player.0.lock().await;
        controller.pause().map_err(|e| e.to_string())?;
    }

    let controller = player.0.lock().await;
    Ok(get_player_status(&controller))
}

#[tauri::command]
#[specta::specta]
pub async fn player_toggle(
    player: State<'_, PlayerStateWrapper>,
    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
    // Check if we're in remote mode
    let mode = playback_mode.0.get_mode();

    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
        // Send toggle command to remote session - clone client before await
        let client = {
            let controller = player.0.lock().await;
            let client_arc = controller.jellyfin_client();
            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
            client_opt
                .as_ref()
                .ok_or("Jellyfin client not configured")?
                .clone()
        };
        client.send_session_command(session_id, "PlayPause").await?;
    } else {
        // Local playback
        let controller = player.0.lock().await;
        controller.toggle_playback().map_err(|e| e.to_string())?;
    }

    let controller = player.0.lock().await;
    Ok(get_player_status(&controller))
}

#[tauri::command]
#[specta::specta]
pub async fn player_stop(
    player: State<'_, PlayerStateWrapper>,
    session: State<'_, MediaSessionManagerWrapper>,
    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
    // Check if we're in remote mode
    let mode = playback_mode.0.get_mode();

    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
        // Send stop command to remote session - clone client before await
        let client = {
            let controller = player.0.lock().await;
            let client_arc = controller.jellyfin_client();
            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
            client_opt
                .as_ref()
                .ok_or("Jellyfin client not configured")?
                .clone()
        };
        client.send_session_command(session_id, "Stop").await?;

        // Stopping the remote session ends the cast, so the manager returns to
        // Idle — same as a local stop. This is also what hands OS volume control
        // back to this device: set_mode releases the Android remote volume
        // provider on any exit from remote mode. Without it the mode stayed
        // Remote and the system volume slider remained stuck on the remote
        // session with no way back to the local speaker.
        playback_mode
            .0
            .set_mode(crate::playback_mode::PlaybackMode::Idle);
    } else {
        // Local playback
        let controller = player.0.lock().await;
        controller.stop().map_err(|e| e.to_string())?;

        // A genuine local stop returns the manager to Idle so it no longer
        // reports Local (or a stale Remote) — otherwise a later play/pause would
        // route to the wrong device.
        playback_mode
            .0
            .set_mode(crate::playback_mode::PlaybackMode::Idle);

        // Handle session state based on type (local playback only)
        {
            let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
            let current_session = session_mgr.current().clone();

            match current_session {
                crate::player::MediaSessionType::Movie { .. } => {
                    // Movies auto-dismiss when stopped
                    session_mgr.movie_session_ended();
                }
                crate::player::MediaSessionType::Audio { .. } => {
                    // Audio persists as inactive (user can resume later)
                    session_mgr.audio_session_inactive();
                }
                crate::player::MediaSessionType::TvShow { .. } => {
                    // TV shows mark episode ended (waiting for next or dismiss)
                    session_mgr.tv_session_episode_ended();
                }
                crate::player::MediaSessionType::Idle => {
                    // Already idle, no-op
                }
            }

            // Emit session changed event
            if let Some(emitter) = controller.event_emitter() {
                emitter.emit(PlayerStatusEvent::SessionChanged {
                    session: session_mgr.current().clone(),
                });
            }
        }
    }

    let controller = player.0.lock().await;
    Ok(get_player_status(&controller))
}

#[tauri::command]
#[specta::specta]
pub async fn player_next(
    player: State<'_, PlayerStateWrapper>,
    session: State<'_, MediaSessionManagerWrapper>,
    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
    db: State<'_, DatabaseWrapper>,
) -> Result<PlayerStatus, String> {
    debug!("[player_next] Command called from frontend");

    // Check if we're in remote mode
    let mode = playback_mode.0.get_mode();

    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
        // Send next track command to remote session - clone client before await
        let client = {
            let controller = player.0.lock().await;
            let client_arc = controller.jellyfin_client();
            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
            client_opt
                .as_ref()
                .ok_or("Jellyfin client not configured")?
                .clone()
        };
        client.send_session_command(session_id, "NextTrack").await?;
    } else {
        // Local playback
        let controller = player.0.lock().await;
        // Prefer downloads that completed since the queue was built
        if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
            warn!("[player_next] Failed to refresh local sources: {}", e);
        }
        controller.next().map_err(|e| e.to_string())?;
        controller.emit_queue_changed();

        // Update audio session track if in audio session
        let current_item = {
            let queue = controller.queue();
            let queue_lock = queue.lock().map_err(|e| e.to_string())?;
            queue_lock.current().cloned()
        };

        if let Some(item) = current_item {
            if item.media_type == MediaType::Audio {
                let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
                session_mgr.update_audio_track(item);

                // Emit session changed event
                if let Some(emitter) = controller.event_emitter() {
                    emitter.emit(PlayerStatusEvent::SessionChanged {
                        session: session_mgr.current().clone(),
                    });
                }
            }
        }
    }

    let controller = player.0.lock().await;
    Ok(get_player_status(&controller))
}

#[tauri::command]
#[specta::specta]
pub async fn player_previous(
    player: State<'_, PlayerStateWrapper>,
    session: State<'_, MediaSessionManagerWrapper>,
    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
    db: State<'_, DatabaseWrapper>,
) -> Result<PlayerStatus, String> {
    // Check if we're in remote mode
    let mode = playback_mode.0.get_mode();

    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
        // Send previous track command to remote session - clone client before await
        let client = {
            let controller = player.0.lock().await;
            let client_arc = controller.jellyfin_client();
            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
            client_opt
                .as_ref()
                .ok_or("Jellyfin client not configured")?
                .clone()
        };
        client
            .send_session_command(session_id, "PreviousTrack")
            .await?;
    } else {
        // Local playback
        let controller = player.0.lock().await;
        // Prefer downloads that completed since the queue was built
        if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
            warn!("[player_previous] Failed to refresh local sources: {}", e);
        }
        controller.previous().map_err(|e| e.to_string())?;
        controller.emit_queue_changed();

        // Update audio session track if in audio session
        let current_item = {
            let queue = controller.queue();
            let queue_lock = queue.lock().map_err(|e| e.to_string())?;
            queue_lock.current().cloned()
        };

        if let Some(item) = current_item {
            if item.media_type == MediaType::Audio {
                let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
                session_mgr.update_audio_track(item);

                // Emit session changed event
                if let Some(emitter) = controller.event_emitter() {
                    emitter.emit(PlayerStatusEvent::SessionChanged {
                        session: session_mgr.current().clone(),
                    });
                }
            }
        }
    }

    let controller = player.0.lock().await;
    Ok(get_player_status(&controller))
}

#[tauri::command]
#[specta::specta]
pub async fn player_seek(
    player: State<'_, PlayerStateWrapper>,
    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
    position: f64,
) -> Result<PlayerStatus, String> {
    // Check if we're in remote mode
    let mode = playback_mode.0.get_mode();

    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
        // Send seek command to remote session - clone client before await
        let client = {
            let controller = player.0.lock().await;
            let client_arc = controller.jellyfin_client();
            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
            client_opt
                .as_ref()
                .ok_or("Jellyfin client not configured")?
                .clone()
        };
        let position_ticks = (position * 10_000_000.0) as i64;
        client.session_seek(session_id, position_ticks).await?;
    } else {
        // Local playback
        let controller = player.0.lock().await;
        controller.seek(position).map_err(|e| e.to_string())?;
    }

    let controller = player.0.lock().await;
    Ok(get_player_status(&controller))
}

/// Smart video seeking that decides between native and server-side seeking
///
/// This command analyzes the current video stream and automatically chooses
/// the best seeking strategy:
/// - HLS streams (.m3u8): Use native seeking
/// - Direct play streams: Use native seeking
/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
///
/// For native (non-HTML5) backends, this command handles the entire stream reload
/// internally. For HTML5 backends, it returns the new URL for the frontend to handle.
#[tauri::command]
#[specta::specta]
pub async fn player_seek_video(
    player: State<'_, PlayerStateWrapper>,
    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
    repository_handle: String,
    position: f64,
    media_source_id: Option<String>,
    audio_stream_index: Option<i32>,
    use_html5: bool,
) -> Result<VideoSeekResponse, String> {
    info!(
        "[player_seek_video] Seeking to {} seconds (use_html5: {})",
        position, use_html5
    );

    // Get repository
    let repository = repository_manager
        .0
        .get(&repository_handle)
        .ok_or("Repository not found - user may need to log in")?;

    // Get current playing item to analyze stream characteristics
    // Clone what we need to avoid holding locks across await points
    let (needs_transcoding, jellyfin_item_id, stream_url, is_local) = {
        let controller = player.0.lock().await;
        let queue_arc = controller.queue();
        let queue = queue_arc.lock().map_err(|e| e.to_string())?;

        let current_item = queue.current().ok_or("No item currently playing")?;

        if current_item.media_type != MediaType::Video {
            return Err("Current item is not a video".to_string());
        }

        let jellyfin_id = current_item
            .jellyfin_id()
            .ok_or("Current video has no Jellyfin ID")?
            .to_string();

        let (stream_url, is_local_file) = match &current_item.source {
            MediaSource::Remote { stream_url, .. } => (stream_url.clone(), false),
            MediaSource::Local { .. } => (String::new(), true),
            MediaSource::DirectUrl { url } => (url.clone(), false),
        };

        let needs_trans = current_item.needs_transcoding;
        (needs_trans, jellyfin_id, stream_url, is_local_file)
    }; // Locks are dropped here

    // Determine seek strategy using the testable helper function
    let is_hls = stream_url.contains(".m3u8");
    let strategy = determine_video_seek_strategy(is_local, is_hls, needs_transcoding, use_html5);

    info!("[player_seek_video] Stream analysis: is_local={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}",
        is_local, is_hls, needs_transcoding, use_html5, strategy);

    match strategy {
        VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
            // Local files and native backend streams - call backend.seek()
            info!("[player_seek_video] Using backend native seek");
            let controller = player.0.lock().await;
            controller.seek(position).map_err(|e| e.to_string())?;
            Ok(VideoSeekResponse::Native { position })
        }
        VideoSeekStrategy::Html5NativeSeek => {
            // HTML5 backend with HLS or direct play - frontend handles seeking
            // We don't call backend.seek() because video is in HTML5 element, not in MPV
            info!("[player_seek_video] HTML5 native seek - returning position for frontend");
            Ok(VideoSeekResponse::Native { position })
        }
        VideoSeekStrategy::Html5ReloadStream => {
            // Transcoded non-HLS with HTML5 - frontend handles stream reload
            info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");

            let new_url = repository
                .get_video_stream_url(
                    &jellyfin_item_id,
                    media_source_id.as_deref(),
                    Some(position),
                    audio_stream_index,
                )
                .await
                .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;

            info!(
                "[player_seek_video] Got new stream URL for position {}",
                position
            );

            Ok(VideoSeekResponse::ReloadStream {
                new_url,
                seek_offset: position,
            })
        }
        VideoSeekStrategy::BackendReloadStream => {
            // Transcoded non-HLS with native backend - backend handles stream reload
            info!("[player_seek_video] Backend reload stream - requesting new stream URL");

            let new_url = repository
                .get_video_stream_url(
                    &jellyfin_item_id,
                    media_source_id.as_deref(),
                    Some(position),
                    audio_stream_index,
                )
                .await
                .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;

            info!("[player_seek_video] Got new stream URL, handling reload internally");

            // Stop current playback
            {
                let controller = player.0.lock().await;
                controller.stop().map_err(|e| e.to_string())?;
            }

            // Update the stream URL in the queue
            {
                let controller = player.0.lock().await;
                let queue_arc = controller.queue();
                let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;

                if !queue.update_current_stream_url(new_url.clone()) {
                    return Err("Failed to update stream URL in queue".to_string());
                }
            }

            // Reload the player with the updated item from queue
            {
                let controller = player.0.lock().await;
                let queue_arc = controller.queue();
                let queue = queue_arc.lock().map_err(|e| e.to_string())?;

                if let Some(updated_item) = queue.current() {
                    controller
                        .load_and_play(updated_item)
                        .map_err(|e| e.to_string())?;
                } else {
                    return Err("No current item after URL update".to_string());
                }
            }

            info!(
                "[player_seek_video] Stream reloaded successfully at position {}",
                position
            );

            Ok(VideoSeekResponse::Native { position })
        }
    }
}

/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
/// Note: Frontend should handle saving series preferences after this command succeeds
#[tauri::command]
#[specta::specta]
pub async fn player_switch_audio_track(
    player: State<'_, PlayerStateWrapper>,
    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
    repository_handle: String,
    stream_index: i32,
    array_index: i32,
    use_html5: bool,
    current_position: Option<f64>,
    media_source_id: Option<String>,
) -> Result<AudioTrackSwitchResponse, String> {
    info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
        stream_index, array_index, use_html5);

    if use_html5 {
        // HTML5 backend needs stream reload
        let repository = repository_manager
            .0
            .get(&repository_handle)
            .ok_or("Repository not found - user may need to log in")?;

        // Get current item to find Jellyfin ID
        let jellyfin_item_id = {
            let controller = player.0.lock().await;
            let queue_arc = controller.queue();
            let queue = queue_arc.lock().map_err(|e| e.to_string())?;

            let current_item = queue.current().ok_or("No item currently playing")?;

            current_item
                .jellyfin_id()
                .ok_or("Current item has no Jellyfin ID")?
                .to_string()
        };

        // Get new stream URL with selected audio track
        let new_url = repository
            .get_video_stream_url(
                &jellyfin_item_id,
                media_source_id.as_deref(),
                current_position,
                Some(stream_index),
            )
            .await
            .map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;

        Ok(AudioTrackSwitchResponse::ReloadStream {
            new_url,
            position: current_position.unwrap_or(0.0),
        })
    } else {
        // Native backend (Android ExoPlayer) - use array index
        let controller = player.0.lock().await;
        controller
            .set_audio_track(array_index)
            .map_err(|e| e.to_string())?;

        Ok(AudioTrackSwitchResponse::Native { success: true })
    }
}

#[tauri::command]
#[specta::specta]
pub async fn player_set_audio_track(
    player: State<'_, PlayerStateWrapper>,
    stream_index: i32,
) -> Result<PlayerStatus, String> {
    let controller = player.0.lock().await;
    controller
        .set_audio_track(stream_index)
        .map_err(|e| e.to_string())?;
    Ok(get_player_status(&controller))
}

#[tauri::command]
#[specta::specta]
pub async fn player_set_subtitle_track(
    player: State<'_, PlayerStateWrapper>,
    stream_index: Option<i32>,
) -> Result<PlayerStatus, String> {
    let controller = player.0.lock().await;
    controller
        .set_subtitle_track(stream_index)
        .map_err(|e| e.to_string())?;
    Ok(get_player_status(&controller))
}

#[tauri::command]
#[specta::specta]
pub async fn player_set_volume(
    player: State<'_, PlayerStateWrapper>,
    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
    volume: f32,
) -> Result<PlayerStatus, String> {
    // Check if we're in remote mode
    let mode = playback_mode.0.get_mode();

    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
        // Send volume command to remote session - clone client before await
        let client = {
            let controller = player.0.lock().await;
            let client_arc = controller.jellyfin_client();
            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
            client_opt
                .as_ref()
                .ok_or("Jellyfin client not configured")?
                .clone()
        };
        // Convert 0-1 range to 0-100 for Jellyfin API
        let volume_percent = (volume * 100.0) as i32;
        client
            .session_set_volume(session_id, volume_percent)
            .await?;
    } else {
        // Local playback
        let controller = player.0.lock().await;
        controller.set_volume(volume).map_err(|e| e.to_string())?;
    }

    let controller = player.0.lock().await;
    Ok(get_player_status(&controller))
}

#[tauri::command]
#[specta::specta]
pub async fn player_toggle_mute(
    player: State<'_, PlayerStateWrapper>,
    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
    // Check if we're in remote mode
    let mode = playback_mode.0.get_mode();

    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
        // Send toggle mute command to remote session - clone client before await
        let client = {
            let controller = player.0.lock().await;
            let client_arc = controller.jellyfin_client();
            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
            client_opt
                .as_ref()
                .ok_or("Jellyfin client not configured")?
                .clone()
        };
        client
            .send_session_command(session_id, "ToggleMute")
            .await?;
    } else {
        // Local playback
        // TODO: Implement toggle_mute in PlayerController
        // let controller = player.0.lock().await;
        // controller.toggle_mute().map_err(|e| e.to_string())?;
    }

    let controller = player.0.lock().await;
    Ok(get_player_status(&controller))
}

#[tauri::command]
#[specta::specta]
pub async fn player_toggle_shuffle(
    player: State<'_, PlayerStateWrapper>,
) -> Result<QueueStatus, String> {
    let controller = player.0.lock().await;
    controller.toggle_shuffle();
    controller.emit_queue_changed();
    Ok(get_queue_status(&controller))
}

#[tauri::command]
#[specta::specta]
pub async fn player_cycle_repeat(
    player: State<'_, PlayerStateWrapper>,
) -> Result<QueueStatus, String> {
    let controller = player.0.lock().await;
    controller.cycle_repeat();
    controller.emit_queue_changed();
    Ok(get_queue_status(&controller))
}

#[tauri::command]
#[specta::specta]
pub async fn player_get_status(
    player: State<'_, PlayerStateWrapper>,
    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
    let mode = playback_mode.0.get_mode();

    // Get base local status
    let controller = player.0.lock().await;
    let mut status = get_player_status(&controller);

    // Get local media from queue
    let local_media = {
        let queue_arc = controller.queue();
        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
        queue.current().map(|item| MergedMediaItem::from(item))
    };

    let local_is_playing = status.state.is_playing();
    let local_volume = status.volume;

    // If in remote mode, fetch session and merge state
    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
        let client = {
            let client_arc = controller.jellyfin_client();
            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
            client_opt
                .as_ref()
                .ok_or("Jellyfin client not configured")?
                .clone()
        };

        drop(controller); // Release lock before async call

        match client.get_session(&session_id).await {
            Ok(Some(session)) => {
                log::info!("[PlayerCommands] Merging remote session state");

                // Merge media item
                status.merged_media = session
                    .now_playing_item
                    .as_ref()
                    .map(|item| MergedMediaItem::from(item));

                // Merge isPlaying (NOT isPaused!)
                status.merged_is_playing = session
                    .play_state
                    .as_ref()
                    .map(|ps| !ps.is_paused.unwrap_or(true))
                    .unwrap_or(false);

                // Merge position (convert ticks to seconds)
                status.position = session
                    .play_state
                    .as_ref()
                    .and_then(|ps| ps.position_ticks)
                    .map(|ticks| ticks as f64 / 10_000_000.0)
                    .unwrap_or(0.0);

                // Merge duration (convert ticks to seconds)
                status.duration = session
                    .now_playing_item
                    .as_ref()
                    .and_then(|item| item.run_time_ticks)
                    .map(|ticks| ticks as f64 / 10_000_000.0);

                // Merge volume (convert 0-100 → 0-1)
                status.merged_volume = session
                    .play_state
                    .as_ref()
                    .and_then(|ps| ps.volume_level)
                    .map(|vol| (vol.clamp(0, 100) as f32) / 100.0)
                    .unwrap_or(1.0);

                return Ok(status);
            }
            Ok(None) => {
                log::warn!("[PlayerCommands] Remote session not found, using local state");
            }
            Err(e) => {
                log::warn!("[PlayerCommands] Failed to fetch remote session: {}", e);
            }
        }
    }

    // Local playback or fallback
    status.merged_media = local_media;
    status.merged_is_playing = local_is_playing;
    status.merged_volume = local_volume;

    Ok(status)
}

#[tauri::command]
#[specta::specta]
pub async fn player_get_queue(
    player: State<'_, PlayerStateWrapper>,
) -> Result<QueueStatus, String> {
    let controller = player.0.lock().await;
    Ok(get_queue_status(&controller))
}

/// What playback facilities this platform's backend actually provides.
///
/// The frontend is presentation-only and must not re-derive backend facts from
/// `navigator.userAgent` — that sniffing was a second copy of the same platform
/// decision Rust already makes with `cfg!`, and it drifted. These flags are the
/// single source of truth; the frontend consumes them.
///
/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaybackCapabilities {
    /// True when audio is rendered by a webview `<audio>` element rather than a
    /// native backend. Native audio exists on Linux (mpv) and Android
    /// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
    pub uses_webview_audio: bool,
    /// True when video can be rendered by a native surface composited *behind*
    /// a transparent webview. Android only: ExoPlayer draws into a SurfaceView
    /// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
    /// compositing), so it stays on the HTML5 element.
    pub supports_native_video: bool,
}

/// Report this platform's playback capabilities to the frontend.
///
/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
#[tauri::command]
#[specta::specta]
pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
    // Mirrors the cfg gates the backends themselves are built under.
    let native_audio = cfg!(any(target_os = "android", target_os = "linux"));

    Ok(PlaybackCapabilities {
        uses_webview_audio: !native_audio,
        supports_native_video: cfg!(target_os = "android"),
    })
}

pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
    // Determine backend at compile time based on platform
    let (backend, use_html5_element) = if cfg!(target_os = "android") {
        // Android uses ExoPlayer native backend
        (VideoBackend::Native, false)
    } else {
        // Linux and other platforms use HTML5 video element in frontend
        (VideoBackend::Html5, true)
    };

    PlayerStatus {
        state: controller.state(),
        position: controller.position(),
        duration: controller.duration(),
        volume: controller.volume(),
        muted: controller.muted(),
        shuffle: controller.is_shuffle(),
        repeat: controller.repeat_mode(),
        backend,
        use_html5_element,

        // Merged fields initialized to defaults (will be set by player_get_status)
        merged_media: None,
        merged_is_playing: false,
        merged_volume: controller.volume(),
    }
}

pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
    let queue = controller.queue();
    let queue_lock = queue.lock_safe();

    QueueStatus {
        items: queue_lock.items().to_vec(),
        current_index: queue_lock.current_index(),
        shuffle: queue_lock.is_shuffle(),
        repeat: queue_lock.repeat_mode(),
        has_next: queue_lock.has_next(),
        has_previous: queue_lock.has_previous(),
    }
}

/// Start a freshly-built queue on the active remote session.
///
/// Used by the "play tracks"/"play album track" commands when we're in remote
/// mode: instead of starting local MPV playback, we cast the selected tracks to
/// the remote device. Mirrors PlaybackModeManager::transfer_to_remote's
/// play_on_session call, but for a brand-new selection (so there's no resume
/// position - playback starts from the chosen track's beginning).
///
/// Local-only items (no Jellyfin ID) can't be cast, so they're filtered out and
/// the start index is adjusted to the remaining Jellyfin items. Returns an error
/// if the selected track itself has no Jellyfin ID.
async fn play_selection_on_remote(
    controller: &PlayerController,
    session_id: &str,
    media_items: &[MediaItem],
    start_index: usize,
) -> Result<(), String> {
    // Collect Jellyfin IDs, tracking where the selected track lands after any
    // local-only items are dropped.
    let mut jellyfin_ids: Vec<String> = Vec::new();
    let mut adjusted_index: Option<usize> = None;
    for (i, item) in media_items.iter().enumerate() {
        if let Some(id) = item.jellyfin_id() {
            if i == start_index {
                adjusted_index = Some(jellyfin_ids.len());
            }
            jellyfin_ids.push(id.to_string());
        }
    }

    let start_index =
        adjusted_index.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;

    if jellyfin_ids.is_empty() {
        return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
    }

    let client = {
        let client_arc = controller.jellyfin_client();
        let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
        client_opt
            .as_ref()
            .ok_or("Jellyfin client not configured")?
            .clone()
    };

    // Fresh selection: start from the beginning of the chosen track.
    client
        .play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
        .await
        .map_err(|e| format!("Failed to start playback on remote session: {}", e))
}

/// Play a track from an album - backend fetches all album tracks and builds queue
#[tauri::command]
#[specta::specta]
pub async fn player_play_album_track(
    player: State<'_, PlayerStateWrapper>,
    session: State<'_, MediaSessionManagerWrapper>,
    db: State<'_, DatabaseWrapper>,
    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
    repository_handle: String,
    request: PlayAlbumTrackRequest,
) -> Result<PlayerStatus, String> {
    info!(
        "player_play_album_track called: album_id={}, track_id={}, shuffle={}",
        request.album_id, request.track_id, request.shuffle
    );

    // Get repository (hybrid - supports offline/online)
    let repository = repository_manager
        .0
        .get(&repository_handle)
        .ok_or("Repository not found - user may need to log in")?;

    // Fetch all tracks from the album via hybrid repository
    info!(
        "Fetching tracks for album {} via repository",
        request.album_id
    );
    let album_items = repository
        .get_items(
            &request.album_id,
            Some(GetItemsOptions {
                limit: Some(1000),
                fields: Some(vec![
                    "PrimaryImageAspectRatio".to_string(),
                    "Overview".to_string(),
                    "MediaStreams".to_string(),
                ]),
                ..Default::default()
            }),
        )
        .await
        .map_err(|e| format!("Failed to fetch album tracks: {}", e))?;

    info!("Found {} items in album", album_items.items.len());

    // Filter to only Audio items and sort by index
    let mut tracks: Vec<_> = album_items
        .items
        .into_iter()
        .filter(|item| item.item_type == "Audio")
        .collect();
    tracks.sort_by_key(|t| t.index_number.unwrap_or(0));

    info!("Found {} audio tracks in album", tracks.len());

    if tracks.is_empty() {
        return Err("No audio tracks found in album".to_string());
    }

    // Debug: Log all track IDs and their indices
    info!("Album has {} tracks after sorting:", tracks.len());
    for (idx, track) in tracks.iter().enumerate() {
        info!("  [{}] {} (ID: {})", idx, track.name, track.id);
    }

    // Validate the requested track exists in the album (its position in the
    // final queue is computed after building, since offline tracks are skipped).
    info!("Looking for track_id: {}", request.track_id);
    let album_index = tracks
        .iter()
        .position(|t| t.id == request.track_id)
        .ok_or_else(|| format!("Track {} not found in album", request.track_id))?;

    info!(
        "Track {} is at index {} in album",
        request.track_id, album_index
    );

    // Convert tracks to MediaItems
    let mut media_items = Vec::new();
    for track in tracks {
        // Check for local download first
        let jellyfin_id = &track.id;
        let local_path = check_for_local_download(&db, jellyfin_id).await?;

        let source = if let Some(path) = local_path {
            MediaSource::Local {
                file_path: PathBuf::from(path),
                jellyfin_item_id: Some(jellyfin_id.clone()),
            }
        } else {
            // Non-downloaded track: needs a stream URL from the server. When the
            // server is unreachable (offline), skip this track rather than failing
            // the whole album — downloaded tracks must still be playable.
            match repository.get_audio_stream_url(&track.id).await {
                Ok(stream_url) => MediaSource::Remote {
                    stream_url,
                    jellyfin_item_id: jellyfin_id.clone(),
                },
                Err(e) => {
                    warn!(
                        "[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
                        track.name, track.id, e
                    );
                    continue;
                }
            }
        };

        let primary_image_tag_for_url = track.primary_image_tag.clone();
        let media_item = MediaItem {
            id: track.id.clone(),
            title: track.name.clone(),
            name: Some(track.name.clone()), // Frontend compatibility
            artist: track
                .album_artist
                .clone()
                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
            album: Some(request.album_name.clone()),
            album_name: Some(request.album_name.clone()), // Frontend compatibility
            album_id: Some(request.album_id.clone()),
            artist_items: track.artist_items.clone(), // For clickable artist links
            artists: track.artists.clone(),           // Fallback artist info
            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
            image_id: track.primary_image_tag.clone(),
            item_type: Some(track.item_type.clone()), // Frontend compatibility
            playlist_id: None,
            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
            artwork_url: primary_image_tag_for_url.map(|tag| {
                repository.get_image_url(
                    &request.album_id,
                    ImageType::Primary,
                    Some(ImageOptions {
                        max_width: Some(300),
                        tag: Some(tag),
                        ..Default::default()
                    }),
                )
            }),
            media_type: MediaType::Audio,
            source,
            video_codec: None,
            needs_transcoding: false,
            video_width: None,
            video_height: None,
            subtitles: vec![],
            series_id: None,
            server_id: None,
        };

        media_items.push(media_item);
    }

    if media_items.is_empty() {
        return Err("No playable tracks available (offline and nothing downloaded)".to_string());
    }

    // Tracks with no local download and no reachable server were skipped above,
    // so positions shifted. Re-locate the requested track in the built queue.
    // If the tapped track itself was skipped, fall back to the first item.
    let start_index = media_items
        .iter()
        .position(|item| item.id == request.track_id)
        .unwrap_or(0);

    info!(
        "Built queue with {} media items, starting at index {}",
        media_items.len(),
        start_index
    );

    // Handle shuffle before setting queue
    if request.shuffle {
        let controller = player.0.lock().await;
        controller.toggle_shuffle();
    }

    // Start audio session with the first item
    if let Some(first_item) = media_items.get(start_index) {
        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
        session_mgr.start_audio_session(first_item.clone());
    }

    let controller = player.0.lock().await;

    // When controlling a remote session, cast the selection there instead of
    // starting local MPV playback. We still load the queue locally (below) so
    // the queue/context stay in sync for the UI and for transferring back.
    let remote_session = match playback_mode.0.get_mode() {
        crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
        _ => None,
    };

    if let Some(session_id) = &remote_session {
        play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
        controller
            .set_queue(media_items, start_index)
            .map_err(|e| e.to_string())?;
    } else {
        // Local playback is now authoritative (see player_play_tracks); set it
        // before starting so the mode-changed event precedes the state events.
        playback_mode
            .0
            .set_mode(crate::playback_mode::PlaybackMode::Local);
        controller
            .play_queue(media_items, start_index)
            .map_err(|e| e.to_string())?;
    }

    // Set the queue context for remote transfer
    {
        let queue_arc = controller.queue();
        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
        queue.set_context(QueueContext::Album {
            album_id: request.album_id.clone(),
            album_name: request.album_name.clone(),
        });
    }

    // Emit queue changed event
    controller.emit_queue_changed();

    // Log final queue state
    {
        let queue_arc = controller.queue();
        let queue_lock = queue_arc.lock().map_err(|e| e.to_string())?;
        info!(
            "player_play_album_track: Queue now has {} items, current_index: {:?}",
            queue_lock.items().len(),
            queue_lock.current_index()
        );
    }

    // Emit session changed event
    if let Some(emitter) = controller.event_emitter() {
        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
        emitter.emit(PlayerStatusEvent::SessionChanged {
            session: session_mgr.current().clone(),
        });
    }

    Ok(get_player_status(&controller))
}

/// Play tracks by ID - backend fetches all metadata
#[tauri::command]
#[specta::specta]
pub async fn player_play_tracks(
    player: State<'_, PlayerStateWrapper>,
    session: State<'_, MediaSessionManagerWrapper>,
    db: State<'_, DatabaseWrapper>,
    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
    repository_handle: String,
    request: PlayTracksRequest,
) -> Result<PlayerStatus, String> {
    info!(
        "player_play_tracks called: {} tracks, start_index={}, shuffle={}",
        request.track_ids.len(),
        request.start_index,
        request.shuffle
    );

    // Validate input
    if request.track_ids.is_empty() {
        return Err("No tracks provided".to_string());
    }

    // Get repository
    let repository = repository_manager
        .0
        .get(&repository_handle)
        .ok_or("Repository not found - user may need to log in")?;

    // Fetch metadata for all tracks
    let mut media_items = Vec::new();
    for track_id in &request.track_ids {
        // Fetch track metadata from repository
        let track = repository
            .get_item(track_id)
            .await
            .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;

        // Check for local download
        let local_path = check_for_local_download(&db, track_id).await?;

        // Build MediaSource
        let source = if let Some(path) = local_path {
            MediaSource::Local {
                file_path: PathBuf::from(path),
                jellyfin_item_id: Some(track.id.clone()),
            }
        } else {
            let stream_url = repository
                .get_audio_stream_url(track_id)
                .await
                .map_err(|e| format!("Failed to get stream URL: {}", e))?;

            MediaSource::Remote {
                stream_url,
                jellyfin_item_id: track.id.clone(),
            }
        };

        // Transform to MediaItem with frontend-compatible fields
        let primary_image_tag_for_url = track.primary_image_tag.clone();
        let media_item = MediaItem {
            id: track.id.clone(),
            title: track.name.clone(),
            name: Some(track.name.clone()), // Frontend compatibility
            artist: track
                .album_artist
                .clone()
                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
            album: track.album_name.clone(),
            album_name: track.album_name.clone(), // Frontend compatibility
            album_id: track.album_id.clone(),
            artist_items: track.artist_items.clone(), // For clickable artist links
            artists: track.artists.clone(),           // Fallback artist info
            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
            image_id: track.primary_image_tag.clone(),
            item_type: Some(track.item_type.clone()), // Frontend compatibility
            playlist_id: None,                        // Set based on context below
            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
            artwork_url: primary_image_tag_for_url.and_then(|tag| {
                track.album_id.as_ref().map(|album_id| {
                    repository.get_image_url(
                        album_id,
                        ImageType::Primary,
                        Some(ImageOptions {
                            max_width: Some(300),
                            tag: Some(tag),
                            ..Default::default()
                        }),
                    )
                })
            }),
            media_type: MediaType::Audio,
            source,
            video_codec: None,
            needs_transcoding: false,
            video_width: None,
            video_height: None,
            subtitles: vec![],
            series_id: None,
            server_id: None,
        };

        media_items.push(media_item);
    }

    info!("Built queue with {} media items", media_items.len());

    // Map context and set playlist_id
    let queue_context = match request.context {
        PlayTracksContext::Playlist {
            playlist_id,
            playlist_name,
        } => {
            for item in &mut media_items {
                item.playlist_id = Some(playlist_id.clone());
            }
            QueueContext::Playlist {
                playlist_id,
                playlist_name,
            }
        }
        PlayTracksContext::Search { .. } | PlayTracksContext::Custom { .. } => QueueContext::Custom,
    };

    // Handle shuffle
    if request.shuffle {
        let controller = player.0.lock().await;
        controller.toggle_shuffle();
    }

    // Start session
    if let Some(first_item) = media_items.get(request.start_index) {
        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
        session_mgr.start_audio_session(first_item.clone());
    }

    let controller = player.0.lock().await;

    // When controlling a remote session, cast the selection there instead of
    // starting local MPV playback. Skip this while a transfer is in flight: the
    // transfer-to-local path calls this command to load the queue locally and
    // the mode is still Remote until the transfer completes - routing it back to
    // the remote would undo the transfer.
    let remote_session = match playback_mode.0.get_mode() {
        crate::playback_mode::PlaybackMode::Remote { session_id }
            if !playback_mode.0.is_transferring() =>
        {
            Some(session_id)
        }
        _ => None,
    };

    if let Some(session_id) = &remote_session {
        play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
            .await?;
        controller
            .set_queue(media_items, request.start_index)
            .map_err(|e| e.to_string())?;
    } else {
        // Starting local playback makes Local the authoritative mode. Without
        // this, a prior Remote mode lingers in the manager and later play/pause
        // commands route back to the (stopped) remote session. Set it BEFORE
        // starting playback so the PlaybackModeChanged event reaches the frontend
        // ahead of the state_changed events it will emit — otherwise the frontend
        // (still thinking it's remote) filters those state events out. Skip during
        // a transfer: transfer_to_local drives the mode itself once complete.
        if !playback_mode.0.is_transferring() {
            playback_mode
                .0
                .set_mode(crate::playback_mode::PlaybackMode::Local);
        }
        controller
            .play_queue_from(media_items, request.start_index, request.start_position)
            .map_err(|e| e.to_string())?;
    }

    // Set queue context
    {
        let queue_arc = controller.queue();
        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
        queue.set_context(queue_context);
    }

    // Emit events
    controller.emit_queue_changed();
    if let Some(emitter) = controller.event_emitter() {
        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
        emitter.emit(PlayerStatusEvent::SessionChanged {
            session: session_mgr.current().clone(),
        });
    }

    info!("player_play_tracks completed successfully");
    Ok(get_player_status(&controller))
}

/// Response for preload operation
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PreloadResult {
    /// Number of tracks queued for preload
    pub queued_count: usize,
    /// Number of tracks already downloaded
    pub already_downloaded: usize,
    /// Number of tracks skipped (no jellyfin ID or other reasons)
    pub skipped: usize,
}

/// Preload upcoming tracks from the queue
/// This queues background downloads for the next N tracks that aren't already downloaded
#[tauri::command]
#[specta::specta]
pub async fn player_preload_upcoming(
    player: State<'_, PlayerStateWrapper>,
    db: State<'_, DatabaseWrapper>,
    smart_cache: State<'_, SmartCacheWrapper>,
    download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
    app: tauri::AppHandle,
    user_id: String,
    _download_base_path: String,
) -> Result<PreloadResult, String> {
    // The pump only starts rows that carry both a stream URL and a target dir,
    // so resolve the same storage root the user-initiated download paths use
    // (storage_get_path = the database's parent directory).
    let (db_service, target_dir) = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        let target_dir = database
            .path()
            .parent()
            .ok_or_else(|| "Database path has no parent directory".to_string())?
            .to_string_lossy()
            .to_string();
        (Arc::new(database.service()), target_dir)
    };

    // Get cache settings
    let precache_count = {
        let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
        if !cache.should_precache_queue() {
            return Ok(PreloadResult {
                queued_count: 0,
                already_downloaded: 0,
                skipped: 0,
            });
        }
        cache.queue_precache_count()
    };

    // Get upcoming items from queue
    let upcoming_items: Vec<MediaItem> = {
        let controller = player.0.lock().await;
        let queue = controller.queue();
        let queue_lock = queue.lock().map_err(|e| e.to_string())?;
        queue_lock
            .get_upcoming(precache_count)
            .into_iter()
            .cloned()
            .collect()
    };

    if upcoming_items.is_empty() {
        return Ok(PreloadResult {
            queued_count: 0,
            already_downloaded: 0,
            skipped: 0,
        });
    }

    let mut queued_count = 0;
    let mut already_downloaded = 0;
    let mut skipped = 0;

    // Process each upcoming item
    for item in upcoming_items {
        // Only process items with Remote source (not already local). The
        // source already carries the resolved stream URL — reuse it so the
        // pump can start the download without any extra resolution step.
        let (jellyfin_id, stream_url) = match &item.source {
            MediaSource::Remote {
                jellyfin_item_id,
                stream_url,
            } => (jellyfin_item_id.clone(), stream_url.clone()),
            MediaSource::Local { .. } => {
                already_downloaded += 1;
                continue;
            }
            MediaSource::DirectUrl { .. } => {
                skipped += 1;
                continue;
            }
        };

        // Check if already downloaded or actively in flight. Stale pending rows
        // without a stream URL are NOT skipped here — the upsert below heals
        // them so the pump can finally start them.
        let query = Query::with_params(
            "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
             AND (status IN ('completed', 'downloading')
                  OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
            vec![
                QueryParam::String(jellyfin_id.clone()),
                QueryParam::String(user_id.clone()),
            ],
        );

        let is_downloaded: Option<String> = db_service
            .query_optional(query, |row| row.get(0))
            .await
            .map_err(|e| e.to_string())?;

        if is_downloaded.is_some() {
            already_downloaded += 1;
            continue;
        }

        // Queue for download with low priority (preload priority = -100) so
        // user-initiated downloads always win a pump slot first.
        let album_dir = item
            .album
            .as_deref()
            .filter(|a| !a.is_empty())
            .unwrap_or("Unknown Album");
        let file_path = format!(
            "downloads/{}/{}.mp3",
            sanitize_filename(album_dir),
            sanitize_filename(&item.title)
        );

        // Insert with the stream URL + target dir the pump needs to start it.
        // On conflict, heal pre-existing rows that were queued without a URL
        // (they could never start) instead of leaving them stuck.
        let insert_query = Query::with_params(
            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, download_source, media_type, stream_url, target_dir)
             VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
             ON CONFLICT(item_id, user_id) DO UPDATE SET
                 stream_url = excluded.stream_url,
                 target_dir = excluded.target_dir
             WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
            vec![
                QueryParam::String(jellyfin_id),
                QueryParam::String(user_id.clone()),
                QueryParam::String(file_path),
                QueryParam::String(item.title.clone()),
                item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
                item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
                QueryParam::String(stream_url),
                QueryParam::String(target_dir.clone()),
            ],
        );

        match db_service.execute(insert_query).await {
            Ok(rows) if rows > 0 => {
                info!(
                    "[Preload] Queued download for: {} - {}",
                    item.artist.as_deref().unwrap_or("Unknown"),
                    item.title
                );
                queued_count += 1;
            }
            Ok(_) => {
                // Row already exists (conflict), count as already queued
                already_downloaded += 1;
            }
            Err(e) => {
                error!("[Preload] Failed to queue {}: {}", item.title, e);
                skipped += 1;
            }
        }
    }

    // Kick the pump so the queued preloads actually start; without this they'd
    // only begin once some other download activity pumps the queue.
    if queued_count > 0 {
        let active_downloads = {
            let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
            manager.get_active_downloads()
        };
        crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
    }

    info!(
        "[Preload] Result: queued={}, already_downloaded={}, skipped={}",
        queued_count, already_downloaded, skipped
    );

    Ok(PreloadResult {
        queued_count,
        already_downloaded,
        skipped,
    })
}

/// Sanitize filename by removing invalid characters
fn sanitize_filename(name: &str) -> String {
    name.chars()
        .map(|c| match c {
            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
            _ => c,
        })
        .collect()
}

/// Update SmartCache configuration
#[tauri::command]
#[specta::specta]
pub async fn player_set_cache_config(
    smart_cache: State<'_, SmartCacheWrapper>,
    config: CacheConfig,
) -> Result<(), String> {
    let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
    cache.update_config(config);
    Ok(())
}

/// Get current SmartCache configuration
#[tauri::command]
#[specta::specta]
pub async fn player_get_cache_config(
    smart_cache: State<'_, SmartCacheWrapper>,
) -> Result<CacheConfig, String> {
    let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
    Ok(cache.get_config().unwrap_or_default())
}

/// Configure Jellyfin API client for automatic playback reporting
#[tauri::command]
#[specta::specta]
pub async fn player_configure_jellyfin(
    player: State<'_, PlayerStateWrapper>,
    db: State<'_, DatabaseWrapper>,
    server_url: String,
    access_token: String,
    user_id: String,
    device_id: String,
) -> Result<(), String> {
    log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");

    let config = JellyfinConfig {
        server_url,
        access_token,
        device_id,
    };

    // Legacy client (used for remote session control / casting).
    let client = JellyfinClient::new(config.clone())?;

    // Build the PlaybackReporter the player and backends (MPV + ExoPlayer)
    // actually report through. Without this, Start/Progress/Stopped never reach
    // Jellyfin, so playback position never syncs and you can't resume on another
    // device. The reporter shares the player controller's Arc, so populating it
    // here lights up reporting on both desktop and Android, on every auth path
    // that configures the player (login / restore / reauth).
    let db_service = {
        let database = db.0.lock().map_err(|e| e.to_string())?;
        Arc::new(database.service())
    };
    let reporter_client = JellyfinClient::new(config)?;
    let reporter = crate::playback_reporting::PlaybackReporter::new(
        db_service,
        Arc::new(TokioMutex::new(Some(reporter_client))),
        user_id,
    );

    let controller = player.0.lock().await;
    controller.set_jellyfin_client(Some(client));
    controller.set_playback_reporter(Some(reporter)).await;

    log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
    Ok(())
}

/// Disable Jellyfin automatic playback reporting
#[tauri::command]
#[specta::specta]
pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> Result<(), String> {
    log::info!("[PlayerCommand] Disabling Jellyfin client");

    let controller = player.0.lock().await;
    controller.set_jellyfin_client(None);
    controller.set_playback_reporter(None).await;

    log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
    Ok(())
}

#[cfg(test)]
mod tests {
    /// The subtitle list the frontend resolved must survive the IPC hop and end
    /// up on the `MediaItem` the native backend loads.
    ///
    /// The bug: `VideoPlayer.svelte` built a fully-resolved subtitle array and
    /// then dropped it on the floor — `PlayItemRequest` had no field to put it
    /// in — so `create_media_item` always produced `subtitles: vec![]`,
    /// `android/mod.rs` serialized `[]` across JNI, and ExoPlayer was handed a
    /// `MediaItem` with zero `SubtitleConfiguration`s. Every later
    /// `setSubtitleTrack(n)` then found no text track groups and logged
    /// "Invalid subtitle track index".
    ///
    /// The payload below is exactly what the frontend sends: camelCase for the
    /// top-level command params (Tauri v2 converts them), and the subtitle
    /// entries in the casing of `SubtitleTrack` itself — note `mime_type`.
    ///
    /// TRACES: UR-020 | IR-016 | UT-145
    #[tokio::test]
    async fn test_play_item_request_carries_subtitles_into_media_item() {
        use super::{create_media_item, PlayItemRequest};

        let payload = serde_json::json!({
            "id": "ep-1",
            "title": "Pilot",
            "streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
            "videoCodec": "h264",
            "needsTranscoding": false,
            "subtitles": [
                {
                    "index": 2,
                    "url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
                    "language": "eng",
                    "label": "English (SRT)",
                    "mime_type": "text/vtt"
                },
                {
                    "index": 3,
                    "url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
                    "language": null,
                    "label": null,
                    "mime_type": "text/vtt"
                }
            ]
        });

        let req: PlayItemRequest =
            serde_json::from_value(payload).expect("frontend payload must deserialize");
        assert_eq!(
            req.subtitles.len(),
            2,
            "PlayItemRequest must carry the subtitle tracks, not silently ignore them"
        );

        let media = create_media_item(req, None).await.unwrap();
        assert_eq!(
            media.subtitles.len(),
            2,
            "create_media_item must thread the tracks onto the MediaItem the backend loads"
        );
        assert_eq!(media.subtitles[0].index, 2);
        assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
        assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
        assert_eq!(media.subtitles[0].mime_type, "text/vtt");
        // Order is the contract: `player_set_subtitle_track(n)` is a position in
        // this list (see the note on `PlayItemRequest::subtitles`).
        assert_eq!(media.subtitles[1].index, 3);
        assert!(media.subtitles[1].language.is_none());
    }

    /// A request without subtitles must still deserialize — the field is
    /// defaulted so the background-audio handoff and the autoplay/next-episode
    /// callers keep compiling and sending what they always sent.
    ///
    /// TRACES: UR-020 | IR-016 | UT-145
    #[tokio::test]
    async fn test_play_item_request_without_subtitles_defaults_to_empty() {
        use super::{create_media_item, PlayItemRequest};

        let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
            "id": "movie-1",
            "title": "Movie",
            "streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
            "videoCodec": "h264",
            "needsTranscoding": false
        }))
        .expect("a subtitle-less payload must still deserialize");

        assert!(req.subtitles.is_empty());
        assert!(create_media_item(req, None)
            .await
            .unwrap()
            .subtitles
            .is_empty());
    }

    /// The JSON handed to Kotlin over JNI must use the keys
    /// `JellyTauPlayer.load()` actually reads.
    ///
    /// `MediaItem` is `rename_all = "camelCase"`, and the instinct (and the
    /// house IPC rule) is to camelCase nested structs too — but
    /// `JellyTauPlayer.kt` reads `subtitle.optString("mime_type", …)`. Renaming
    /// the field would not fail to compile or fail the IPC; it would silently
    /// fall back to the default MIME type for every track, so this is asserted
    /// on the exact bytes `android/mod.rs` sends.
    ///
    /// TRACES: UR-020 | IR-016, JA-008 | UT-146
    #[test]
    fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
        use crate::player::media::SubtitleTrack;

        let subtitles = vec![SubtitleTrack {
            index: 2,
            url: "https://jelly.example/subs.vtt".to_string(),
            language: Some("eng".to_string()),
            label: Some("English".to_string()),
            mime_type: "text/vtt".to_string(),
        }];

        // Exactly what player/android/mod.rs passes to loadWithMetadata.
        let json = serde_json::to_string(&subtitles).unwrap();
        let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
        let obj = parsed[0].as_object().unwrap();

        for key in ["url", "language", "label", "mime_type"] {
            assert!(
                obj.contains_key(key),
                "JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
                obj.keys().collect::<Vec<_>>()
            );
        }
        assert!(
            !obj.contains_key("mimeType"),
            "camelCasing mime_type silently drops every track's MIME type on Android"
        );
    }

    /// The audio-only handoff must play a downloaded file when there is one,
    /// rather than fetching an audio-only stream for media already on disk.
    ///
    /// TRACES: UR-071 | DR-128 | UT-119
    #[test]
    fn test_background_audio_source_prefers_local_file() {
        use super::background_audio_source;
        use crate::player::MediaSource;
        use std::path::PathBuf;

        let local = background_audio_source(
            Some("/downloads/ep1.mkv".to_string()),
            "https://server/audio-only".to_string(),
            "ep-1",
        );
        match local {
            MediaSource::Local {
                file_path,
                jellyfin_item_id,
            } => {
                assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
                // The Jellyfin id must survive so progress still syncs back.
                assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
            }
            other => panic!("expected a local source, got {:?}", other),
        }

        let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
        match remote {
            MediaSource::Remote {
                stream_url,
                jellyfin_item_id,
            } => {
                assert_eq!(stream_url, "https://server/audio-only");
                assert_eq!(jellyfin_item_id, "ep-1");
            }
            other => panic!("expected a remote source, got {:?}", other),
        }
    }

    /// A downloaded item must resolve to its file, and a `downloads` row whose
    /// file has gone must resolve to `None` so the caller falls back to
    /// streaming instead of handing the player a path that cannot be opened.
    ///
    /// TRACES: UR-071 | DR-123 | UT-116
    #[tokio::test]
    async fn test_resolve_local_media_path() {
        use super::resolve_local_media_path;
        use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
        use rusqlite::Connection;
        use std::sync::{Arc, Mutex};

        let conn = Connection::open_in_memory().unwrap();
        conn.execute(
            "CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
            [],
        )
        .unwrap();
        let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));

        // A real file on disk, so the existence check passes.
        let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
        std::fs::write(&present, b"x").unwrap();
        let present_str = present.to_string_lossy().to_string();

        for (item, status, path) in [
            ("downloaded", "completed", present_str.as_str()),
            ("still-going", "downloading", present_str.as_str()),
            (
                "file-gone",
                "completed",
                "/nonexistent/jellytau/missing.mp4",
            ),
        ] {
            db_service
                .execute(Query::with_params(
                    "INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
                    vec![
                        crate::storage::db_service::QueryParam::String(item.to_string()),
                        crate::storage::db_service::QueryParam::String(status.to_string()),
                        crate::storage::db_service::QueryParam::String(path.to_string()),
                    ],
                ))
                .await
                .unwrap();
        }

        assert_eq!(
            resolve_local_media_path(&db_service, "downloaded")
                .await
                .unwrap()
                .as_deref(),
            Some(present_str.as_str()),
            "a completed download with its file present must resolve"
        );

        assert_eq!(
            resolve_local_media_path(&db_service, "still-going")
                .await
                .unwrap(),
            None,
            "an in-progress download is not playable from disk"
        );

        assert_eq!(
            resolve_local_media_path(&db_service, "file-gone")
                .await
                .unwrap(),
            None,
            "a row whose file has gone must fall back to streaming, not hand over a dead path"
        );

        assert_eq!(
            resolve_local_media_path(&db_service, "never-heard-of-it")
                .await
                .unwrap(),
            None
        );

        let _ = std::fs::remove_file(&present);
    }

    /// Queue items enqueued as Remote must flip to Local once a completed
    /// download exists on disk — this is what makes preloaded tracks (and
    /// offline playback after a connection drop) actually use the cache.
    #[tokio::test]
    async fn test_refresh_queue_local_sources_switches_completed_downloads() {
        use super::{refresh_queue_local_sources, DatabaseWrapper};
        use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
        use crate::storage::Database;
        use std::sync::Mutex;

        // A real file on disk for the completed download; a missing file for
        // the second entry to prove nonexistent files are not switched.
        let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
        std::fs::create_dir_all(&dir).unwrap();
        let existing = dir.join("track-a.mp3");
        std::fs::write(&existing, b"audio").unwrap();
        let missing = dir.join("track-b-missing.mp3");
        let _ = std::fs::remove_file(&missing);

        let database = Database::open_in_memory().unwrap();
        {
            let conn = database.connection();
            let conn = conn.lock().unwrap();
            conn.execute_batch(&format!(
                r#"
                INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
                INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
                INSERT INTO downloads (item_id, user_id, file_path, status)
                    VALUES ('track-a', 'user1', '{}', 'completed');
                INSERT INTO downloads (item_id, user_id, file_path, status)
                    VALUES ('track-b', 'user1', '{}', 'completed');
                "#,
                existing.display(),
                missing.display()
            ))
            .unwrap();
        }
        let db = DatabaseWrapper(Mutex::new(database));

        let make_item = |id: &str| MediaItem {
            id: id.to_string(),
            title: id.to_string(),
            name: None,
            artist: None,
            album: None,
            album_name: None,
            album_id: None,
            artist_items: None,
            artists: None,
            primary_image_tag: None,
            image_id: None,
            item_type: None,
            playlist_id: None,
            duration: None,
            artwork_url: None,
            media_type: MediaType::Audio,
            source: MediaSource::Remote {
                stream_url: format!("http://test/Audio/{}/stream", id),
                jellyfin_item_id: id.to_string(),
            },
            video_codec: None,
            needs_transcoding: false,
            video_width: None,
            video_height: None,
            subtitles: vec![],
            series_id: None,
            server_id: None,
        };

        let controller = PlayerController::default();
        controller
            .set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
            .unwrap();

        let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
        assert_eq!(switched, 1, "only the download whose file exists switches");

        let queue = controller.queue();
        let queue_lock = queue.lock().unwrap();
        match &queue_lock.items()[0].source {
            MediaSource::Local {
                file_path,
                jellyfin_item_id,
            } => {
                assert_eq!(file_path, &existing);
                assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
            }
            other => panic!("track-a should be local, got {:?}", other),
        }
        assert!(
            matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
            "track-b's file is missing, it must stay remote"
        );
    }

    /// Test track index finding in album
    /// This reproduces the bug where clicking songs 1-5 always played song 13
    #[test]
    fn test_find_track_index_in_album() {
        // Create mock album tracks
        #[derive(Clone)]
        struct MockTrack {
            id: String,
            name: String,
            index_number: Option<i32>,
        }

        let mut tracks = vec![
            MockTrack {
                id: "track1".to_string(),
                name: "Song 1".to_string(),
                index_number: Some(1),
            },
            MockTrack {
                id: "track2".to_string(),
                name: "Song 2".to_string(),
                index_number: Some(2),
            },
            MockTrack {
                id: "track3".to_string(),
                name: "Song 3".to_string(),
                index_number: Some(3),
            },
            MockTrack {
                id: "track4".to_string(),
                name: "Song 4".to_string(),
                index_number: Some(4),
            },
            MockTrack {
                id: "track5".to_string(),
                name: "Song 5".to_string(),
                index_number: Some(5),
            },
        ];

        // Sort by index (same as the real code does)
        tracks.sort_by_key(|t| t.index_number.unwrap_or(0));

        // Test finding track 1 (should be index 0)
        let index1 = tracks.iter().position(|t| t.id == "track1");
        assert_eq!(index1, Some(0), "Track 1 should be at index 0");
        assert_eq!(
            tracks[0].name, "Song 1",
            "Track at index 0 should carry its name"
        );

        // Test finding track 3 (should be index 2)
        let index3 = tracks.iter().position(|t| t.id == "track3");
        assert_eq!(index3, Some(2), "Track 3 should be at index 2");

        // Test finding track 5 (should be index 4)
        let index5 = tracks.iter().position(|t| t.id == "track5");
        assert_eq!(index5, Some(4), "Track 5 should be at index 4");

        // Test finding non-existent track
        let index_none = tracks.iter().position(|t| t.id == "nonexistent");
        assert_eq!(index_none, None, "Non-existent track should return None");
    }

    /// Test that track order is preserved when iterating
    #[test]
    fn test_track_iteration_order() {
        // Simulate the loop that builds MediaItems
        let track_ids = vec!["id1", "id2", "id3", "id4", "id5"];

        // Find where "id3" is in the original list
        let target_index = track_ids.iter().position(|&id| id == "id3");
        assert_eq!(
            target_index,
            Some(2),
            "id3 should be at index 2 in original list"
        );

        // Simulate building the MediaItems vector
        let mut media_items = Vec::new();
        for id in &track_ids {
            media_items.push(id.to_string());
        }

        // Verify the order is preserved
        assert_eq!(media_items.len(), 5);
        assert_eq!(media_items[0], "id1");
        assert_eq!(media_items[2], "id3");
        assert_eq!(media_items[4], "id5");

        // The start_index found earlier should still be valid
        assert_eq!(media_items[target_index.unwrap()], "id3");
    }

    /// Test album track sorting behavior
    #[test]
    fn test_album_track_sorting() {
        #[derive(Clone, Debug)]
        struct MockTrack {
            id: String,
            name: String,
            index_number: Option<i32>,
        }

        // Create tracks in random order (not sorted)
        let mut tracks = vec![
            MockTrack {
                id: "id5".to_string(),
                name: "Track 5".to_string(),
                index_number: Some(5),
            },
            MockTrack {
                id: "id1".to_string(),
                name: "Track 1".to_string(),
                index_number: Some(1),
            },
            MockTrack {
                id: "id3".to_string(),
                name: "Track 3".to_string(),
                index_number: Some(3),
            },
            MockTrack {
                id: "id2".to_string(),
                name: "Track 2".to_string(),
                index_number: Some(2),
            },
            MockTrack {
                id: "id4".to_string(),
                name: "Track 4".to_string(),
                index_number: Some(4),
            },
        ];

        // User clicks track "id1" before sorting - what index is it?
        let requested_track_id = "id1";

        // Sort the tracks (same as real code)
        tracks.sort_by_key(|t| t.index_number.unwrap_or(0));

        // Now find the index AFTER sorting
        let start_index = tracks.iter().position(|t| t.id == requested_track_id);

        // Track "id1" should be at index 0 after sorting
        assert_eq!(
            start_index,
            Some(0),
            "After sorting, track id1 should be at index 0"
        );

        // Verify all tracks are in correct order
        assert_eq!(tracks[0].id, "id1");
        assert_eq!(
            tracks[0].name, "Track 1",
            "Sorted track should retain its name"
        );
        assert_eq!(tracks[1].id, "id2");
        assert_eq!(tracks[2].id, "id3");
        assert_eq!(tracks[3].id, "id4");
        assert_eq!(tracks[4].id, "id5");
    }
}
