//! TRACES: UR-002, UR-007 | DR-013 | IR-010

use async_trait::async_trait;
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};

use super::{types::*, MediaRepository};
use crate::connectivity::ConnectivityReporter;
use crate::jellyfin::HttpClient;
use crate::settings::StreamingQuality;
use crate::utils::lock::RwLockSafe;

/// The bandwidth ceiling every video stream this process opens is built against.
///
/// Process-wide rather than a field on [`OnlineRepository`] because it is a user
/// preference about *this device's connection*, not about a server session: it
/// must survive a repository being rebuilt on re-login, and every URL builder and
/// the `PlaybackInfo` negotiation have to agree on it or the cap leaks (the
/// negotiation would authorise a direct play the URL builder then never gets to
/// constrain). Same shape as `offline::INCLUDE_CATALOG_BROWSE`.
///
/// Set from `player_set_video_settings` / `player_set_stream_quality`, and
/// restored from the database at startup.
///
/// TRACES: UR-074 | DR-162
static STREAMING_QUALITY: RwLock<StreamingQuality> = RwLock::new(StreamingQuality::Original);

/// Apply a bandwidth ceiling to every subsequently-opened video stream.
///
/// Streams already playing keep the bitrate they were opened at — a cap is a
/// property of the URL the server is transcoding for, so changing it mid-stream
/// requires re-opening at the new quality (`player_set_stream_quality`).
///
/// TRACES: UR-074 | DR-162
pub fn set_streaming_quality(quality: StreamingQuality) {
    *STREAMING_QUALITY.write_safe() = quality;
}

/// The ceiling currently applied to new video streams.
///
/// TRACES: UR-074 | DR-162
pub fn streaming_quality() -> StreamingQuality {
    *STREAMING_QUALITY.read_safe()
}

/// Every request this app makes identifies the same device, so the device id
/// alone cannot tell two transcodes of the same item apart — see
/// [`begin_video_play_session`].
const DEVICE_ID: &str = "jellytau-tauri";

/// The `PlaySessionId` of the video transcode most recently opened, so the next
/// open can stop it.
///
/// Process-wide for the same reason as [`STREAMING_QUALITY`]: it describes what
/// *this device* currently has running on the server, and must survive the
/// repository being rebuilt on re-login.
///
/// TRACES: UR-074 | DR-162
static VIDEO_PLAY_SESSION: RwLock<Option<String>> = RwLock::new(None);

/// Claim a transcode identity for a stream about to be opened, returning the new
/// `PlaySessionId` and the one it replaces (if any).
///
/// Jellyfin keys a transcode job by device *and* play session. Without a session
/// id every open of the same item on this device looked like the same job, so
/// re-opening a stream — a quality switch, a transcoded seek, an audio-track
/// switch — left the old ffmpeg running and the server intermittently rejected
/// segment requests for the new one (`400` on `hls1/main/0.ts`) while the two
/// fought over one transcode path. The caller stops the returned previous
/// session before the new stream's segments are fetched.
///
/// TRACES: UR-074 | DR-177 | UT-173
pub fn begin_video_play_session() -> (String, Option<String>) {
    let new_session = uuid::Uuid::new_v4().to_string();
    let mut current = VIDEO_PLAY_SESSION.write_safe();
    let previous = current.replace(new_session.clone());
    (new_session, previous)
}

/// Take ownership of a transcode this process did not build a URL for, returning
/// the session it replaces.
///
/// When `PlaybackInfo` answers with a `TranscodingUrl` the server has already
/// started the job and named the session; that id is the only handle on it we
/// will ever have. Without adopting it, the first re-open of that stream has no
/// previous session to stop and collides with the very job that was playing.
///
/// TRACES: UR-074 | DR-177 | UT-173
pub fn adopt_video_play_session(session_id: String) -> Option<String> {
    VIDEO_PLAY_SESSION.write_safe().replace(session_id)
}

/// A single actor returned by the JRay plugin's "context at time t" endpoint.
///
/// Mirrors the `actors[]` objects from `GET /Plugins/JRay/Items/{id}/jray?t=`.
/// `jellyfin_id` (a Jellyfin Person item GUID) is preferred for navigation;
/// the IMDb/TMDb ids are informational fallbacks. Unknown ids are `""`.
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
pub struct JRayActor {
    pub name: String,
    #[serde(default)]
    pub imdb_id: String,
    #[serde(default)]
    pub tmdb_id: String,
    #[serde(default)]
    pub jellyfin_id: String,
}

/// Envelope returned by the JRay `jray?t=` endpoint. Extra keys (future
/// `locations`, `trivia`, …) are ignored so the client tolerates schema growth.
#[derive(Debug, Clone, Deserialize)]
struct JRayContext {
    #[serde(default)]
    actors: Vec<JRayActor>,
}

/// Online repository - fetches data from Jellyfin server via HTTP
pub struct OnlineRepository {
    http_client: Arc<HttpClient>,
    server_url: String,
    user_id: String,
    access_token: String,
    /// Reports the outcome of every server request to the connectivity monitor.
    /// This is the source of truth for the offline/online banner. `None` in
    /// tests / contexts where connectivity tracking isn't wired up.
    connectivity: Option<ConnectivityReporter>,
}

impl OnlineRepository {
    /// The signed-in user these requests are made as. Needed by the favourites
    /// drain, which reads this user's queued rows. TRACES: UR-069 | DR-120
    pub fn user_id(&self) -> &str {
        &self.user_id
    }

    pub fn new(
        http_client: Arc<HttpClient>,
        server_url: String,
        user_id: String,
        access_token: String,
    ) -> Self {
        Self {
            http_client,
            server_url,
            user_id,
            access_token,
            connectivity: None,
        }
    }

    /// Attach a connectivity reporter so server outcomes drive the reachability
    /// state observed by the UI. See `report_outcome`.
    pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
        self.connectivity = Some(reporter);
        self
    }

    /// Feed a request outcome into the connectivity monitor.
    ///
    /// Classification (matches docs/architecture/07-connectivity.md):
    /// - `Ok` / `Authentication` / `NotFound` / `Server` → the server answered,
    ///   so it is reachable → `report_success` (instant recovery).
    /// - `Network` → connection-level failure → `report_network_failure`
    ///   (subject to the time-window debounce before going offline).
    /// - `Database` → not a server signal → ignored.
    async fn report_outcome<T>(&self, result: &Result<T, RepoError>) {
        let Some(reporter) = &self.connectivity else {
            return;
        };

        match result {
            Ok(_)
            | Err(RepoError::Authentication { .. })
            | Err(RepoError::NotFound { .. })
            | Err(RepoError::Server { .. }) => {
                reporter.report_success().await;
            }
            Err(RepoError::Network { message }) => {
                reporter.report_network_failure(Some(message.clone())).await;
            }
            Err(RepoError::Database { .. }) | Err(RepoError::Offline) => {
                // Local-side errors (cache failure / already-offline) — not a
                // statement about the server's reachability, so ignore them.
            }
        }
    }

    /// Build authorization header
    fn auth_header(&self) -> String {
        HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
    }

    /// Download raw bytes from a URL using the shared authenticated HTTP client.
    /// Used by thumbnail cache to download images with proper auth and connection reuse.
    pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
        let request = self
            .http_client
            .client
            .get(url)
            .header("X-Emby-Authorization", self.auth_header())
            .build()
            .map_err(|e| format!("Failed to build request: {}", e))?;

        let response = self
            .http_client
            .request_with_retry(request)
            .await
            .map_err(|e| format!("Download failed: {}", e))?;

        if !response.status().is_success() {
            let status = response.status();
            let body = response.text().await.unwrap_or_default();
            let body_preview = if body.len() > 200 {
                &body[..200]
            } else {
                &body
            };
            return Err(format!("HTTP {} ({})", status, body_preview.trim()));
        }

        response
            .bytes()
            .await
            .map(|b| b.to_vec())
            .map_err(|e| format!("Failed to read bytes: {}", e))
    }

    /// Query the JRay plugin for the actors on screen at time `t` (seconds) in
    /// the given item. Returns an empty list when the plugin isn't installed or
    /// has no truth data for the item (HTTP 404), so callers can treat "no JRay"
    /// and "nobody on screen" identically. Other failures propagate.
    pub async fn get_jray_actors(
        &self,
        item_id: &str,
        t: f64,
    ) -> Result<Vec<JRayActor>, RepoError> {
        let endpoint = format!("/Plugins/JRay/Items/{}/jray?t={}", item_id, t);
        match self.get_json::<JRayContext>(&endpoint).await {
            Ok(context) => Ok(context.actors),
            // No plugin / no truth data for this item — not an error to the user.
            Err(RepoError::NotFound { .. }) => Ok(Vec::new()),
            Err(e) => Err(e),
        }
    }

    /// Make authenticated GET request
    async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
        // Fast-fail when connectivity is known-offline. Without this every request
        // still runs the full HTTP retry/backoff cycle (~7s) before giving up,
        // which stalls cache-miss paths and makes offline browsing feel janky.
        // The offline recovery probe (connectivity monitor) flips us back to
        // reachable the moment the server returns, so this never sticks.
        if let Some(reporter) = &self.connectivity {
            if !reporter.is_reachable().await {
                return Err(RepoError::Offline);
            }
        }

        let result = self.get_json_inner(endpoint).await;
        self.report_outcome(&result).await;
        result
    }

    async fn get_json_inner<T: for<'de> Deserialize<'de>>(
        &self,
        endpoint: &str,
    ) -> Result<T, RepoError> {
        let url = format!("{}{}", self.server_url, endpoint);

        let request = self
            .http_client
            .client
            .get(&url)
            .header("X-Emby-Authorization", self.auth_header())
            .build()
            .map_err(|e| RepoError::Network {
                message: format!("Failed to build request: {}", e),
            })?;

        let response = self
            .http_client
            .request_with_retry(request)
            .await
            .map_err(|e| RepoError::Network {
                message: e.to_string(),
            })?;

        if !response.status().is_success() {
            let status = response.status();
            if status.as_u16() == 401 || status.as_u16() == 403 {
                return Err(RepoError::Authentication {
                    message: format!("HTTP {}", status),
                });
            } else if status.as_u16() == 404 {
                return Err(RepoError::NotFound {
                    message: "Resource not found".to_string(),
                });
            } else {
                return Err(RepoError::Server {
                    message: format!("HTTP {}", status),
                });
            }
        }

        // Get the response text first for better error reporting
        let text = response.text().await.map_err(|e| RepoError::Server {
            message: format!("Failed to read response: {}", e),
        })?;

        // Try to deserialize and log the raw JSON on error
        serde_json::from_str(&text).map_err(|e| {
            error!(
                "[OnlineRepo] Failed to deserialize {} response: {}",
                endpoint, e
            );
            error!(
                "[OnlineRepo] Response body (first 1000 chars): {}",
                if text.len() > 1000 {
                    &text[..1000]
                } else {
                    &text
                }
            );
            RepoError::Server {
                message: format!("Failed to parse response: {}", e),
            }
        })
    }

    /// Make authenticated POST request
    async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
        let result = self.post_json_inner(endpoint, body).await;
        self.report_outcome(&result).await;
        result
    }

    async fn post_json_inner<T: Serialize>(
        &self,
        endpoint: &str,
        body: &T,
    ) -> Result<(), RepoError> {
        let url = format!("{}{}", self.server_url, endpoint);

        let request = self
            .http_client
            .client
            .post(&url)
            .header("Content-Type", "application/json")
            .header("X-Emby-Authorization", self.auth_header())
            .json(body)
            .build()
            .map_err(|e| RepoError::Network {
                message: format!("Failed to build request: {}", e),
            })?;

        let response = self
            .http_client
            .request_with_retry(request)
            .await
            .map_err(|e| RepoError::Network {
                message: e.to_string(),
            })?;

        if !response.status().is_success() {
            let status = response.status();
            if status.as_u16() == 401 || status.as_u16() == 403 {
                return Err(RepoError::Authentication {
                    message: format!("HTTP {}", status),
                });
            } else {
                return Err(RepoError::Server {
                    message: format!("HTTP {}", status),
                });
            }
        }

        Ok(())
    }

    /// Make authenticated POST request and return response
    async fn post_json_response<T: Serialize, R: for<'de> Deserialize<'de>>(
        &self,
        endpoint: &str,
        body: &T,
    ) -> Result<R, RepoError> {
        let result = self.post_json_response_inner(endpoint, body).await;
        self.report_outcome(&result).await;
        result
    }

    async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
        &self,
        endpoint: &str,
        body: &T,
    ) -> Result<R, RepoError> {
        let url = format!("{}{}", self.server_url, endpoint);

        // Log request body for debugging
        if let Ok(json) = serde_json::to_string_pretty(body) {
            debug!("[HTTP] POST {}", endpoint);
            debug!("[HTTP] Request body:\n{}", json);
        }

        let request = self
            .http_client
            .client
            .post(&url)
            .header("Content-Type", "application/json")
            .header("X-Emby-Authorization", self.auth_header())
            .json(body)
            .build()
            .map_err(|e| RepoError::Network {
                message: format!("Failed to build request: {}", e),
            })?;

        let response = self
            .http_client
            .request_with_retry(request)
            .await
            .map_err(|e| RepoError::Network {
                message: e.to_string(),
            })?;

        if !response.status().is_success() {
            let status = response.status();

            // Capture response body for error details
            let error_body = response
                .text()
                .await
                .unwrap_or_else(|_| "Failed to read error body".to_string());
            error!("[HTTP] Error response ({}): {}", status, error_body);

            if status.as_u16() == 401 || status.as_u16() == 403 {
                return Err(RepoError::Authentication {
                    message: format!("HTTP {}: {}", status, error_body),
                });
            } else if status.as_u16() == 404 {
                return Err(RepoError::NotFound {
                    message: format!("Resource not found: {}", error_body),
                });
            } else {
                return Err(RepoError::Server {
                    message: format!("HTTP {}: {}", status, error_body),
                });
            }
        }

        response.json().await.map_err(|e| RepoError::Server {
            message: format!("Failed to parse response: {}", e),
        })
    }

    /// Ask the server to tear down a transcode this device started.
    ///
    /// Best-effort and deliberately un-retried: it runs on the path that opens a
    /// replacement stream, so a slow or failed stop must not delay playback. The
    /// worst case if it does fail is the job Jellyfin would have reaped on its
    /// own idle timer anyway — the new stream still has its own session id, so it
    /// no longer collides with the old one.
    ///
    /// TRACES: UR-074 | DR-177
    async fn stop_transcode(&self, play_session_id: &str) {
        let url = format!(
            "{}/Videos/ActiveEncodings?deviceId={}&playSessionId={}",
            self.server_url, DEVICE_ID, play_session_id
        );

        let request = self
            .http_client
            .client
            .delete(&url)
            .header("X-Emby-Authorization", self.auth_header())
            .send();

        match request.await {
            Ok(response) if response.status().is_success() => {
                debug!("[Transcode] Stopped previous encoding {}", play_session_id);
            }
            Ok(response) => {
                debug!(
                    "[Transcode] Server declined to stop encoding {}: HTTP {}",
                    play_session_id,
                    response.status()
                );
            }
            Err(e) => {
                debug!(
                    "[Transcode] Could not stop encoding {}: {}",
                    play_session_id, e
                );
            }
        }
    }

    /// Get a video stream URL (initial play, resume, transcoded seeking,
    /// audio-track switching).
    ///
    /// Returns an HLS master playlist (`/Videos/{id}/master.m3u8`) transcoded to
    /// h264/aac. HLS is used rather than a progressive `stream.mp4` because the
    /// HTML5 `<video>` element (via HLS.js) starts playing within seconds and can
    /// seek within the stream, whereas a progressive MP4 transcode of HEVC source
    /// forces the server to transcode the whole file before playback can begin —
    /// which manifests as playback never starting.
    ///
    /// **There is deliberately no start-position parameter.** A playlist covers
    /// the whole item and asking for segment N *is* the seek, so a position would
    /// be redundant — and actively fatal: Jellyfin builds every segment URI by
    /// echoing this playlist's query string into it, while its segment handler
    /// rejects `StartTimeTicks > 0` outright (`ArgumentException` → `400`). One
    /// resume position here therefore 400s every segment of the stream, which
    /// presents as a resumed episode that simply never plays while the same
    /// episode from the beginning is fine. Resume by seeking the player once it
    /// has loaded. (The progressive `/Audio/universal` builder below has no
    /// segments and keeps its `StartTimeTicks`.)
    ///
    /// The stream is built against the current [`streaming_quality`] ceiling:
    /// `MaxStreamingBitrate`/`VideoBitrate`/`AudioBitrate`, plus a `MaxHeight`
    /// that suits the budget. `Original` keeps the historical 20/18 Mbps
    /// allowance, which is a transcode ceiling rather than a user-facing limit.
    ///
    /// TRACES: UR-004, UR-074 | DR-140, DR-162, DR-177, DR-181 | UT-130, UT-156, UT-173, UT-182
    pub async fn get_video_stream_url(
        &self,
        item_id: &str,
        media_source_id: Option<&str>,
        audio_stream_index: Option<i32>,
    ) -> Result<String, RepoError> {
        let quality = streaming_quality();
        // `Original` is uncapped as a *user* setting, but a transcode still needs
        // a ceiling to encode against — keep the values this endpoint has always
        // used so nothing changes for the default.
        let max_bitrate = quality.max_bitrate().unwrap_or(20_000_000);
        let video_bitrate = quality.video_bitrate().unwrap_or(18_000_000);

        // Claim a distinct transcode identity and retire the one it replaces, so
        // the server is never running two jobs for this device at once. Doing it
        // here covers every path that re-opens a stream (quality switch,
        // transcoded seek, audio-track switch) rather than each remembering to.
        let (play_session_id, superseded) = begin_video_play_session();
        if let Some(previous) = superseded {
            self.stop_transcode(&previous).await;
        }

        // Build an HLS transcode URL. VideoCodec lists h264 first so the server
        // transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
        let mut params = vec![
            ("api_key", self.access_token.clone()),
            ("DeviceId", DEVICE_ID.to_string()),
            ("PlaySessionId", play_session_id),
            ("VideoCodec", "h264".to_string()),
            ("AudioCodec", "aac".to_string()),
            ("MaxStreamingBitrate", max_bitrate.to_string()),
            ("VideoBitrate", video_bitrate.to_string()),
            ("AudioBitrate", quality.audio_bitrate().to_string()),
            (
                "TranscodingMaxAudioChannels",
                super::device_profile::max_audio_channels().to_string(),
            ),
            ("SegmentContainer", "ts".to_string()),
            ("TranscodingContainer", "ts".to_string()),
            ("TranscodingProtocol", "hls".to_string()),
            // Say "no subtitle" rather than leaving the choice open. An omitted
            // index is not neutral: the server then picks the source's own
            // default/forced track, and an image-based one can only be delivered
            // by burning it into the picture (DR-176). The negotiation already
            // sends this sentinel, but most streams are opened by rebuilding
            // *this* URL — a quality switch, a transcoded seek, an audio-track
            // switch — so it has to hold here too, independently of whatever
            // session state the server still holds.
            (
                "SubtitleStreamIndex",
                super::device_profile::playback_subtitle_stream_index().to_string(),
            ),
        ];

        // Scale the picture down to what the budget can carry. Omitted for the
        // uncapped steps so the source resolution is preserved.
        if let Some(height) = quality.max_height() {
            params.push(("MaxHeight", height.to_string()));
        }

        // Only pin an audio track when the user actually picked one. Jellyfin's
        // `MediaStream.Index` is global across *all* streams in a media source, so
        // index 0 is the video stream on virtually every file — defaulting to 0
        // asks the server to transcode the video stream as the audio track, which
        // yields a picture with no sound. Omitting the param lets the server use
        // the source's `DefaultAudioStreamIndex`.
        if let Some(index) = audio_stream_index {
            params.push(("AudioStreamIndex", index.to_string()));
        }

        if let Some(source_id) = media_source_id {
            params.push(("MediaSourceId", source_id.to_string()));
        }

        // Build query string (values are already safe, no encoding needed)
        let query = params
            .iter()
            .map(|(k, v)| format!("{}={}", k, v))
            .collect::<Vec<_>>()
            .join("&");

        let url = format!(
            "{}/Videos/{}/master.m3u8?{}",
            self.server_url, item_id, query
        );

        Ok(url)
    }

    /// Get an **audio-only** stream URL for a *video* item, for the
    /// background-audio handoff (UR-040).
    ///
    /// TRACES: UR-040 | JA-032, DR-140 | UT-059, UT-130
    ///
    /// This deliberately targets `/Audio/{id}/universal`, NOT the video stream:
    /// the server extracts/transcodes only the item's audio track and streams
    /// pure audio bytes — no video frames reach the device, so there is no client
    /// video decode while backgrounded. Do NOT "optimize" this to reuse the
    /// `/Videos/.../master.m3u8` URL: that would keep the device decoding video,
    /// defeating the entire point of the feature.
    ///
    /// `AudioStreamIndex` carries the user's currently-selected audio track over
    /// from the video player; `StartTimeTicks` resumes at the handoff position.
    /// `universal` lets the server pick direct-play vs transcode per codec/device.
    ///
    /// The stream is a **progressive** container (mp3 over plain HTTP), NOT HLS:
    /// ExoPlayer plays this natively, whereas an HLS/`ts` transcode on the
    /// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
    /// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
    /// decodable and supports mid-stream `StartTimeTicks`.
    pub async fn build_audio_only_stream_url_for_video(
        &self,
        item_id: &str,
        media_source_id: Option<&str>,
        start_time_seconds: Option<f64>,
        audio_stream_index: Option<i32>,
    ) -> Result<String, RepoError> {
        let mut params = vec![
            ("UserId", self.user_id.clone()),
            ("api_key", self.access_token.clone()),
            ("DeviceId", DEVICE_ID.to_string()),
            // Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
            ("Container", "mp3".to_string()),
            ("AudioCodec", "mp3".to_string()),
            ("TranscodingContainer", "mp3".to_string()),
            ("TranscodingProtocol", "http".to_string()),
            // Audio-only is already far under any video cap, but a user on the
            // bottom rungs of the ladder asked for *less traffic*, so take the
            // lower of the two rather than always 384 kbps.
            // TRACES: UR-074 | DR-162
            (
                "MaxStreamingBitrate",
                streaming_quality().audio_bitrate().min(384_000).to_string(),
            ),
        ];

        // Carry the track over only if one was actually selected — index 0 is the
        // video stream, not "the first audio track" (see `get_video_stream_url`).
        if let Some(index) = audio_stream_index {
            params.push(("AudioStreamIndex", index.to_string()));
        }

        if let Some(source_id) = media_source_id {
            params.push(("MediaSourceId", source_id.to_string()));
        }

        if let Some(seconds) = start_time_seconds {
            let ticks = (seconds * 10_000_000.0) as i64;
            params.push(("StartTimeTicks", ticks.to_string()));
        }

        let query = params
            .iter()
            .map(|(k, v)| format!("{}={}", k, v))
            .collect::<Vec<_>>()
            .join("&");

        let url = format!("{}/Audio/{}/universal?{}", self.server_url, item_id, query);

        Ok(url)
    }
}

// Jellyfin API response types (PascalCase from server)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct ItemsResponse {
    items: Vec<JellyfinItem>,
    total_record_count: usize,
}

/// Jellyfin playlist creation response
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct CreatePlaylistResponse {
    id: String,
}

/// Jellyfin playlist items response — items include PlaylistItemId
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
#[allow(dead_code)]
struct PlaylistItemsResponse {
    items: Vec<JellyfinPlaylistItem>,
    total_record_count: usize,
}

/// A playlist item from Jellyfin — wraps a regular item with an entry-scoped ID
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JellyfinPlaylistItem {
    playlist_item_id: String,
    #[serde(flatten)]
    item: JellyfinItem,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct JellyfinItem {
    id: String,
    name: String,
    #[serde(rename = "Type")]
    item_type: String,
    #[serde(default)]
    is_folder: bool,
    parent_id: Option<String>,
    overview: Option<String>,
    genres: Option<Vec<String>>,
    production_year: Option<i32>,
    premiere_date: Option<String>,
    community_rating: Option<f64>,
    official_rating: Option<String>,
    run_time_ticks: Option<i64>,
    image_tags: Option<ImageTags>,
    backdrop_image_tags: Option<Vec<String>>,
    parent_backdrop_image_tags: Option<Vec<String>>,
    album_id: Option<String>,
    album: Option<String>,
    album_artist: Option<String>,
    artists: Option<Vec<String>>,
    artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
    index_number: Option<i32>,
    parent_index_number: Option<i32>,
    series_id: Option<String>,
    series_name: Option<String>,
    season_id: Option<String>,
    season_name: Option<String>,
    media_streams: Option<Vec<JellyfinMediaStream>>,
    media_sources: Option<Vec<JellyfinMediaSource>>,
    people: Option<Vec<crate::repository::types::Person>>,
    user_data: Option<JellyfinUserData>,
}

/// Per-user state Jellyfin attaches to an item (favourite, played, resume).
///
/// Returned on every `/Users/{uid}/Items*` response; we additionally name
/// `UserData` in the `Fields=` list so the shape is explicit rather than
/// dependent on the server's default field set.
///
/// `PlaybackPositionTicks` is the server's resume position for the item, and the
/// only place it is published — Jellyfin has no per-item "resume position"
/// endpoint, so reading `UserData` *is* how a resume point is obtained.
///
/// TRACES: UR-019, UR-069 | DR-113, JA-013, JA-034 | UT-099
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "PascalCase")]
struct JellyfinUserData {
    playback_position_ticks: Option<i64>,
    #[serde(rename = "Played")]
    is_played: Option<bool>,
    is_favorite: Option<bool>,
    play_count: Option<i32>,
    last_played_date: Option<String>,
}

impl From<JellyfinUserData> for UserData {
    fn from(jf: JellyfinUserData) -> Self {
        UserData {
            playback_position_ticks: jf.playback_position_ticks,
            playback_position_ms: jf.playback_position_ticks.map(crate::domain::ticks_to_ms),
            is_played: jf.is_played,
            is_favorite: jf.is_favorite,
            play_count: jf.play_count,
            last_played_date: jf.last_played_date,
            playback_context_type: None,
            playback_context_id: None,
        }
    }
}

/// Build the Jellyfin endpoint for a folder listing.
///
/// Extracted from `get_items` so the query it produces — in particular the
/// favourites filter — can be asserted without standing up an HTTP server.
///
/// TRACES: UR-007, UR-067 | DR-116 | UT-104
fn build_get_items_endpoint(
    user_id: &str,
    parent_id: &str,
    options: Option<&GetItemsOptions>,
) -> String {
    let mut endpoint = format!("/Users/{}/Items?ParentId={}", user_id, parent_id);

    if let Some(opts) = options {
        if let Some(limit) = opts.limit {
            endpoint.push_str(&format!("&Limit={}", limit));
        }
        if let Some(start_index) = opts.start_index {
            endpoint.push_str(&format!("&StartIndex={}", start_index));
        }
        if let Some(types) = &opts.include_item_types {
            endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
        }
        if let Some(sort_by) = &opts.sort_by {
            endpoint.push_str(&format!("&SortBy={}", sort_by));
        }
        if let Some(sort_order) = &opts.sort_order {
            endpoint.push_str(&format!("&SortOrder={}", sort_order));
        }
        if let Some(recursive) = opts.recursive {
            endpoint.push_str(&format!("&Recursive={}", recursive));
        }
        if let Some(genres) = &opts.genres {
            if !genres.is_empty() {
                // Genre names may contain spaces/ampersands, so percent-encode each.
                let encoded: Vec<String> = genres
                    .iter()
                    .map(|g| urlencoding::encode(g).into_owned())
                    .collect();
                endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
            }
        }
        // TRACES: UR-067 | DR-116 | UT-104
        if opts.favorites_only == Some(true) {
            endpoint.push_str("&Filters=IsFavorite");
        }
    }

    // Request image fields for list views (People only needed in get_item
    // detail view). Genres is needed so cached items carry their genres,
    // which lets the offline store derive genre lists + per-genre counts.
    endpoint
        .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
    endpoint
}

/// Build the Jellyfin endpoint for a "recently added" listing.
///
/// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to
/// `false`, which returns each newly-added *leaf* separately, so importing one
/// 14-track album pushed 14 rows into "recently added" and buried everything
/// else. With grouping on, the server collapses children into the container
/// that was added — an album appears once, while movies (which have no such
/// container) are unaffected.
///
/// Pulled out of `get_latest_items` so the query can be asserted without an
/// HTTP server, matching `build_favorites_endpoint`.
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016
fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
    format!(
        "/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
        user_id,
        parent_id,
        limit.unwrap_or(16)
    )
}

/// Build the Jellyfin endpoint for a Next Up listing.
///
/// `EnableResumable=false` is the point of this query: the server default is
/// `true`, which makes a partially-watched episode its own series' "next up" —
/// the very episode `/Items/Resume` returns — so Continue Watching and Next Up
/// end up showing the same cards. Next Up should only ever offer episodes the
/// viewer has not started. Servers predating the parameter ignore it, which is
/// why the frontend also drops in-progress entries (DR-197).
///
/// Pulled out of `get_next_up_episodes` so the query can be asserted without an
/// HTTP server, matching `build_favorites_endpoint`.
///
/// TRACES: UR-023, UR-059 | DR-197, JA-014, JA-036 | UT-190, UT-191
fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
    let mut endpoint = format!(
        "/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
        user_id,
        limit.unwrap_or(16)
    );

    if let Some(sid) = series_id {
        endpoint.push_str(&format!("&SeriesId={}", sid));
    }

    endpoint
}

/// Build the Jellyfin endpoint for a favourites listing.
///
/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
/// server. `scope` is expanded here — `SearchScope::All` yields `None`, and the
/// `IncludeItemTypes` filter is then **omitted entirely** rather than sent as a
/// union, which would silently drop every type nobody enumerated (see
/// `SearchScope::item_types`).
///
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
fn build_favorites_endpoint(
    user_id: &str,
    scope: SearchScope,
    options: Option<&GetItemsOptions>,
) -> String {
    let mut endpoint = format!("/Users/{}/Items?Filters=IsFavorite&Recursive=true", user_id);

    if let Some(types) = scope.item_types() {
        endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
    }

    // Jellyfin has no "date favourited", so name order is the only stable sort
    // available; callers may still override it.
    let sort_by = options
        .and_then(|o| o.sort_by.as_deref())
        .unwrap_or("SortName");
    let sort_order = options
        .and_then(|o| o.sort_order.as_deref())
        .unwrap_or("Ascending");
    endpoint.push_str(&format!("&SortBy={}&SortOrder={}", sort_by, sort_order));

    if let Some(limit) = options.and_then(|o| o.limit) {
        endpoint.push_str(&format!("&Limit={}", limit));
    }
    if let Some(start_index) = options.and_then(|o| o.start_index) {
        endpoint.push_str(&format!("&StartIndex={}", start_index));
    }

    endpoint
        .push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
    endpoint
}

// ImageTags from Jellyfin API - can be a HashMap with various image type keys
// We use a wrapper to extract just the Primary tag we need
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ImageTags {
    // Modern format: HashMap
    Map(std::collections::HashMap<String, String>),
    // Legacy/alternative format: structured
    Structured {
        #[serde(rename = "Primary")]
        primary: Option<String>,
    },
}

impl ImageTags {
    fn primary(&self) -> Option<String> {
        match self {
            ImageTags::Map(map) => map.get("Primary").cloned(),
            ImageTags::Structured { primary } => primary.clone(),
        }
    }
}

#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "PascalCase")]
struct JellyfinMediaStream {
    #[serde(rename = "Type")]
    stream_type: String,
    codec: Option<String>,
    language: Option<String>,
    display_title: Option<String>,
    index: i32,
    is_default: bool,
    #[serde(default)]
    is_forced: bool,
}

#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "PascalCase")]
struct JellyfinMediaSource {
    id: String,
    name: String,
    container: Option<String>,
    size: Option<i64>,
    bitrate: Option<i32>,
    supports_direct_play: bool,
    supports_direct_stream: bool,
    supports_transcoding: bool,
    direct_stream_url: Option<String>,
}

impl JellyfinItem {
    fn into_media_item(self, server_id: String) -> MediaItem {
        // Extract image tags before consuming self
        let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary());
        let backdrop_tags = self.backdrop_image_tags;

        let kind = crate::domain::kind_from_jellyfin(&self.item_type, self.is_folder);

        MediaItem {
            id: self.id,
            name: self.name,
            item_type: self.item_type,
            kind,
            is_folder: self.is_folder,
            server_id,
            parent_id: self.parent_id,
            library_id: None, // Not provided by Jellyfin API directly
            overview: self.overview,
            genres: self.genres,
            production_year: self.production_year,
            premiere_date: self.premiere_date,
            community_rating: self.community_rating,
            official_rating: self.official_rating,
            runtime_ticks: self.run_time_ticks,
            duration_ms: self.run_time_ticks.map(crate::domain::ticks_to_ms),
            primary_image_tag: primary_tag.clone(),
            image_id: primary_tag,
            backdrop_image_tags: backdrop_tags,
            parent_backdrop_image_tags: self.parent_backdrop_image_tags,
            album_id: self.album_id,
            album_name: self.album,
            album_artist: self.album_artist,
            artists: self.artists,
            artist_items: self.artist_items,
            index_number: self.index_number,
            parent_index_number: self.parent_index_number,
            series_id: self.series_id,
            series_name: self.series_name,
            season_id: self.season_id,
            season_name: self.season_name,
            // Favourite/played/resume state as the server sees it. TRACES:
            // UR-069 | DR-113, JA-034
            user_data: self.user_data.map(UserData::from),
            media_streams: self.media_streams.map(|streams| {
                streams
                    .into_iter()
                    .map(|s| {
                        let kind = crate::domain::stream_kind_from_jellyfin(&s.stream_type);
                        // Only a subtitle can be a sidecar; asked of anything
                        // else the question has no answer. TRACES: UR-020 |
                        // DR-176 | UT-168
                        let supports_external_delivery =
                            (kind == crate::domain::StreamKind::Subtitle).then(|| {
                                super::device_profile::subtitle_supports_external_delivery(
                                    s.codec.as_deref(),
                                )
                            });
                        crate::repository::types::MediaStream {
                            kind,
                            stream_type: s.stream_type,
                            codec: s.codec,
                            language: s.language,
                            display_title: s.display_title,
                            index: s.index,
                            is_default: s.is_default,
                            is_forced: s.is_forced,
                            supports_external_delivery,
                        }
                    })
                    .collect()
            }),
            media_sources: self.media_sources.map(|sources| {
                sources
                    .into_iter()
                    .map(|s| crate::repository::types::MediaSource {
                        id: s.id,
                        name: s.name,
                        container: s.container,
                        size: s.size,
                        bitrate: s.bitrate,
                        supports_direct_play: s.supports_direct_play,
                        supports_direct_stream: s.supports_direct_stream,
                        supports_transcoding: s.supports_transcoding,
                        direct_stream_url: s.direct_stream_url,
                    })
                    .collect()
            }),
            people: self.people,
        }
    }
}

#[async_trait]
impl MediaRepository for OnlineRepository {
    async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
        #[derive(Debug, Deserialize)]
        #[serde(rename_all = "PascalCase")]
        struct LibrariesResponse {
            items: Vec<JellyfinLibrary>,
        }

        #[derive(Debug, Deserialize)]
        #[serde(rename_all = "PascalCase")]
        struct JellyfinLibrary {
            id: String,
            name: String,
            collection_type: Option<String>,
            image_tags: Option<ImageTags>,
        }

        let endpoint = format!("/Users/{}/Views", self.user_id);
        let response: LibrariesResponse = self.get_json(&endpoint).await?;

        Ok(response
            .items
            .into_iter()
            .map(|lib| {
                Library::new(
                    lib.id,
                    lib.name,
                    lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
                    lib.image_tags.and_then(|tags| tags.primary()),
                )
            })
            .collect())
    }

    async fn get_items(
        &self,
        parent_id: &str,
        options: Option<GetItemsOptions>,
    ) -> Result<SearchResult, RepoError> {
        let endpoint = build_get_items_endpoint(&self.user_id, parent_id, options.as_ref());

        let response: ItemsResponse = self.get_json(&endpoint).await?;

        Ok(SearchResult {
            items: response
                .items
                .into_iter()
                .map(|item| item.into_media_item(self.user_id.clone()))
                .collect(),
            total_record_count: response.total_record_count,
        })
    }

    /// Fetch one item with every field the detail and player screens need.
    ///
    /// The `Fields=` list is the load-bearing part: Jellyfin omits these unless
    /// they are named. `MediaStreams` is what makes the item's **audio and
    /// subtitle tracks** knowable at all — there is no separate "tracks"
    /// endpoint, so this single call is how the player learns which audio tracks
    /// an item offers (`to_media_item` maps them, and the player's selector
    /// filters them by `kind`). `People` is likewise how **cast and crew** are
    /// obtained.
    ///
    /// TRACES: UR-021, UR-035 | IR-016, IR-022, JA-005, JA-009
    async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
        let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, item_id);

        let item: JellyfinItem = self.get_json(&endpoint).await?;
        let media_item = item.into_media_item(self.user_id.clone());

        Ok(media_item)
    }

    async fn get_latest_items(
        &self,
        parent_id: &str,
        limit: Option<usize>,
    ) -> Result<Vec<MediaItem>, RepoError> {
        let endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);

        let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
        Ok(items
            .into_iter()
            .map(|item| item.into_media_item(self.user_id.clone()))
            .collect())
    }

    /// Continue Watching: the items this user has started and not finished.
    ///
    /// `/Users/{uid}/Items/Resume` is the server-side answer to both "what goes
    /// in the Continue Watching row" and "where was this left off" — each item
    /// carries its own `UserData.PlaybackPositionTicks`, which is why `UserData`
    /// is named in `Fields=` rather than left to the server's default field set.
    ///
    /// TRACES: UR-019, UR-023 | IR-024, JA-013, JA-015
    async fn get_resume_items(
        &self,
        parent_id: Option<&str>,
        limit: Option<usize>,
    ) -> Result<Vec<MediaItem>, RepoError> {
        let limit_str = limit.unwrap_or(16);
        let mut endpoint = format!(
            "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
            self.user_id, limit_str
        );

        if let Some(pid) = parent_id {
            endpoint.push_str(&format!("&ParentId={}", pid));
        }

        let response: ItemsResponse = self.get_json(&endpoint).await?;
        Ok(response
            .items
            .into_iter()
            .map(|item| item.into_media_item(self.user_id.clone()))
            .collect())
    }

    /// "Next Up": the episode that follows the ones this user has finished,
    /// per series — the Shows-scoped counterpart to Continue Watching.
    ///
    /// TRACES: UR-023, UR-059 | IR-024, JA-014
    async fn get_next_up_episodes(
        &self,
        series_id: Option<&str>,
        limit: Option<usize>,
    ) -> Result<Vec<MediaItem>, RepoError> {
        let endpoint = build_next_up_endpoint(&self.user_id, series_id, limit);

        let response: ItemsResponse = self.get_json(&endpoint).await?;
        Ok(response
            .items
            .into_iter()
            .map(|item| item.into_media_item(self.user_id.clone()))
            .collect())
    }

    async fn get_recently_played_audio(
        &self,
        limit: Option<usize>,
    ) -> Result<Vec<MediaItem>, RepoError> {
        let limit_val = limit.unwrap_or(12);
        // Fetch more items to account for grouping reducing the count
        let fetch_limit = limit_val * 3;
        let endpoint = format!(
            "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
            self.user_id, fetch_limit
        );

        let response: ItemsResponse = self.get_json(&endpoint).await?;
        let items: Vec<MediaItem> = response
            .items
            .into_iter()
            .map(|item| item.into_media_item(self.user_id.clone()))
            .collect();

        debug!("[get_recently_played_audio] Fetched {} items", items.len());
        for item in &items {
            debug!("[get_recently_played_audio] Item: name={}, type={}, album_id={:?}, album_name={:?}",
                item.name, item.item_type, item.album_id, item.album_name);
        }

        // Group by album - create pseudo-album entries for tracks with same albumId
        use std::collections::BTreeMap;
        let mut album_map: BTreeMap<String, Vec<MediaItem>> = BTreeMap::new();
        let mut ungrouped = Vec::new();

        for item in items {
            // Use album_id if available, fall back to album_name for grouping
            let group_key = item.album_id.clone().or_else(|| item.album_name.clone());

            if let Some(key) = group_key {
                debug!(
                    "[get_recently_played_audio] Grouping item '{}' into album '{}'",
                    item.name, key
                );
                album_map.entry(key).or_default().push(item);
            } else {
                debug!(
                    "[get_recently_played_audio] No album_id or album_name for item: '{}'",
                    item.name
                );
                ungrouped.push(item);
            }
        }

        // Create album entries from grouped tracks
        let mut result: Vec<MediaItem> = album_map
            .into_iter()
            .map(|(album_id, tracks)| {
                let first_track = &tracks[0];
                let most_recent = tracks
                    .iter()
                    .max_by(|a, b| {
                        let date_a = a
                            .user_data
                            .as_ref()
                            .and_then(|ud| ud.last_played_date.as_deref())
                            .unwrap_or("");
                        let date_b = b
                            .user_data
                            .as_ref()
                            .and_then(|ud| ud.last_played_date.as_deref())
                            .unwrap_or("");
                        date_b.cmp(date_a)
                    })
                    .unwrap_or(first_track);

                MediaItem {
                    id: album_id,
                    name: first_track
                        .album_name
                        .clone()
                        .unwrap_or_else(|| "Unknown Album".to_string()),
                    item_type: "MusicAlbum".to_string(),
                    kind: crate::domain::MediaKind::Album,
                    is_folder: true,
                    server_id: first_track.server_id.clone(),
                    parent_id: None,
                    library_id: None,
                    overview: None,
                    genres: None,
                    production_year: None,
                    premiere_date: None,
                    community_rating: None,
                    official_rating: None,
                    runtime_ticks: None,
                    duration_ms: None,
                    primary_image_tag: first_track.primary_image_tag.clone(),
                    image_id: first_track.primary_image_tag.clone(),
                    backdrop_image_tags: None,
                    parent_backdrop_image_tags: None,
                    album_id: None,
                    album_name: None,
                    album_artist: None,
                    artists: first_track.artists.clone(),
                    artist_items: first_track.artist_items.clone(),
                    index_number: None,
                    parent_index_number: None,
                    series_id: None,
                    series_name: None,
                    season_id: None,
                    season_name: None,
                    user_data: most_recent.user_data.clone(),
                    media_streams: None,
                    media_sources: None,
                    people: None,
                }
            })
            .collect();

        // Append ungrouped tracks
        result.extend(ungrouped);

        // Return only the requested limit
        let final_result: Vec<MediaItem> = result.into_iter().take(limit_val).collect();
        debug!(
            "[get_recently_played_audio] Returning {} items after grouping",
            final_result.len()
        );
        for item in &final_result {
            debug!(
                "[get_recently_played_audio] Return: name={}, type={}",
                item.name, item.item_type
            );
        }
        Ok(final_result)
    }

    async fn get_rediscover_albums(
        &self,
        parent_id: Option<&str>,
        limit: Option<usize>,
    ) -> Result<Vec<MediaItem>, RepoError> {
        let limit_val = limit.unwrap_or(12);
        // Ask Jellyfin for played albums sorted by least-recently played first.
        // Filters=IsPlayed keeps only albums the user has actually listened to,
        // and SortBy=DatePlayed ascending surfaces the ones they've neglected.
        let mut endpoint = format!(
            "/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
            self.user_id, limit_val
        );

        if let Some(pid) = parent_id {
            endpoint.push_str(&format!("&ParentId={}", pid));
        }

        let response: ItemsResponse = self.get_json(&endpoint).await?;
        Ok(response
            .items
            .into_iter()
            .map(|item| item.into_media_item(self.user_id.clone()))
            .collect())
    }

    /// Continue Watching, narrowed to movies — the home screen's movie row and
    /// the movie library's own hero both want the unfinished films without the
    /// episodes mixed in.
    ///
    /// TRACES: UR-019, UR-034 | IR-024, JA-013, JA-015
    async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
        let limit_str = limit.unwrap_or(16);
        let endpoint = format!(
            "/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
            self.user_id, limit_str
        );

        let response: ItemsResponse = self.get_json(&endpoint).await?;
        Ok(response
            .items
            .into_iter()
            .map(|item| item.into_media_item(self.user_id.clone()))
            .collect())
    }

    async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
        // Ask Jellyfin to scope counts to albums and include them, so the
        // frontend can rank genres by popularity without probing each one.
        let mut endpoint = format!(
            "/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
            self.user_id
        );

        if let Some(pid) = parent_id {
            endpoint.push_str(&format!("&ParentId={}", pid));
        }

        #[derive(Debug, Deserialize)]
        #[serde(rename_all = "PascalCase")]
        struct GenresResponse {
            items: Vec<JellyfinGenre>,
        }

        #[derive(Debug, Deserialize)]
        #[serde(rename_all = "PascalCase")]
        struct JellyfinGenre {
            id: String,
            name: String,
            // Which count field Jellyfin populates for a genre under
            // Fields=ItemCounts varies by server/version: scoped queries may
            // fill AlbumCount, others only ChildCount. Read whichever is
            // present so ranking still works. Absent on servers that ignore
            // Fields=ItemCounts entirely, so all stay optional.
            album_count: Option<u32>,
            child_count: Option<u32>,
        }

        let response: GenresResponse = self.get_json(&endpoint).await?;
        let genres: Vec<Genre> = response
            .items
            .into_iter()
            .map(|g| Genre {
                id: g.id,
                name: g.name,
                album_count: g.album_count.or(g.child_count),
            })
            .collect();

        let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
        // TEMP DIAGNOSTIC: dump the first few genres with their counts so we can
        // see whether the server populates any count field. Remove once known.
        log::warn!(
            "get_genres: {} genres, {} carry counts. sample: {:?}",
            genres.len(),
            with_counts,
            genres
                .iter()
                .take(8)
                .map(|g| (g.name.as_str(), g.album_count))
                .collect::<Vec<_>>()
        );

        Ok(genres)
    }

    /// Search every library the user can see.
    ///
    /// `Recursive=true` with no `ParentId` is what makes this cross-library
    /// rather than folder-scoped; a caller narrowing the search passes the item
    /// types through `SearchOptions` (already expanded from an opaque
    /// `SearchScope` on this side of the boundary).
    ///
    /// TRACES: UR-008 | IR-010, JA-006
    async fn search(
        &self,
        query: &str,
        options: Option<SearchOptions>,
    ) -> Result<SearchResult, RepoError> {
        let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
        // SearchTerm is arbitrary user input and must be percent-encoded so that
        // spaces, ampersands, etc. don't corrupt the query string (a multi-word
        // search like "Star Wars" would otherwise produce a malformed URL).
        let mut endpoint = format!(
            "/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
            self.user_id,
            urlencoding::encode(query),
            limit
        );

        if let Some(opts) = options {
            if let Some(types) = opts.include_item_types {
                let encoded_types = types
                    .iter()
                    .map(|t| urlencoding::encode(t).into_owned())
                    .collect::<Vec<_>>()
                    .join(",");
                endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
            }
        }

        // Request image fields for list views (plus Genres so cached items
        // carry genres for offline genre lists/counts).
        endpoint.push_str(
            "&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData",
        );

        let response: ItemsResponse = self.get_json(&endpoint).await?;
        Ok(SearchResult {
            items: response
                .items
                .into_iter()
                .map(|item| item.into_media_item(self.user_id.clone()))
                .collect(),
            total_record_count: response.total_record_count,
        })
    }

    async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
        let endpoint = format!("/Items/{}/PlaybackInfo", item_id);

        #[derive(Debug, Serialize)]
        #[serde(rename_all = "PascalCase")]
        struct PlaybackInfoRequest {
            user_id: String,
            /// Omitted so the server resolves the source's default audio stream.
            /// Never send 0 here: the index is global across all streams, so 0 is
            /// the video stream and the negotiated source comes back soundless.
            #[serde(skip_serializing_if = "Option::is_none")]
            audio_stream_index: Option<i32>,
            #[serde(skip_serializing_if = "Option::is_none")]
            subtitle_stream_index: Option<i32>,
            start_time_ticks: i64,
            is_playback: bool,
            auto_open_live_stream: bool,
            max_streaming_bitrate: i64,
            #[serde(skip_serializing_if = "Option::is_none")]
            device_profile: Option<DeviceProfile>,
        }

        #[derive(Debug, Serialize)]
        #[serde(rename_all = "PascalCase")]
        struct DeviceProfile {
            name: String,
            max_streaming_bitrate: i64,
            max_static_bitrate: i64,
            /// Channels the device's audio route can actually voice. Without it
            /// the server may direct-play a 5.1 track to a two-channel sink,
            /// which is silence or inaudible dialogue depending on the device.
            max_audio_channels: String,
            direct_play_profiles: Vec<DirectPlayProfile>,
            transcoding_profiles: Vec<TranscodingProfile>,
            subtitle_profiles: Vec<SubtitleProfile>,
        }

        #[derive(Debug, Serialize)]
        #[serde(rename_all = "PascalCase")]
        struct DirectPlayProfile {
            #[serde(rename = "Type")]
            profile_type: String,
            container: String,
            #[serde(skip_serializing_if = "Option::is_none")]
            video_codec: Option<String>,
            audio_codec: String,
        }

        #[derive(Debug, Serialize)]
        #[serde(rename_all = "PascalCase")]
        struct TranscodingProfile {
            #[serde(rename = "Type")]
            profile_type: String,
            context: String,
            protocol: String,
            container: String,
            #[serde(skip_serializing_if = "Option::is_none")]
            video_codec: Option<String>,
            audio_codec: String,
            max_audio_channels: String,
        }

        #[derive(Debug, Serialize)]
        #[serde(rename_all = "PascalCase")]
        struct SubtitleProfile {
            format: String,
            method: String,
        }

        #[derive(Debug, Deserialize)]
        #[serde(rename_all = "PascalCase")]
        struct PlaybackInfoResponse {
            media_sources: Vec<MediaSource>,
            play_session_id: String,
        }

        #[derive(Debug, Deserialize)]
        #[serde(rename_all = "PascalCase")]
        struct MediaSource {
            id: String,
            supports_direct_play: bool,
            supports_transcoding: bool,
            transcoding_url: Option<String>,
            #[serde(default)]
            media_streams: Vec<MediaStream>,
        }

        #[derive(Debug, Deserialize)]
        #[serde(rename_all = "PascalCase")]
        struct MediaStream {
            #[serde(rename = "Type")]
            stream_type: String,
            #[serde(default)]
            index: i32,
            #[serde(default)]
            codec: Option<String>,
            /// The track the server serves when the client pins none.
            #[serde(default)]
            is_default: bool,
        }

        // Get detected codecs from Android MediaCodecList or use platform defaults
        #[cfg(target_os = "android")]
        let (video_codecs, audio_codecs) = crate::player::get_detected_codecs()
            .map(|(video, audio, _channels)| (video, audio))
            .unwrap_or_else(|| {
                warn!("[DeviceProfile] Codec detection not complete, using conservative defaults");
                ("h264,hevc".to_string(), "aac,mp3".to_string())
            });

        // Linux desktop plays video through the WebKitGTK HTML5 <video> element,
        // which cannot reliably decode HEVC/AV1/VP9. Advertise only codecs the
        // WebView can decode so Jellyfin transcodes anything else to h264 HLS.
        // (Audio-only files still direct-play via MPV; these codecs are what
        // both renderers handle, and the audio profile keeps them in full while
        // the video profile is narrowed below.)
        #[cfg(all(not(target_os = "android"), target_os = "linux"))]
        let (video_codecs, audio_codecs) =
            ("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string());

        #[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
        let (video_codecs, audio_codecs) = (
            "h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
            "aac,mp3,opus,vorbis,flac".to_string(),
        );

        // Video plays in a webview <video> element on every platform, which
        // decodes a narrower audio set than the platform does — so the video
        // profile must claim less than the audio-only profile. Without this a
        // Dolby-licensed device advertises eac3, gets a direct play, and shows
        // picture with no sound.
        let video_audio_codecs = super::device_profile::video_audio_codecs(&audio_codecs);

        info!("[DeviceProfile] Using video codecs: {}", video_codecs);
        info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);
        info!(
            "[DeviceProfile] Audio codecs for video direct play: {}",
            video_audio_codecs
        );

        // Bound every profile by what the audio route can actually voice, so a
        // multichannel track is downmixed by the server rather than direct-played
        // into a sink that has nowhere to put the extra channels.
        let max_audio_channels = super::device_profile::max_audio_channels().to_string();
        info!("[DeviceProfile] Max audio channels: {}", max_audio_channels);

        // The user's bandwidth ceiling has to be part of the *negotiation*, not
        // just the transcode URL: `max_static_bitrate` is what makes the server
        // refuse to direct-play a source fatter than the cap, and without it a
        // 30 Mbps remux is handed over untouched and every URL parameter
        // downstream is moot. `Original` keeps the historical "no ceiling"
        // sentinel so the default path negotiates exactly as before.
        //
        // TRACES: UR-074 | DR-162
        let quality = streaming_quality();
        let negotiated_bitrate = quality.max_bitrate().unwrap_or(999_999_999) as i64;
        if let Some(cap) = quality.max_bitrate() {
            info!(
                "[DeviceProfile] Streaming quality cap active: {} ({} bps)",
                quality.label(),
                cap
            );
        }

        // Create device profile with detected hardware capabilities
        let device_profile = DeviceProfile {
            name: "JellyTau Native Player".to_string(),
            max_streaming_bitrate: negotiated_bitrate,
            max_static_bitrate: negotiated_bitrate,
            max_audio_channels: max_audio_channels.clone(),
            direct_play_profiles: vec![
                DirectPlayProfile {
                    profile_type: "Video".to_string(),
                    container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp".to_string(),
                    video_codec: Some(video_codecs.clone()),
                    // The webview decodes this stream, not ExoPlayer/MPV.
                    audio_codec: video_audio_codecs.clone(),
                },
                DirectPlayProfile {
                    profile_type: "Audio".to_string(),
                    container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
                    video_codec: None,
                    // Audio-only really is the native player's, so it keeps the
                    // full platform list — narrowing it would transcode music
                    // that plays perfectly well.
                    audio_codec: audio_codecs.clone(),
                },
            ],
            transcoding_profiles: vec![
                TranscodingProfile {
                    profile_type: "Video".to_string(),
                    context: "Streaming".to_string(),
                    protocol: "hls".to_string(),
                    container: "ts".to_string(),
                    video_codec: Some("h264,hevc".to_string()),
                    audio_codec: "aac,mp3".to_string(),
                    max_audio_channels: max_audio_channels.clone(),
                },
                TranscodingProfile {
                    profile_type: "Audio".to_string(),
                    context: "Streaming".to_string(),
                    protocol: "http".to_string(),
                    container: "mp3".to_string(),
                    video_codec: None,
                    audio_codec: "mp3".to_string(),
                    max_audio_channels: max_audio_channels.clone(),
                },
            ],
            subtitle_profiles: super::device_profile::subtitle_profiles()
                .into_iter()
                .map(|(format, method)| SubtitleProfile {
                    format: format.to_string(),
                    method: method.to_string(),
                })
                .collect(),
        };

        // POST to PlaybackInfo with device profile containing detected codecs
        let request_body = PlaybackInfoRequest {
            user_id: self.user_id.clone(),
            audio_stream_index: None, // Let the server pick the source default
            // Never let the server choose a subtitle track for us. Omitting this
            // makes it honour the source's default/forced flag, and an image-based
            // default (PGS) it cannot send as a sidecar becomes SubtitleMethod=Encode
            // — burn-in, which forces a full video re-encode of a stream that would
            // otherwise be remuxed. The app renders subtitles itself (UR-020).
            //
            // TRACES: UR-020, UR-004 | DR-176 | UT-168
            subtitle_stream_index: Some(super::device_profile::playback_subtitle_stream_index()),
            start_time_ticks: 0,
            is_playback: true,
            auto_open_live_stream: true,
            // The user's cap, or the historical 20 Mbps allowance when uncapped.
            max_streaming_bitrate: quality.max_bitrate().unwrap_or(20_000_000) as i64,
            device_profile: Some(device_profile), // Now sending profile with detected codecs
        };

        let response: PlaybackInfoResponse =
            self.post_json_response(&endpoint, &request_body).await?;
        let source = response.media_sources.first().ok_or(RepoError::NotFound {
            message: "No media sources available".to_string(),
        })?;

        // Log available media streams for debugging
        info!(
            "PlaybackInfo MediaSource has {} streams",
            source.media_streams.len()
        );
        for stream in &source.media_streams {
            info!(
                "  Stream type={}, index={}, codec={:?}",
                stream.stream_type, stream.index, stream.codec
            );
        }

        // Name the tracks we are declining to have the server composite. Burn-in
        // rules out remuxing the video, so a single image-based track can turn a
        // free passthrough into a full re-encode; when that used to happen there
        // was nothing in the log connecting the stall to the subtitle.
        for stream in &source.media_streams {
            if stream.stream_type == "Subtitle" {
                if let Some(codec) = stream.codec.as_deref() {
                    if super::device_profile::subtitle_forces_burn_in(codec) {
                        info!(
                            "  Subtitle index={} ({}) is image-based — not requested; the app renders text tracks itself rather than have the server burn it in (which would force a video re-encode)",
                            stream.index, codec
                        );
                    }
                }
            }
        }

        // Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec
        // but ignores its audio codec, so it offers an E-AC-3 track for direct
        // play even though DR-148 advertises only AAC — and the webview renders
        // the picture in silence. Judge the track we would actually be served
        // against what the webview can decode, and override the server's answer.
        let audio_streams: Vec<(Option<&str>, bool)> = source
            .media_streams
            .iter()
            .filter(|stream| stream.stream_type == "Audio")
            .map(|stream| (stream.codec.as_deref(), stream.is_default))
            .collect();
        let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);

        // Use TranscodingUrl from response if available (Streamyfin pattern)
        let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
            // The server started this job and named the session — adopt it, or a
            // later quality switch / seek on this stream has no previous job to
            // stop and ends up contending with the one currently playing.
            if let Some(previous) = adopt_video_play_session(response.play_session_id.clone()) {
                self.stop_transcode(&previous).await;
            }
            // The server built this URL from its *own* subtitle verdict, so it can
            // hand back the burn-in the request above just declined. Strip it: the
            // negotiated answer only holds for the stream we actually open.
            //
            // TRACES: UR-020, UR-004 | DR-176 | UT-168
            format!(
                "{}{}",
                self.server_url,
                super::device_profile::without_server_chosen_subtitle(transcoding_url)
            )
        } else if audio_forces_transcode {
            warn!(
                "[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
                audio_streams.first().and_then(|(codec, _)| *codec)
            );
            self.get_video_stream_url(item_id, Some(&source.id), None)
                .await?
        } else {
            // Fall back to direct stream URL. No audioStreamIndex: static=true
            // serves the original file untouched, and pinning index 0 (the video
            // stream) only misleads servers that do honour it.
            format!(
                "{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&userId={}",
                self.server_url,
                item_id,
                source.id,
                self.access_token,
                self.user_id
            )
        };

        info!("Final stream URL: {}", stream_url);

        Ok(PlaybackInfo {
            media_source_id: source.id.clone(),
            play_session_id: response.play_session_id,
            stream_url,
            direct_play: source.supports_direct_play && !audio_forces_transcode,
            needs_transcoding: audio_forces_transcode
                || (!source.supports_direct_play && source.supports_transcoding),
        })
    }

    async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
        // Construct direct audio stream URL
        let url = format!(
            "{}/Audio/{}/stream?UserId={}&api_key={}&Static=true",
            self.server_url, item_id, self.user_id, self.access_token
        );
        Ok(url)
    }

    async fn get_audio_only_stream_url_for_video(
        &self,
        item_id: &str,
        media_source_id: Option<&str>,
        start_time_seconds: Option<f64>,
        audio_stream_index: Option<i32>,
    ) -> Result<String, RepoError> {
        self.build_audio_only_stream_url_for_video(
            item_id,
            media_source_id,
            start_time_seconds,
            audio_stream_index,
        )
        .await
    }

    async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
        // Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
        // type "TvChannel" — playable via open_live_stream.
        let endpoint = format!(
            "/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
            self.user_id
        );
        let response: ItemsResponse = self.get_json(&endpoint).await?;
        Ok(response
            .items
            .into_iter()
            .map(|item| item.into_media_item(self.server_url.clone()))
            .collect())
    }

    async fn get_channels(&self) -> Result<SearchResult, RepoError> {
        // Root list of plugin "Channels". Drill-down into a channel folder reuses
        // get_items(channel_id, ...).
        let endpoint = format!("/Channels?UserId={}", self.user_id);
        let response: ItemsResponse = self.get_json(&endpoint).await?;
        let total = response.total_record_count;
        let items = response
            .items
            .into_iter()
            .map(|item| item.into_media_item(self.server_url.clone()))
            .collect();
        Ok(SearchResult {
            items,
            total_record_count: total,
        })
    }

    async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
        // Live channels require a PlaybackInfo call with AutoOpenLiveStream so the
        // server opens the live stream and returns a ready-to-play transcoding URL.
        // We send a minimal request; the server applies its own defaults for live.
        #[derive(Debug, Serialize)]
        #[serde(rename_all = "PascalCase")]
        struct OpenLiveStreamRequest {
            user_id: String,
            #[serde(rename = "AutoOpenLiveStream")]
            auto_open_live_stream: bool,
            is_playback: bool,
            max_streaming_bitrate: u64,
            /// "No subtitle", for the same reason as everywhere else: omitting it
            /// lets the server apply the channel's default track, and broadcast
            /// subtitles are DVB bitmaps — deliverable only by burning them in,
            /// which forces a full re-encode of a stream that is already tight.
            ///
            /// TRACES: UR-020, UR-004 | DR-176 | UT-168
            subtitle_stream_index: i32,
        }

        #[derive(Debug, Deserialize)]
        #[serde(rename_all = "PascalCase")]
        struct OpenLiveStreamResponse {
            #[serde(default)]
            media_sources: Vec<LiveMediaSource>,
            play_session_id: Option<String>,
        }

        #[derive(Debug, Deserialize)]
        #[serde(rename_all = "PascalCase")]
        struct LiveMediaSource {
            id: String,
            transcoding_url: Option<String>,
            live_stream_id: Option<String>,
        }

        let endpoint = format!("/Items/{}/PlaybackInfo", item_id);
        let request = OpenLiveStreamRequest {
            user_id: self.user_id.clone(),
            auto_open_live_stream: true,
            is_playback: true,
            // Live TV is video like any other, so the user's cap applies here
            // too — a channel opened at the source bitrate would walk straight
            // past a limit set for the connection. TRACES: UR-074 | DR-162
            max_streaming_bitrate: streaming_quality().max_bitrate().unwrap_or(20_000_000),
            subtitle_stream_index: super::device_profile::playback_subtitle_stream_index(),
        };

        let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;

        let source = response
            .media_sources
            .into_iter()
            .next()
            .ok_or(RepoError::NotFound {
                message: "No live media source returned".to_string(),
            })?;

        // The transcoding URL is server-relative; make it absolute. If the server
        // did not provide one (rare for live), fall back to the HLS master endpoint.
        let stream_url = match source.transcoding_url {
            // As in `get_playback_info`: the server chose the subtitle in this
            // URL, so decline it here too. TRACES: UR-020 | DR-176 | UT-168
            Some(url) => format!(
                "{}{}",
                self.server_url,
                super::device_profile::without_server_chosen_subtitle(&url)
            ),
            None => format!(
                "{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
                self.server_url,
                item_id,
                self.access_token,
                source.id,
                source.live_stream_id.clone().unwrap_or_default(),
                super::device_profile::playback_subtitle_stream_index(),
            ),
        };

        Ok(LiveStreamInfo {
            stream_url,
            play_session_id: response.play_session_id,
            live_stream_id: source.live_stream_id,
            media_source_id: Some(source.id),
        })
    }

    async fn report_playback_start(
        &self,
        item_id: &str,
        position_ticks: i64,
    ) -> Result<(), RepoError> {
        #[derive(Serialize)]
        #[serde(rename_all = "PascalCase")]
        struct PlaybackStartRequest {
            item_id: String,
            position_ticks: i64,
            play_command: String,
            is_paused: bool,
        }

        let request = PlaybackStartRequest {
            item_id: item_id.to_string(),
            position_ticks,
            play_command: "PlayNow".to_string(),
            is_paused: false,
        };

        self.post_json("/Sessions/Playing", &request).await
    }

    async fn report_playback_progress(
        &self,
        item_id: &str,
        position_ticks: i64,
    ) -> Result<(), RepoError> {
        #[derive(Serialize)]
        #[serde(rename_all = "PascalCase")]
        struct PlaybackProgressRequest {
            item_id: String,
            position_ticks: i64,
            is_paused: bool,
        }

        let request = PlaybackProgressRequest {
            item_id: item_id.to_string(),
            position_ticks,
            is_paused: false,
        };

        self.post_json("/Sessions/Playing/Progress", &request).await
    }

    async fn report_playback_stopped(
        &self,
        item_id: &str,
        position_ticks: i64,
    ) -> Result<(), RepoError> {
        #[derive(Serialize)]
        #[serde(rename_all = "PascalCase")]
        struct PlaybackStoppedRequest {
            item_id: String,
            position_ticks: i64,
        }

        let request = PlaybackStoppedRequest {
            item_id: item_id.to_string(),
            position_ticks,
        };

        self.post_json("/Sessions/Playing/Stopped", &request).await
    }

    fn get_image_url(
        &self,
        item_id: &str,
        image_type: ImageType,
        options: Option<ImageOptions>,
    ) -> String {
        let mut url = format!(
            "{}/Items/{}/Images/{}",
            self.server_url,
            item_id,
            image_type.as_str()
        );

        // Authentication is handled by X-Emby-Authorization header in download_bytes()
        // Do NOT include api_key here — some Jellyfin servers reject requests when
        // api_key is present but the token doesn't match the expected format.
        let mut params: Vec<String> = Vec::new();

        if let Some(opts) = options {
            if let Some(width) = opts.max_width {
                params.push(format!("maxWidth={}", width));
            }
            if let Some(height) = opts.max_height {
                params.push(format!("maxHeight={}", height));
            }
            if let Some(quality) = opts.quality {
                params.push(format!("quality={}", quality));
            }
            if let Some(tag) = opts.tag {
                params.push(format!("tag={}", tag));
            }
        }

        if !params.is_empty() {
            url.push('?');
            url.push_str(&params.join("&"));
        }

        url
    }

    fn get_subtitle_url(
        &self,
        item_id: &str,
        media_source_id: &str,
        stream_index: i32,
        format: &str,
    ) -> String {
        format!(
            "{}/Videos/{}/{}/Subtitles/{}/{}",
            self.server_url, item_id, media_source_id, stream_index, format
        )
    }

    /// TRACES: UR-071 | DR-123
    fn get_video_download_url(
        &self,
        item_id: &str,
        quality: &str,
        media_source_id: Option<&str>,
        source_audio_codec: Option<&str>,
    ) -> String {
        // NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally
        // available (returns 404 on many server configs), which silently broke
        // every movie/TV download. Use the progressive `stream.mp4` endpoint
        // instead — it is always present and supports HTTP Range, which the
        // download worker relies on for resume.
        let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
        let mut params = vec![format!("api_key={}", self.access_token)];

        // Map the frontend quality preset to concrete transcode params. For
        // "original" we request a direct static copy (no transcode) which is
        // byte-range resumable; other presets ask the server to transcode.
        //
        // 🔴 It is `videoBitRate`/`audioBitRate` — **capital R**. Jellyfin binds
        // query keys case-insensitively, so `maxHeight`/`videoCodec` casing is
        // free, but `videoBitrate` (lowercase r) is a *different token*: it
        // fails to bind, is silently dropped, and the requested cap vanishes
        // with no error. That is why every "480p"/"720p" download came back at
        // full original quality. See `Jellyfin.Api` BaseEncodingJobOptions.
        //
        // `allowVideoStreamCopy=false` forces a real re-encode. Without it the
        // server may stream-copy the source when it already satisfies the cap —
        // fine in itself, but it also means a mis-typed cap degrades silently.
        // Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy;
        // video copy is gated by `allowVideoStreamCopy`.
        match quality {
            "high" => {
                params.push("videoBitRate=8000000".to_string());
                params.push("maxHeight=1080".to_string());
                params.push("audioBitRate=384000".to_string());
                params.push("videoCodec=h264".to_string());
                params.push("audioCodec=aac".to_string());
                params.push("allowVideoStreamCopy=false".to_string());
            }
            "medium" => {
                params.push("videoBitRate=4000000".to_string());
                params.push("maxHeight=720".to_string());
                params.push("audioBitRate=256000".to_string());
                params.push("videoCodec=h264".to_string());
                params.push("audioCodec=aac".to_string());
                params.push("allowVideoStreamCopy=false".to_string());
            }
            "low" => {
                params.push("videoBitRate=1500000".to_string());
                params.push("maxHeight=480".to_string());
                params.push("audioBitRate=128000".to_string());
                params.push("videoCodec=h264".to_string());
                params.push("audioCodec=aac".to_string());
                params.push("allowVideoStreamCopy=false".to_string());
            }
            // "original" (and any unknown value) → direct, resumable copy —
            // unless the audio in that copy is undecodable where the file will
            // be played back. A download is watched with no server in reach, so
            // it has to satisfy the same constraint DR-149 applies to streams:
            // the webview `<video>` element renders video on both platforms and
            // decodes none of AC-3/E-AC-3/DTS/TrueHD. Copying those bytes to
            // disk is what made a downloaded film play offline as picture with
            // no sound while the same film had sound when streamed.
            //
            // Only the *audio* is re-encoded. `allowVideoStreamCopy` keeps an
            // h264 source's picture byte-for-byte, so "original" still means
            // original quality, and no bitrate or resolution cap is added. A
            // source the webview could not have rendered anyway (HEVC) is
            // re-encoded to h264 as a side effect, which is the only form of it
            // that would have played.
            //
            // The cost of the transcode is that the response is no longer
            // range-resumable, which is exactly why this is decided per item
            // rather than applied to every `original` download.
            //
            // TRACES: UR-071, UR-004 | DR-171 | UT-166
            _ => match source_audio_codec {
                Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
                    params.push("videoCodec=h264".to_string());
                    params.push("allowVideoStreamCopy=true".to_string());
                    params.push("audioCodec=aac".to_string());
                    params.push("audioBitRate=384000".to_string());
                }
                // Decodable, or unknown: an unknown codec must not provoke a
                // transcode — that would burn server CPU on a guess for files
                // that play perfectly well.
                _ => params.push("Static=true".to_string()),
            },
        }

        // Add media source ID if provided
        if let Some(source_id) = media_source_id {
            params.push(format!("mediaSourceId={}", source_id));
        }

        url.push('?');
        url.push_str(&params.join("&"));

        url
    }

    async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
        let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
        self.post_json(&endpoint, &serde_json::json!({})).await
    }

    /// TRACES: UR-067 | DR-115, JA-033 | UT-100
    async fn get_favorites(
        &self,
        scope: SearchScope,
        options: Option<GetItemsOptions>,
    ) -> Result<SearchResult, RepoError> {
        let endpoint = build_favorites_endpoint(&self.user_id, scope, options.as_ref());
        let response: ItemsResponse = self.get_json(&endpoint).await?;

        Ok(SearchResult {
            items: response
                .items
                .into_iter()
                .map(|item| item.into_media_item(self.user_id.clone()))
                .collect(),
            total_record_count: response.total_record_count,
        })
    }

    /// Un-favourite an item: the same `/Users/{uid}/FavoriteItems/{id}` resource
    /// as [`Self::mark_favorite`], removed rather than posted. Written out by
    /// hand rather than through `post_json` because it is the one favourite call
    /// that needs `DELETE`.
    ///
    /// TRACES: UR-017 | JA-018, DR-021
    async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
        let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
        let url = format!("{}{}", self.server_url, endpoint);

        let result = async {
            let request = self
                .http_client
                .client
                .delete(&url)
                .header("X-Emby-Authorization", self.auth_header())
                .build()
                .map_err(|e| RepoError::Network {
                    message: format!("Failed to build request: {}", e),
                })?;

            let response = self
                .http_client
                .request_with_retry(request)
                .await
                .map_err(|e| RepoError::Network {
                    message: e.to_string(),
                })?;

            if !response.status().is_success() {
                return Err(RepoError::Server {
                    message: format!("HTTP {}", response.status()),
                });
            }

            Ok(())
        }
        .await;

        self.report_outcome(&result).await;
        result
    }

    /// `DELETE /Users/{userId}/PlayedItems/{itemId}` — Jellyfin's "mark
    /// unplayed", which also zeroes the resume position. On a folder (series,
    /// season) the server applies it recursively to the children.
    ///
    /// TRACES: UR-064 | DR-106, JA-033
    async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
        let endpoint = format!("/Users/{}/PlayedItems/{}", self.user_id, item_id);
        let url = format!("{}{}", self.server_url, endpoint);

        let result = async {
            let request = self
                .http_client
                .client
                .delete(&url)
                .header("X-Emby-Authorization", self.auth_header())
                .build()
                .map_err(|e| RepoError::Network {
                    message: format!("Failed to build request: {}", e),
                })?;

            let response = self
                .http_client
                .request_with_retry(request)
                .await
                .map_err(|e| RepoError::Network {
                    message: e.to_string(),
                })?;

            if !response.status().is_success() {
                return Err(RepoError::Server {
                    message: format!("HTTP {}", response.status()),
                });
            }

            Ok(())
        }
        .await;

        self.report_outcome(&result).await;
        result
    }

    /// `POST /Users/{userId}/PlayedItems/{itemId}` — the mirror image of
    /// `clear_watch_history`.
    ///
    /// TRACES: UR-025 | DR-131 | JA-035
    async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
        let endpoint = format!("/Users/{}/PlayedItems/{}", self.user_id, item_id);
        let url = format!("{}{}", self.server_url, endpoint);

        let result = async {
            let request = self
                .http_client
                .client
                .post(&url)
                .header("X-Emby-Authorization", self.auth_header())
                .header("Content-Length", "0")
                .build()
                .map_err(|e| RepoError::Network {
                    message: format!("Failed to build request: {}", e),
                })?;

            let response = self
                .http_client
                .request_with_retry(request)
                .await
                .map_err(|e| RepoError::Network {
                    message: e.to_string(),
                })?;

            if !response.status().is_success() {
                return Err(RepoError::Server {
                    message: format!("HTTP {}", response.status()),
                });
            }

            Ok(())
        }
        .await;

        self.report_outcome(&result).await;
        result
    }

    /// A single Person item (actor, director, …) by id.
    ///
    /// Jellyfin models people as ordinary items, so this is the plain item
    /// endpoint rather than anything under `/Persons`; the cast entries returned
    /// on an item's `People` field carry the ids this is called with.
    ///
    /// TRACES: UR-035, UR-036 | IR-022, JA-030
    async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
        let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
        let item: JellyfinItem = self.get_json(&endpoint).await?;
        Ok(item.into_media_item(self.user_id.clone()))
    }

    /// A person's filmography — every item they are credited on.
    ///
    /// TRACES: UR-036 | IR-022, JA-031
    async fn get_items_by_person(
        &self,
        person_id: &str,
        options: Option<GetItemsOptions>,
    ) -> Result<SearchResult, RepoError> {
        let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);

        let mut endpoint = format!(
            "/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
            self.user_id, person_id, limit
        );

        // Add item type filtering if specified in options
        if let Some(ref opts) = options {
            if let Some(ref include_types) = opts.include_item_types {
                if !include_types.is_empty() {
                    let types_param = include_types.join(",");
                    endpoint.push_str(&format!("&IncludeItemTypes={}", types_param));
                }
            }
        }

        let response: ItemsResponse = self.get_json(&endpoint).await?;
        Ok(SearchResult {
            items: response
                .items
                .into_iter()
                .map(|item| item.into_media_item(self.user_id.clone()))
                .collect(),
            total_record_count: response.total_record_count,
        })
    }

    async fn get_similar_items(
        &self,
        item_id: &str,
        limit: Option<usize>,
    ) -> Result<SearchResult, RepoError> {
        let limit_str = limit.unwrap_or(20);

        // Try the /Similar endpoint which works for most items
        let endpoint = format!(
            "/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
            item_id, self.user_id, limit_str
        );

        let response: ItemsResponse = self.get_json(&endpoint).await?;
        Ok(SearchResult {
            items: response
                .items
                .into_iter()
                .map(|item| item.into_media_item(self.user_id.clone()))
                .collect(),
            total_record_count: response.total_record_count,
        })
    }

    // ===== Playlist Methods =====

    async fn create_playlist(
        &self,
        name: &str,
        item_ids: &[String],
    ) -> Result<PlaylistCreatedResult, RepoError> {
        info!(
            "[OnlineRepo] Creating playlist '{}' with {} items",
            name,
            item_ids.len()
        );
        let body = serde_json::json!({
            "Name": name,
            "Ids": item_ids,
            "MediaType": "Audio",
            "UserId": self.user_id,
        });
        let response: CreatePlaylistResponse = self.post_json_response("/Playlists", &body).await?;
        Ok(PlaylistCreatedResult { id: response.id })
    }

    async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
        info!("[OnlineRepo] Deleting playlist {}", playlist_id);
        let endpoint = format!("/Items/{}", playlist_id);
        let url = format!("{}{}", self.server_url, endpoint);

        let request = self
            .http_client
            .client
            .delete(&url)
            .header("X-Emby-Authorization", self.auth_header())
            .build()
            .map_err(|e| RepoError::Network {
                message: format!("Failed to build request: {}", e),
            })?;

        let response = self
            .http_client
            .request_with_retry(request)
            .await
            .map_err(|e| RepoError::Network {
                message: e.to_string(),
            })?;

        if !response.status().is_success() {
            return Err(RepoError::Server {
                message: format!("HTTP {}", response.status()),
            });
        }

        Ok(())
    }

    async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
        info!(
            "[OnlineRepo] Renaming playlist {} to '{}'",
            playlist_id, name
        );
        let endpoint = format!("/Items/{}", playlist_id);
        self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
            .await
    }

    async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
        let endpoint = format!(
            "/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
            playlist_id, self.user_id
        );

        let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
        debug!(
            "[OnlineRepo] Got {} playlist items for {}",
            response.items.len(),
            playlist_id
        );

        Ok(response
            .items
            .into_iter()
            .map(|pi| PlaylistEntry {
                playlist_item_id: pi.playlist_item_id,
                item: pi.item.into_media_item(self.user_id.clone()),
            })
            .collect())
    }

    async fn add_to_playlist(
        &self,
        playlist_id: &str,
        item_ids: &[String],
    ) -> Result<(), RepoError> {
        info!(
            "[OnlineRepo] Adding {} items to playlist {}",
            item_ids.len(),
            playlist_id
        );
        let ids_param = item_ids.join(",");
        let endpoint = format!("/Playlists/{}/Items?Ids={}", playlist_id, ids_param);
        self.post_json(&endpoint, &serde_json::json!({})).await
    }

    async fn remove_from_playlist(
        &self,
        playlist_id: &str,
        entry_ids: &[String],
    ) -> Result<(), RepoError> {
        info!(
            "[OnlineRepo] Removing {} entries from playlist {}",
            entry_ids.len(),
            playlist_id
        );
        let ids_param = entry_ids.join(",");
        let endpoint = format!("/Playlists/{}/Items?EntryIds={}", playlist_id, ids_param);
        let url = format!("{}{}", self.server_url, endpoint);

        let request = self
            .http_client
            .client
            .delete(&url)
            .header("X-Emby-Authorization", self.auth_header())
            .build()
            .map_err(|e| RepoError::Network {
                message: format!("Failed to build request: {}", e),
            })?;

        let response = self
            .http_client
            .request_with_retry(request)
            .await
            .map_err(|e| RepoError::Network {
                message: e.to_string(),
            })?;

        if !response.status().is_success() {
            return Err(RepoError::Server {
                message: format!("HTTP {}", response.status()),
            });
        }

        Ok(())
    }

    async fn move_playlist_item(
        &self,
        playlist_id: &str,
        item_id: &str,
        new_index: u32,
    ) -> Result<(), RepoError> {
        info!(
            "[OnlineRepo] Moving item {} in playlist {} to index {}",
            item_id, playlist_id, new_index
        );
        let endpoint = format!(
            "/Playlists/{}/Items/{}/Move/{}",
            playlist_id, item_id, new_index
        );
        self.post_json(&endpoint, &serde_json::json!({})).await
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::utils::lock::MutexSafe;
    use std::sync::Arc;

    fn create_test_repository() -> OnlineRepository {
        let http_config = crate::jellyfin::HttpConfig::default();
        let http_client =
            Arc::new(HttpClient::new(http_config).expect("Failed to create HTTP client for test"));
        OnlineRepository::new(
            http_client,
            "https://test.server.com".to_string(),
            "test-user-id".to_string(),
            "test-access-token".to_string(),
        )
    }

    /// Build a repository wired to a real ConnectivityReporter so we can assert
    /// how `report_outcome` classifies each `RepoError` into reachability.
    /// (No app handle → event emission is a harmless no-op.)
    fn create_test_repository_with_connectivity(
    ) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
        let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
            .expect("Failed to create HTTP client for monitor");
        let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
        let reporter = monitor.reporter();
        let repo = create_test_repository().with_connectivity(reporter.clone());
        (repo, reporter)
    }

    /// `report_outcome` is the seam between repository traffic and the
    /// connectivity monitor. Verify each `RepoError` variant routes correctly:
    /// - the server answering at all (Ok / 401 / 404 / 5xx) ⇒ reachable
    /// - a network-level failure ⇒ marked unreachable (debounce reduced for test)
    /// - local-side errors (Database / Offline) ⇒ no effect on reachability
    ///
    /// @req-test: UR-002 - Access media when online or offline
    /// @req-test: DR-013 - Repository pattern for online/offline data access
    #[tokio::test]
    async fn test_report_outcome_classifies_server_answered_as_reachable() {
        let (repo, reporter) = create_test_repository_with_connectivity();

        // Drive offline first so we can observe "recover to reachable".
        for err in [
            RepoError::Authentication {
                message: "401".into(),
            },
            RepoError::NotFound {
                message: "404".into(),
            },
            RepoError::Server {
                message: "500".into(),
            },
        ] {
            reporter.mark_unreachable_for_test().await;
            assert!(!reporter.is_reachable().await, "precondition: offline");

            let result: Result<(), RepoError> = Err(err);
            repo.report_outcome(&result).await;

            assert!(
                reporter.is_reachable().await,
                "a server that answers should be reported reachable"
            );
        }

        // Ok should also report reachable.
        reporter.mark_unreachable_for_test().await;
        let ok: Result<(), RepoError> = Ok(());
        repo.report_outcome(&ok).await;
        assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
    }

    /// Local-side errors must NOT flip reachability — they say nothing about the
    /// server.
    #[tokio::test]
    async fn test_report_outcome_ignores_local_errors() {
        let (repo, reporter) = create_test_repository_with_connectivity();

        // Force offline, then a Database/Offline error must leave it offline
        // (not falsely report reachable).
        reporter.mark_unreachable_for_test().await;
        for err in [
            RepoError::Database {
                message: "cache".into(),
            },
            RepoError::Offline,
        ] {
            let result: Result<(), RepoError> = Err(err);
            repo.report_outcome(&result).await;
            assert!(
                !reporter.is_reachable().await,
                "local-side error must not change reachability"
            );
        }
    }

    /// When connectivity is known-offline, `get_json` must fast-fail with
    /// `RepoError::Offline` instead of running the full HTTP retry cycle (~7s).
    /// This is what keeps offline browsing snappy. `test.server.com` is
    /// unroutable, so if the guard were absent this would hang on retries; the
    /// assertion returning promptly with `Offline` proves the short-circuit.
    #[tokio::test]
    async fn test_get_json_fast_fails_when_offline() {
        let (repo, reporter) = create_test_repository_with_connectivity();
        reporter.mark_unreachable_for_test().await;
        assert!(!reporter.is_reachable().await, "precondition: offline");

        let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
        assert!(
            matches!(result, Err(RepoError::Offline)),
            "known-offline get_json should return Offline immediately, got {:?}",
            result
        );
    }

    /// A network error routes through the debounced path. A single failure stays
    /// online (debounce window not yet elapsed).
    #[tokio::test]
    async fn test_report_outcome_network_error_is_debounced() {
        let (repo, reporter) = create_test_repository_with_connectivity();
        assert!(reporter.is_reachable().await, "starts online");

        let result: Result<(), RepoError> = Err(RepoError::Network {
            message: "timeout".into(),
        });
        repo.report_outcome(&result).await;

        assert!(
            reporter.is_reachable().await,
            "a single network failure stays online (debounced)"
        );
    }

    #[tokio::test]
    async fn test_get_audio_stream_url_formats_correctly() {
        let repo = create_test_repository();
        let item_id = "test-track-123";

        let result = repo.get_audio_stream_url(item_id).await;

        assert!(result.is_ok());
        let url = result.unwrap();
        assert_eq!(
            url,
            "https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&api_key=test-access-token&Static=true"
        );
    }

    /// Serialises every test whose expectations depend on the process-wide
    /// streaming ceiling, and restores the uncapped default afterwards — without
    /// it, a capped test running concurrently changes what an uncapped one sees.
    ///
    /// TRACES: UR-074 | DR-162
    static QUALITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    struct QualityFixture(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);

    impl QualityFixture {
        fn set(quality: StreamingQuality) -> Self {
            let guard = QUALITY_LOCK.lock_safe();
            set_streaming_quality(quality);
            Self(guard)
        }
    }

    impl Drop for QualityFixture {
        fn drop(&mut self) {
            set_streaming_quality(StreamingQuality::Original);
        }
    }

    /// A cap has to reach the transcode URL as all four of its parts: the total
    /// ceiling, the split between video and audio, and the resolution the budget
    /// can carry. Capping only `MaxStreamingBitrate` would leave the server
    /// encoding 1080p into 2 Mbps.
    ///
    /// TRACES: UR-074 | DR-162 | UT-156
    #[tokio::test]
    async fn test_video_stream_url_applies_bitrate_cap() {
        let _fixture = QualityFixture::set(StreamingQuality::Mbps2);
        let repo = create_test_repository();

        let url = repo
            .get_video_stream_url("vid-1", None, None)
            .await
            .unwrap();

        assert!(url.contains("MaxStreamingBitrate=2000000"), "url: {url}");
        // 2 Mbps total less the 192 kbps audio share — the two must not sum to
        // more than the cap the user asked for.
        assert!(url.contains("VideoBitrate=1808000"), "url: {url}");
        assert!(url.contains("AudioBitrate=192000"), "url: {url}");
        assert!(url.contains("MaxHeight=720"), "url: {url}");
    }

    /// The uncapped default must keep the exact transcode allowance this
    /// endpoint has always used, and must not start constraining resolution.
    ///
    /// TRACES: UR-074 | DR-162 | UT-156
    #[tokio::test]
    async fn test_video_stream_url_uncapped_keeps_legacy_allowance() {
        let _fixture = QualityFixture::set(StreamingQuality::Original);
        let repo = create_test_repository();

        let url = repo
            .get_video_stream_url("vid-1", None, None)
            .await
            .unwrap();

        assert!(url.contains("MaxStreamingBitrate=20000000"), "url: {url}");
        assert!(url.contains("VideoBitrate=18000000"), "url: {url}");
        assert!(url.contains("AudioBitrate=384000"), "url: {url}");
        assert!(
            !url.contains("MaxHeight"),
            "uncapped must not scale the picture down: {url}"
        );
    }

    /// The background-audio handoff is already cheap, but someone who capped the
    /// connection at 720 kbps asked for less traffic than its fixed 384 kbps.
    ///
    /// TRACES: UR-040, UR-074 | DR-162 | UT-156
    #[tokio::test]
    async fn test_audio_only_stream_url_takes_the_lower_of_cap_and_default() {
        {
            let _fixture = QualityFixture::set(StreamingQuality::Kbps720);
            let repo = create_test_repository();
            let url = repo
                .get_audio_only_stream_url_for_video("vid-1", None, None, None)
                .await
                .unwrap();
            assert!(url.contains("MaxStreamingBitrate=96000"), "url: {url}");
        }

        let _fixture = QualityFixture::set(StreamingQuality::Original);
        let repo = create_test_repository();
        let url = repo
            .get_audio_only_stream_url_for_video("vid-1", None, None, None)
            .await
            .unwrap();
        assert!(url.contains("MaxStreamingBitrate=384000"), "url: {url}");
    }

    /// Transcoded video must be an HLS master playlist, not a progressive
    /// `stream.mp4`: a progressive transcode of an HEVC source makes the server
    /// convert the whole file before serving a byte, which presents as playback
    /// that never starts. The chosen source and audio track ride along with it.
    ///
    /// This is the surviving half of the old
    /// `test_get_video_stream_url_returns_hls_with_position`, whose other half
    /// asserted the `StartTimeTicks` that DR-181 removed — the position now
    /// belongs to a seek after load, never to this URL, so the assertion for it
    /// is gone rather than inverted (its inverse is UT-182's own test).
    ///
    /// TRACES: UR-004 | DR-140, DR-181 | UT-130
    #[tokio::test]
    async fn test_get_video_stream_url_returns_an_hls_master_playlist() {
        let _fixture = QualityFixture::set(StreamingQuality::Original);
        let repo = create_test_repository();

        let url = repo
            .get_video_stream_url("vid-1", Some("source-1"), Some(1))
            .await
            .unwrap();

        assert!(
            url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
            "expected HLS master playlist, got: {url}"
        );
        assert!(url.contains("VideoCodec=h264"));
        assert!(url.contains("MediaSourceId=source-1"));
        assert!(url.contains("AudioStreamIndex=1"));
        assert!(!url.contains("stream.mp4"));
    }

    /// Resuming a transcoded video played nothing at all: every segment came back
    /// `400`, hls.js exhausted its retries and gave up. Starting the same episode
    /// from the beginning was fine.
    ///
    /// Jellyfin builds each segment URI by echoing the *master playlist's* query
    /// string into it (`CreateMainPlaylistRequest(… Request.QueryString …)`), and
    /// its segment handler opens with
    ///
    /// ```csharp
    /// if ((streamingRequest.StartTimeTicks ?? 0) > 0)
    ///     throw new ArgumentException("StartTimeTicks is not allowed.");
    /// ```
    ///
    /// so a resume position put on the playlist is copied onto every
    /// `hls1/main/N.ts` and makes all of them 400. `> 0` is exactly why playing
    /// from the beginning survived.
    ///
    /// HLS does not need the parameter: the playlist spans the whole item, and
    /// asking for segment N *is* the seek — the server transcodes from there. So
    /// the position never belongs in this URL; the player seeks after load. The
    /// sibling progressive `/Audio/universal` builder is a different endpoint with
    /// no segments, and keeps its `StartTimeTicks`.
    ///
    /// TRACES: UR-004, UR-074 | DR-181 | UT-182
    #[tokio::test]
    async fn test_video_stream_url_never_carries_start_time_ticks() {
        let _fixture = QualityFixture::set(StreamingQuality::Original);
        let repo = create_test_repository();

        let url = repo
            .get_video_stream_url("vid-1", Some("source-1"), Some(1))
            .await
            .unwrap();

        assert!(
            !url.contains("StartTimeTicks"),
            "an HLS playlist must never carry StartTimeTicks — the server copies it \
             onto every segment URI and then rejects each one with 400: {url}"
        );
    }

    #[tokio::test]
    async fn test_get_video_stream_url_omits_position_when_absent() {
        let _fixture = QualityFixture::set(StreamingQuality::Original);
        let repo = create_test_repository();

        let url = repo
            .get_video_stream_url("vid-1", None, None)
            .await
            .unwrap();

        assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
        assert!(!url.contains("StartTimeTicks"));
        assert!(!url.contains("MediaSourceId"));
        // With no track chosen, the param must be OMITTED so the server picks the
        // source's DefaultAudioStreamIndex. `MediaStream.Index` is global across
        // all streams of a source, so index 0 is the *video* stream on virtually
        // every file — sending it asks for a "audio track" that has no audio.
        assert!(
            !url.contains("AudioStreamIndex"),
            "must not pin an audio index when none was chosen: {url}"
        );
    }

    /// Jellyfin keys a transcode job by device *and* play session. Every stream
    /// this app opened used the same `DeviceId` and no `PlaySessionId`, so
    /// re-opening the same item — what a mid-playback quality switch, a
    /// transcoded seek and an audio-track switch all do — handed the server a
    /// second job it could not tell apart from the one still running. Observed
    /// on-device: the new playlist is served, then `hls1/main/0.ts` 400s
    /// intermittently while the two jobs fight over the same transcode path, and
    /// playback stalls.
    ///
    /// TRACES: UR-074 | DR-177 | UT-173
    #[tokio::test]
    async fn test_video_stream_url_carries_a_play_session_id() {
        let _fixture = QualityFixture::set(StreamingQuality::Original);
        let repo = create_test_repository();

        let url = repo
            .get_video_stream_url("vid-1", None, None)
            .await
            .unwrap();

        assert!(
            url.contains("PlaySessionId="),
            "every transcode must be openable as its own job: {url}"
        );
    }

    /// Naming no subtitle stream is not the same as asking for none. The server
    /// fills the gap with the source's own default/forced track, and an
    /// image-based one (PGS/DVD/DVB) can only be delivered by painting it into
    /// the picture — the burn-in of DR-176, arriving through the URL rather than
    /// through the negotiation.
    ///
    /// The negotiation already sends the sentinel, but it is not what opens most
    /// streams: a quality switch, a transcoded seek and an audio-track switch all
    /// build this URL again, on their own. Saying it here too makes "no subtitle"
    /// a property of the request instead of something inherited from whatever
    /// session state the server happens to still hold.
    ///
    /// TRACES: UR-020, UR-004 | DR-176 | UT-168
    #[tokio::test]
    async fn test_video_stream_url_asks_for_no_subtitle_stream() {
        let _fixture = QualityFixture::set(StreamingQuality::Original);
        let repo = create_test_repository();

        let url = repo
            .get_video_stream_url("vid-1", Some("source-1"), Some(1))
            .await
            .unwrap();

        assert!(
            url.contains("SubtitleStreamIndex=-1"),
            "the stream URL must ask for no subtitle, not leave the choice open: {url}"
        );
    }

    /// The picker must not offer a subtitle the app cannot draw. Image-based
    /// tracks are bitmaps: the only way to show one is to have the server
    /// composite it, which is exactly what DR-176 stopped asking for. Selecting
    /// one was therefore a control that could not do anything — so the verdict
    /// travels with the stream, decided here where the codec vocabulary lives.
    ///
    /// TRACES: UR-020 | DR-176 | UT-168
    #[test]
    fn test_media_streams_carry_whether_the_app_can_render_them() {
        let item: JellyfinItem = serde_json::from_value(serde_json::json!({
            "Id": "ep-1",
            "Name": "Partings",
            "Type": "Episode",
            "MediaStreams": [
                { "Type": "Video", "Index": 0, "Codec": "hevc", "IsDefault": true },
                { "Type": "Audio", "Index": 1, "Codec": "eac3", "IsDefault": true },
                { "Type": "Subtitle", "Index": 2, "Codec": "PGSSUB", "IsDefault": true },
                { "Type": "Subtitle", "Index": 3, "Codec": "subrip", "IsDefault": false },
                { "Type": "Subtitle", "Index": 4, "Codec": null, "IsDefault": false },
            ],
        }))
        .expect("fixture must deserialize");

        let streams = item.into_media_item("server-1".to_string()).media_streams;
        let streams = streams.expect("the item carries streams");
        let deliverable = |index: i32| {
            streams
                .iter()
                .find(|s| s.index == index)
                .unwrap_or_else(|| panic!("stream {index} missing"))
                .supports_external_delivery
        };

        // The bitmap track the server would have had to burn in.
        assert_eq!(deliverable(2), Some(false));
        // Text: fetched as WebVTT and drawn by the app itself.
        assert_eq!(deliverable(3), Some(true));
        // A subtitle whose format the server did not name could be anything;
        // offering it risks a dead control, so it is not offered.
        assert_eq!(deliverable(4), Some(false));
        // Meaningless for anything that is not a subtitle — and said as `None`
        // rather than as a `false` a reader could mistake for a verdict.
        assert_eq!(deliverable(0), None);
        assert_eq!(deliverable(1), None);
    }

    /// The session id is what makes two opens *distinguishable*, so a fresh one
    /// per open is the whole point — and the open must report the id it replaced
    /// so the caller can stop that job instead of leaving it running.
    ///
    /// TRACES: UR-074 | DR-177 | UT-173
    #[test]
    fn test_each_stream_open_gets_a_fresh_session_and_reports_the_previous() {
        let _lock = QUALITY_LOCK.lock_safe();

        let (first, _) = begin_video_play_session();
        let (second, replaced) = begin_video_play_session();

        assert_ne!(first, second, "each open needs its own job identity");
        assert_eq!(
            replaced,
            Some(first),
            "the open must hand back the job it superseded so it can be stopped"
        );

        // A server-started transcode (PlaybackInfo answered with a TranscodingUrl)
        // has to become the current session too — otherwise the first switch on
        // that stream stops nothing and collides with what is playing.
        let replaced_by_adoption = adopt_video_play_session("server-named-session".to_string());
        assert_eq!(replaced_by_adoption, Some(second));

        let (_, after_adoption) = begin_video_play_session();
        assert_eq!(
            after_adoption,
            Some("server-named-session".to_string()),
            "the adopted job must be the one the next open stops"
        );
    }

    #[tokio::test]
    async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
        // TRACES: UR-040 | JA-032 | UT-059
        // Background-audio handoff must request an audio-only stream (no video
        // decode) that resumes at the current position and keeps the selected
        // audio track.
        let repo = create_test_repository();

        let url = repo
            .get_audio_only_stream_url_for_video("vid-1", Some("source-1"), Some(193.0), Some(2))
            .await
            .unwrap();

        assert!(
            url.starts_with("https://test.server.com/Audio/vid-1/universal?"),
            "expected audio-only universal endpoint, got: {url}"
        );
        // Must NOT be a video stream (no client video decode in background).
        assert!(
            !url.contains("/Videos/"),
            "url must not hit the video endpoint: {url}"
        );
        assert!(
            !url.contains("master.m3u8"),
            "url must not be a video HLS playlist: {url}"
        );
        assert!(url.contains("AudioStreamIndex=2"));
        assert!(url.contains("MediaSourceId=source-1"));
        // 193.0 seconds * 10_000_000 ticks/sec
        assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
        // Progressive mp3 over HTTP — NOT HLS/ts, or ExoPlayer's progressive
        // loader fails with ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED.
        assert!(url.contains("TranscodingProtocol=http"), "url: {url}");
        assert!(url.contains("TranscodingContainer=mp3"), "url: {url}");
        assert!(
            !url.contains("TranscodingProtocol=hls"),
            "url must not be HLS: {url}"
        );
        assert!(!url.contains("Container=ts"), "url must not be ts: {url}");
    }

    #[tokio::test]
    async fn test_get_audio_only_stream_url_for_video_omits_position_when_absent() {
        // TRACES: UR-040 | JA-032 | UT-059
        let repo = create_test_repository();

        let url = repo
            .get_audio_only_stream_url_for_video("vid-1", None, None, None)
            .await
            .unwrap();

        assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
        assert!(!url.contains("StartTimeTicks"));
        assert!(!url.contains("MediaSourceId"));
        // Same as the video path: omit rather than pin index 0 (the video stream),
        // and let the server fall back to the source's default audio stream.
        assert!(
            !url.contains("AudioStreamIndex"),
            "must not pin an audio index when none was chosen: {url}"
        );
    }

    #[tokio::test]
    async fn test_get_audio_stream_url_with_special_characters() {
        let repo = create_test_repository();
        let item_id = "track-with-special-chars-!@#";

        let result = repo.get_audio_stream_url(item_id).await;

        assert!(result.is_ok());
        let url = result.unwrap();
        assert!(url.contains("track-with-special-chars-!@#"));
        assert!(url.starts_with("https://test.server.com/Audio/"));
    }

    #[test]
    fn test_image_tags_deserialize_hashmap_format() {
        // Test modern HashMap format with Primary tag
        let json = r#"{"Primary":"abc123","Banner":"def456","Backdrop":"ghi789"}"#;
        let result: Result<ImageTags, _> = serde_json::from_str(json);

        assert!(result.is_ok());
        let tags = result.unwrap();
        assert_eq!(tags.primary(), Some("abc123".to_string()));
    }

    #[test]
    fn test_image_tags_deserialize_structured_format() {
        // Test legacy structured format with Primary field
        let json = r#"{"Primary":"xyz789"}"#;
        let result: Result<ImageTags, _> = serde_json::from_str(json);

        assert!(result.is_ok());
        let tags = result.unwrap();
        assert_eq!(tags.primary(), Some("xyz789".to_string()));
    }

    #[test]
    fn test_image_tags_deserialize_missing_primary() {
        // Test HashMap without Primary tag
        let json = r#"{"Banner":"def456","Backdrop":"ghi789"}"#;
        let result: Result<ImageTags, _> = serde_json::from_str(json);

        assert!(result.is_ok());
        let tags = result.unwrap();
        assert_eq!(tags.primary(), None);
    }

    #[test]
    fn test_image_tags_deserialize_empty_map() {
        // Test empty HashMap
        let json = r#"{}"#;
        let result: Result<ImageTags, _> = serde_json::from_str(json);

        assert!(result.is_ok());
        let tags = result.unwrap();
        assert_eq!(tags.primary(), None);
    }

    // ===== Video download URL (real impl) =====
    //
    // These exercise the PRODUCTION `OnlineRepository::get_video_download_url`,
    // not a mock. A prior mock in online_integration_test.rs used the correct
    // `stream.mp4` endpoint while the real impl shipped `/Videos/{id}/download`,
    // which returns 404 on real servers and silently broke every movie/TV
    // download. Assert the real builder targets the resumable stream endpoint.
    //
    // @req-test: DR-013 - Repository pattern for online/offline data access

    #[test]
    fn test_video_download_url_uses_stream_not_download_endpoint() {
        let repo = create_test_repository();
        let url = repo.get_video_download_url("item123", "original", None, None);

        // Must NOT use the /download endpoint (404 on real servers).
        assert!(
            !url.contains("/download"),
            "download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
        );
        // Must use the progressive, range-resumable stream endpoint.
        assert!(
            url.contains("/Videos/item123/stream.mp4"),
            "download URL must target /Videos/{{id}}/stream.mp4: {url}"
        );
        assert!(url.contains("api_key=test-access-token"), "url: {url}");
    }

    #[test]
    fn test_video_download_url_original_is_static_direct_copy() {
        let repo = create_test_repository();
        let url = repo.get_video_download_url("item123", "original", None, None);

        // "original" must request a direct static copy (byte-range resumable),
        // with no transcode params.
        assert!(url.contains("Static=true"), "url: {url}");
        assert!(
            !url.contains("videoBitRate"),
            "original must not transcode: {url}"
        );
        assert!(
            !url.contains("maxHeight"),
            "original must not transcode: {url}"
        );
    }

    #[test]
    fn test_video_download_url_quality_presets_transcode() {
        let repo = create_test_repository();

        for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
            let url = repo.get_video_download_url("item123", quality, None, None);
            assert!(
                url.contains("/Videos/item123/stream.mp4"),
                "{quality} must use stream.mp4: {url}"
            );
            assert!(
                url.contains("videoBitRate="),
                "{quality} must set bitrate: {url}"
            );
            assert!(
                url.contains(&format!("maxHeight={height}")),
                "{quality} must cap height at {height}: {url}"
            );
            assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
            // Transcoded presets must not also ask for a static copy.
            assert!(
                !url.contains("Static=true"),
                "{quality} must not be Static: {url}"
            );
        }
    }

    /// The bitrate params are spelled `videoBitRate`/`audioBitRate` — **capital
    /// R**. Jellyfin binds query keys case-insensitively, so this is not a
    /// casing preference: `videoBitrate` is a *different token* that fails to
    /// bind and is silently discarded, taking the user's quality cap with it.
    /// Nothing errors — the download just returns the full-size original, which
    /// is exactly how this bug went unnoticed.
    #[test]
    fn test_video_download_url_bitrate_params_use_capital_r_spelling() {
        let repo = create_test_repository();

        for quality in ["high", "medium", "low"] {
            let url = repo.get_video_download_url("item123", quality, None, None);

            assert!(
                url.contains("videoBitRate="),
                "{quality} must spell it videoBitRate (capital R): {url}"
            );
            assert!(
                url.contains("audioBitRate="),
                "{quality} must spell it audioBitRate (capital R): {url}"
            );

            // The lowercase-r spellings never bind — they must not appear at
            // all, or the cap is silently dropped by the server.
            assert!(
                !url.contains("videoBitrate="),
                "{quality} emits the unbindable lowercase-r spelling: {url}"
            );
            assert!(
                !url.contains("audioBitrate="),
                "{quality} emits the unbindable lowercase-r spelling: {url}"
            );
        }
    }

    /// A correctly-spelled cap is still only *conditionally* honored: the server
    /// may stream-copy the source when it already satisfies the cap. Video copy
    /// is gated by `allowVideoStreamCopy` (NOT `enableAutoStreamCopy`, which
    /// only governs audio), so the transcode presets must disable it to
    /// guarantee a real re-encode at the requested bitrate.
    #[test]
    fn test_video_download_url_transcode_presets_forbid_video_stream_copy() {
        let repo = create_test_repository();

        for quality in ["high", "medium", "low"] {
            let url = repo.get_video_download_url("item123", quality, None, None);
            assert!(
                url.contains("allowVideoStreamCopy=false"),
                "{quality} must forbid video stream copy: {url}"
            );
        }

        // "original" is a deliberate direct copy — it must NOT disable copying.
        let original = repo.get_video_download_url("item123", "original", None, None);
        assert!(
            !original.contains("allowVideoStreamCopy=false"),
            "original must remain a direct copy: {original}"
        );
    }

    /// A downloaded file is played with no server in reach, so `original`
    /// quality cannot mean "copy whatever the source holds" when the source
    /// holds audio this device cannot decode.
    ///
    /// `Static=true` hands back the source bytes untouched, E-AC-3/AC-3/DTS
    /// track included, and video plays through the webview `<video>` element on
    /// both platforms — which decodes none of them. Streaming already knows this
    /// (DR-149 forces a transcode over the server's own direct-play offer); the
    /// download path did not, so a downloaded film played offline as picture with
    /// no sound while the very same film had sound when streamed.
    ///
    /// TRACES: UR-071, UR-004 | DR-171 | UT-166
    #[test]
    fn test_video_download_url_original_transcodes_undecodable_audio() {
        let repo = create_test_repository();

        for codec in ["eac3", "ac3", "dts", "truehd", "EAC3"] {
            let url = repo.get_video_download_url("item123", "original", None, Some(codec));
            assert!(
                !url.contains("Static=true"),
                "{codec} cannot be decoded here, so the source must not be copied verbatim: {url}"
            );
            assert!(
                url.contains("audioCodec=aac"),
                "{codec} must be re-encoded to aac on the way down: {url}"
            );
            // "Original" still has to mean original picture: the video stream is
            // copied when it can be, so no bitrate or resolution cap appears.
            assert!(
                url.contains("allowVideoStreamCopy=true"),
                "the video stream must still be copied where possible: {url}"
            );
            assert!(
                !url.contains("videoBitRate") && !url.contains("maxHeight"),
                "original must not degrade the picture to fix the audio: {url}"
            );
        }
    }

    /// The converse, and the reason the policy is per-item rather than blanket:
    /// audio that plays here keeps the byte-exact, range-resumable copy that the
    /// download worker's resume depends on.
    ///
    /// TRACES: UR-071 | DR-171 | UT-166
    #[test]
    fn test_video_download_url_original_keeps_static_copy_for_playable_audio() {
        let repo = create_test_repository();

        for codec in ["aac", "mp3", "opus", "vorbis", "flac", "AAC"] {
            let url = repo.get_video_download_url("item123", "original", None, Some(codec));
            assert!(
                url.contains("Static=true"),
                "{codec} plays here — the download must stay a direct copy: {url}"
            );
            assert!(
                !url.contains("audioCodec="),
                "{codec} needs no transcode: {url}"
            );
        }

        // Unknown codec: the policy only ever *adds* a transcode, so an item we
        // could not look up behaves exactly as it did before.
        let unknown = repo.get_video_download_url("item123", "original", None, None);
        assert!(unknown.contains("Static=true"), "url: {unknown}");
    }

    /// The explicit quality presets already transcode audio to AAC, so the
    /// policy has nothing to add — and must not start overriding a chosen cap.
    ///
    /// TRACES: UR-071 | DR-171 | UT-166
    #[test]
    fn test_video_download_url_presets_ignore_the_audio_policy() {
        let repo = create_test_repository();

        for quality in ["high", "medium", "low"] {
            let with = repo.get_video_download_url("item123", quality, None, Some("eac3"));
            let without = repo.get_video_download_url("item123", quality, None, None);
            assert_eq!(with, without, "{quality} must not vary with source audio");
            assert!(with.contains("audioCodec=aac"), "url: {with}");
        }
    }

    #[test]
    fn test_video_download_url_passes_media_source_id() {
        let repo = create_test_repository();
        let url = repo.get_video_download_url("item123", "original", Some("src-42"), None);
        assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
    }

    #[test]
    fn test_jellyfin_item_deserialize_with_image_tags() {
        // Test full JellyfinItem deserialization with ImageTags
        let json = r#"{
            "Id": "album123",
            "Name": "Test Album",
            "Type": "MusicAlbum",
            "ImageTags": {"Primary": "tag123"},
            "ArtistItems": [
                {"Id": "artist1", "Name": "Artist One"},
                {"Id": "artist2", "Name": "Artist Two"}
            ]
        }"#;

        let result: Result<JellyfinItem, _> = serde_json::from_str(json);
        assert!(result.is_ok());

        let item = result.unwrap();
        assert_eq!(item.id, "album123");
        assert_eq!(item.name, "Test Album");
        assert_eq!(item.item_type, "MusicAlbum");
        assert!(item.image_tags.is_some());
        assert_eq!(
            item.image_tags.unwrap().primary(),
            Some("tag123".to_string())
        );
    }

    /// UT-100 — the favourites endpoint asks the server for favourites, scoped.
    ///
    /// TRACES: UR-067 | DR-115, JA-033 | UT-100
    #[test]
    fn test_build_favorites_endpoint_scopes_and_filters() {
        let movies = build_favorites_endpoint("u1", SearchScope::Movies, None);
        assert!(movies.starts_with("/Users/u1/Items?Filters=IsFavorite&Recursive=true"));
        assert!(movies.contains("&IncludeItemTypes=Movie"));
        // Jellyfin has no favourite timestamp, so name order is the default.
        assert!(movies.contains("&SortBy=SortName&SortOrder=Ascending"));
        // Hearts must render on the returned cards.
        assert!(movies.contains("UserData"));

        // Tv covers both the show and any individually favourited episode.
        let tv = build_favorites_endpoint("u1", SearchScope::Tv, None);
        assert!(tv.contains("&IncludeItemTypes=Series,Episode"));

        let music = build_favorites_endpoint("u1", SearchScope::Music, None);
        assert!(music.contains("&IncludeItemTypes=MusicAlbum,MusicArtist,Audio,Playlist"));
    }

    /// `All` must omit the type filter entirely rather than send a union, which
    /// would silently drop every type nobody enumerated.
    ///
    /// TRACES: UR-067 | DR-115 | UT-100
    #[test]
    fn test_build_favorites_endpoint_all_scope_omits_type_filter() {
        let all = build_favorites_endpoint("u1", SearchScope::All, None);
        assert!(!all.contains("IncludeItemTypes"));
    }

    /// Paging and an explicit sort still reach the server.
    ///
    /// TRACES: UR-067 | DR-115 | UT-100
    #[test]
    fn test_build_favorites_endpoint_honours_paging_and_sort() {
        let endpoint = build_favorites_endpoint(
            "u1",
            SearchScope::All,
            Some(&GetItemsOptions {
                limit: Some(20),
                start_index: Some(40),
                sort_by: Some("Random".to_string()),
                sort_order: Some("Descending".to_string()),
                ..Default::default()
            }),
        );
        assert!(endpoint.contains("&Limit=20"));
        assert!(endpoint.contains("&StartIndex=40"));
        assert!(endpoint.contains("&SortBy=Random&SortOrder=Descending"));
    }

    /// UT-104 — the in-library favourites toggle reaches the server as
    /// `Filters=IsFavorite`, and is absent unless asked for.
    ///
    /// TRACES: UR-067 | DR-116 | UT-104
    #[test]
    fn test_get_items_endpoint_applies_favorites_only() {
        let plain = build_get_items_endpoint("u1", "lib-1", None);
        assert!(!plain.contains("Filters=IsFavorite"));

        let filtered = build_get_items_endpoint(
            "u1",
            "lib-1",
            Some(&GetItemsOptions {
                favorites_only: Some(true),
                include_item_types: Some(vec!["Movie".to_string()]),
                ..Default::default()
            }),
        );
        assert!(filtered.contains("&Filters=IsFavorite"));
        // Composes with the filters already there rather than replacing them.
        assert!(filtered.contains("&IncludeItemTypes=Movie"));
        assert!(filtered.contains("ParentId=lib-1"));

        // Explicitly false is not a request to filter.
        let off = build_get_items_endpoint(
            "u1",
            "lib-1",
            Some(&GetItemsOptions {
                favorites_only: Some(false),
                ..Default::default()
            }),
        );
        assert!(!off.contains("Filters=IsFavorite"));
    }

    /// A newly-added album must arrive as one entry, not one per track.
    ///
    /// Jellyfin's `/Items/Latest` defaults to `GroupItems=false`, which returns
    /// every new Audio track individually — so ripping a 14-track album filled
    /// the whole "recently added" row with that one album. `GroupItems=true`
    /// makes the server collapse children into their parent container.
    #[test]
    fn test_latest_items_endpoint_groups_children_into_containers() {
        let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));

        assert!(
            endpoint.contains("GroupItems=true"),
            "latest items must be grouped so an album counts once, got: {}",
            endpoint
        );
        assert!(endpoint.contains("ParentId=lib-1"));
        assert!(endpoint.contains("Limit=16"));
    }

    /// UT-190 — Next Up asks the server to leave resumable episodes out.
    ///
    /// Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns
    /// the *in-progress* episode as a series' next up — exactly the episode
    /// `/Items/Resume` already returns, so Continue Watching and Next Up render
    /// the same cards.
    ///
    /// TRACES: UR-059 | DR-197, JA-036 | UT-190
    #[test]
    fn test_build_next_up_endpoint_excludes_resumable() {
        let endpoint = build_next_up_endpoint("u1", None, Some(12));

        assert!(
            endpoint.contains("EnableResumable=false"),
            "next up must exclude in-progress episodes, got: {}",
            endpoint
        );
        assert!(endpoint.contains("UserId=u1"));
        assert!(endpoint.contains("Limit=12"));
        assert!(
            !endpoint.contains("SeriesId"),
            "no series filter when none was requested, got: {}",
            endpoint
        );
    }

    /// UT-191 — a per-series Next Up query keeps the series filter.
    ///
    /// TRACES: UR-059 | DR-197 | UT-191
    #[test]
    fn test_build_next_up_endpoint_scopes_to_series() {
        let endpoint = build_next_up_endpoint("u1", Some("series-a"), None);

        assert!(endpoint.contains("SeriesId=series-a"));
        assert!(endpoint.contains("EnableResumable=false"));
        assert!(
            endpoint.contains("Limit=16"),
            "default limit, got: {}",
            endpoint
        );
    }

    /// UT-099 — a Jellyfin item's `UserData` reaches `MediaItem.user_data`.
    ///
    /// Before DR-113 this mapping was hardcoded to `None`, so nothing outside
    /// the mini player could know an item was favourited.
    ///
    /// TRACES: UR-069 | DR-113, JA-034 | UT-099
    #[test]
    fn test_jellyfin_item_maps_user_data_favorite() {
        let json = r#"{
            "Id": "movie123",
            "Name": "Test Movie",
            "Type": "Movie",
            "UserData": {
                "PlaybackPositionTicks": 6000000000,
                "Played": false,
                "IsFavorite": true,
                "PlayCount": 2,
                "LastPlayedDate": "2026-08-01T12:00:00Z"
            }
        }"#;

        let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
        let media = item.into_media_item("server1".to_string());

        let user_data = media.user_data.expect("user data should be mapped");
        assert_eq!(user_data.is_favorite, Some(true));
        assert_eq!(user_data.is_played, Some(false));
        assert_eq!(user_data.play_count, Some(2));
        assert_eq!(user_data.playback_position_ticks, Some(6_000_000_000));
        // Ticks are converted for the frontend, which never divides them itself.
        assert_eq!(user_data.playback_position_ms, Some(600_000));
    }

    /// An item without `UserData` still maps — the field is optional, and every
    /// non-user-scoped endpoint omits it.
    ///
    /// TRACES: UR-069 | DR-113 | UT-099
    #[test]
    fn test_jellyfin_item_without_user_data_maps_to_none() {
        let json = r#"{"Id": "x", "Name": "No User Data", "Type": "Movie"}"#;

        let item: JellyfinItem = serde_json::from_str(json).expect("item should deserialize");
        let media = item.into_media_item("server1".to_string());

        assert!(media.user_data.is_none());
    }

    #[test]
    fn test_jellyfin_item_deserialize_with_artist_items() {
        // Test that ArtistItems with PascalCase fields deserialize correctly
        let json = r#"{
            "Id": "track123",
            "Name": "Test Track",
            "Type": "Audio",
            "ArtistItems": [
                {"Id": "artist1", "Name": "Bob Dylan"},
                {"Id": "artist2", "Name": "Johnny Cash"}
            ]
        }"#;

        let result: Result<JellyfinItem, _> = serde_json::from_str(json);
        assert!(result.is_ok());

        let item = result.unwrap();
        let artist_items = item.artist_items.expect("Expected artist items");
        assert_eq!(artist_items.len(), 2);
        assert_eq!(artist_items[0].id, "artist1");
        assert_eq!(artist_items[0].name, "Bob Dylan");
        assert_eq!(artist_items[1].id, "artist2");
        assert_eq!(artist_items[1].name, "Johnny Cash");
    }

    #[test]
    fn test_jellyfin_item_to_media_item_conversion() {
        // Test conversion from JellyfinItem to MediaItem preserves image tags
        let json = r#"{
            "Id": "album456",
            "Name": "Love and Theft",
            "Type": "MusicAlbum",
            "ImageTags": {"Primary": "7ebab4f6a80cd09d"},
            "Artists": ["Bob Dylan"],
            "ArtistItems": [{"Id": "0b2a6e969a27f22aba97f9f0e69fa849", "Name": "Bob Dylan"}],
            "RunTimeTicks": 33900137190
        }"#;

        let jellyfin_item: JellyfinItem = serde_json::from_str(json).expect("Failed to parse");
        let media_item = jellyfin_item.into_media_item("test-server-id".to_string());

        assert_eq!(media_item.id, "album456");
        assert_eq!(media_item.name, "Love and Theft");
        assert_eq!(media_item.item_type, "MusicAlbum");
        assert_eq!(
            media_item.primary_image_tag,
            Some("7ebab4f6a80cd09d".to_string())
        );
        assert_eq!(media_item.server_id, "test-server-id");
    }

    #[test]
    fn test_items_response_deserialize() {
        // Test full ItemsResponse with multiple items
        let json = r#"{
            "Items": [
                {
                    "Id": "item1",
                    "Name": "Item One",
                    "Type": "MusicAlbum",
                    "ImageTags": {"Primary": "tag1"}
                },
                {
                    "Id": "item2",
                    "Name": "Item Two",
                    "Type": "Audio",
                    "ImageTags": {"Primary": "tag2"}
                }
            ],
            "TotalRecordCount": 2
        }"#;

        let result: Result<ItemsResponse, _> = serde_json::from_str(json);
        assert!(result.is_ok());

        let response = result.unwrap();
        assert_eq!(response.total_record_count, 2);
        assert_eq!(response.items.len(), 2);
        assert_eq!(response.items[0].id, "item1");
        assert_eq!(response.items[1].id, "item2");
    }

    #[test]
    fn test_search_term_is_url_encoded() {
        // A multi-word query (and one with a reserved character) must be
        // percent-encoded before being placed in the SearchTerm query param,
        // otherwise the request URL is malformed and search returns nothing.
        assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
        assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
    }

    #[test]
    fn test_jray_context_deserializes_actors() {
        // The jray?t= envelope as documented in the JRay truth file format.
        let json = r#"{
            "actors": [
                { "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
            ]
        }"#;
        let ctx: JRayContext = serde_json::from_str(json).expect("should parse");
        assert_eq!(ctx.actors.len(), 1);
        assert_eq!(ctx.actors[0].name, "Tom Hanks");
        assert_eq!(ctx.actors[0].jellyfin_id, "abc123-guid");
    }

    #[test]
    fn test_jray_context_ignores_unknown_keys_and_missing_ids() {
        // Future fields (locations/trivia) must be ignored, and absent id keys
        // must default to "" rather than failing to parse.
        let json = r#"{
            "actors": [ { "name": "Extra" } ],
            "locations": ["Beach"],
            "trivia": "filmed in 1994"
        }"#;
        let ctx: JRayContext = serde_json::from_str(json).expect("should tolerate extra keys");
        assert_eq!(ctx.actors.len(), 1);
        assert_eq!(ctx.actors[0].name, "Extra");
        assert_eq!(ctx.actors[0].imdb_id, "");
        assert_eq!(ctx.actors[0].jellyfin_id, "");
    }
}
