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

use async_trait::async_trait;
#[cfg(target_os = "android")]
use log::warn;
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use std::sync::Arc;

use super::{types::*, MediaRepository};
use crate::connectivity::ConnectivityReporter;
use crate::jellyfin::HttpClient;

/// 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 {
    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),
        })
    }

    /// Get a video stream URL for playback at an arbitrary position (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. `StartTimeTicks` makes the
    /// server begin the transcode at the requested position.
    pub async fn get_video_stream_url(
        &self,
        item_id: &str,
        media_source_id: Option<&str>,
        start_time_seconds: Option<f64>,
        audio_stream_index: Option<i32>,
    ) -> Result<String, RepoError> {
        // Convert seconds to ticks (10,000,000 ticks per second)
        let start_time_ticks = start_time_seconds.map(|seconds| (seconds * 10_000_000.0) as i64);

        // Use provided audio stream index, or default to 0
        let audio_index = audio_stream_index.unwrap_or(0).to_string();

        // 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", "jellytau-tauri".to_string()),
            ("VideoCodec", "h264".to_string()),
            ("AudioCodec", "aac".to_string()),
            ("AudioStreamIndex", audio_index),
            ("MaxStreamingBitrate", "20000000".to_string()),
            ("VideoBitrate", "18000000".to_string()),
            ("AudioBitrate", "384000".to_string()),
            ("TranscodingMaxAudioChannels", "2".to_string()),
            ("SegmentContainer", "ts".to_string()),
            ("TranscodingContainer", "ts".to_string()),
            ("TranscodingProtocol", "hls".to_string()),
        ];

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

        if let Some(ticks) = start_time_ticks {
            params.push(("StartTimeTicks", ticks.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 | UT-059
    ///
    /// 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 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> {
        let audio_index = audio_stream_index.unwrap_or(0).to_string();

        let mut params = vec![
            ("UserId", self.user_id.clone()),
            ("api_key", self.access_token.clone()),
            ("DeviceId", "jellytau-tauri".to_string()),
            ("AudioStreamIndex", audio_index),
            // 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()),
            ("MaxStreamingBitrate", "384000".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>>,
}

// 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 to_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;

        MediaItem {
            id: self.id,
            name: self.name,
            item_type: self.item_type,
            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,
            primary_image_tag: 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,
            user_data: None, // User data not included in basic item responses
            media_streams: self.media_streams.map(|streams| {
                streams
                    .into_iter()
                    .map(|s| crate::repository::types::MediaStream {
                        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,
                    })
                    .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 {
                id: lib.id,
                name: lib.name,
                collection_type: lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
                image_tag: lib.image_tags.and_then(|tags| tags.primary()),
            })
            .collect())
    }

    async fn get_items(
        &self,
        parent_id: &str,
        options: Option<GetItemsOptions>,
    ) -> Result<SearchResult, RepoError> {
        let mut endpoint = format!("/Users/{}/Items?ParentId={}", self.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("|")));
                }
            }
        }

        // 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");

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

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

    async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
        let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate", self.user_id, item_id);

        let item: JellyfinItem = self.get_json(&endpoint).await?;
        let media_item = item.to_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 limit_str = limit.unwrap_or(16);
        let endpoint = format!(
            "/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
            self.user_id, parent_id, limit_str
        );

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

    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",
            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.to_media_item(self.user_id.clone()))
            .collect())
    }

    async fn get_next_up_episodes(
        &self,
        series_id: Option<&str>,
        limit: Option<usize>,
    ) -> Result<Vec<MediaItem>, RepoError> {
        let limit_str = limit.unwrap_or(16);
        let mut endpoint = format!(
            "/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
            self.user_id, limit_str
        );

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

        let response: ItemsResponse = self.get_json(&endpoint).await?;
        Ok(response
            .items
            .into_iter()
            .map(|item| item.to_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",
            self.user_id, fetch_limit
        );

        let response: ItemsResponse = self.get_json(&endpoint).await?;
        let items: Vec<MediaItem> = response
            .items
            .into_iter()
            .map(|item| item.to_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_insert_with(Vec::new).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(),
                    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,
                    primary_image_tag: 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",
            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.to_media_item(self.user_id.clone()))
            .collect())
    }

    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",
            self.user_id, limit_str
        );

        let response: ItemsResponse = self.get_json(&endpoint).await?;
        Ok(response
            .items
            .into_iter()
            .map(|item| item.to_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)
    }

    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");

        let response: ItemsResponse = self.get_json(&endpoint).await?;
        Ok(SearchResult {
            items: response
                .items
                .into_iter()
                .map(|item| item.to_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,
            audio_stream_index: 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,
            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,
        }

        #[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>,
        }

        // Get detected codecs from Android MediaCodecList or use platform defaults
        #[cfg(target_os = "android")]
        let (video_codecs, audio_codecs) =
            crate::player::get_detected_codecs().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, but the PlaybackInfo
        // profile is shared, so we keep the broadly-supported audio codecs.)
        #[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(),
        );

        info!("[DeviceProfile] Using video codecs: {}", video_codecs);
        info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);

        // Create device profile with detected hardware capabilities
        let device_profile = DeviceProfile {
            name: "JellyTau Native Player".to_string(),
            max_streaming_bitrate: 999_999_999,
            max_static_bitrate: 999_999_999,
            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()),
                    audio_codec: audio_codecs.clone(),
                },
                DirectPlayProfile {
                    profile_type: "Audio".to_string(),
                    container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
                    video_codec: None,
                    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(),
                },
                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(),
                },
            ],
            subtitle_profiles: vec![
                SubtitleProfile {
                    format: "srt".to_string(),
                    method: "External".to_string(),
                },
                SubtitleProfile {
                    format: "vtt".to_string(),
                    method: "External".to_string(),
                },
            ],
        };

        // POST to PlaybackInfo with device profile containing detected codecs
        let request_body = PlaybackInfoRequest {
            user_id: self.user_id.clone(),
            audio_stream_index: 0, // Request first audio stream
            subtitle_stream_index: None,
            start_time_ticks: 0,
            is_playback: true,
            auto_open_live_stream: true,
            max_streaming_bitrate: 20_000_000,    // 20 Mbps
            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
            );
        }

        // Use TranscodingUrl from response if available (Streamyfin pattern)
        let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
            format!("{}{}", self.server_url, transcoding_url)
        } else {
            // Fall back to direct stream URL
            format!(
                "{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&audioStreamIndex=0&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,
            needs_transcoding: !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_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.to_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.to_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,
        }

        #[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,
            max_streaming_bitrate: 20_000_000,
        };

        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 {
            Some(url) => format!("{}{}", self.server_url, url),
            None => format!(
                "{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts",
                self.server_url,
                item_id,
                self.access_token,
                source.id,
                source.live_stream_id.clone().unwrap_or_default(),
            ),
        };

        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
        )
    }

    fn get_video_download_url(
        &self,
        item_id: &str,
        quality: &str,
        media_source_id: 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.
        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());
            }
            "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());
            }
            "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());
            }
            // "original" (and any unknown value) → direct, resumable copy.
            _ => {
                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
    }

    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
    }

    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.to_media_item(self.user_id.clone()))
    }

    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",
            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.to_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",
            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.to_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.to_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 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"
        );
    }

    #[tokio::test]
    async fn test_get_video_stream_url_returns_hls_with_position() {
        // Transcoded video resume/seek must produce an HLS master playlist with
        // StartTimeTicks, not a progressive stream.mp4 (which never starts playing
        // for HEVC sources). See get_video_stream_url docs.
        let repo = create_test_repository();

        let url = repo
            .get_video_stream_url("vid-1", Some("source-1"), Some(193.0), 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"));
        // 193.0 seconds * 10_000_000 ticks/sec
        assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
        assert!(!url.contains("stream.mp4"));
    }

    #[tokio::test]
    async fn test_get_video_stream_url_omits_position_when_absent() {
        let repo = create_test_repository();

        let url = repo
            .get_video_stream_url("vid-1", None, 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"));
        // Defaults to first audio stream
        assert!(url.contains("AudioStreamIndex=0"));
    }

    #[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"));
        // Defaults to first audio stream.
        assert!(url.contains("AudioStreamIndex=0"));
    }

    #[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);

        // 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);

        // "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);
            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}"
            );
        }
    }

    #[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"));
        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())
        );
    }

    #[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.to_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, "");
    }
}
