use serde::{Deserialize, Serialize};

/// Error types for repository operations
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum RepoError {
    Network { message: String },
    Authentication { message: String },
    NotFound { message: String },
    Server { message: String },
    Database { message: String },
    Offline,
}

impl std::fmt::Display for RepoError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RepoError::Network { message } => write!(f, "Network error: {}", message),
            RepoError::Authentication { message } => write!(f, "Authentication error: {}", message),
            RepoError::NotFound { message } => write!(f, "Not found: {}", message),
            RepoError::Server { message } => write!(f, "Server error: {}", message),
            RepoError::Database { message } => write!(f, "Database error: {}", message),
            RepoError::Offline => write!(f, "Offline - no server connection"),
        }
    }
}

impl std::error::Error for RepoError {}

/// Library (media collection)
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Library {
    pub id: String,
    pub name: String,
    pub collection_type: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub image_tag: Option<String>,
    /// The favourites scope this library's contents fall under, or `None` for a
    /// library kind favourites does not carve up (Live TV, channels, books…).
    ///
    /// Derived here rather than in the UI: which collection type maps to which
    /// scope is Jellyfin vocabulary, and the frontend must not hold a
    /// collection-type → category table any more than an item-type one. See
    /// `SearchScope::for_collection_type`.
    ///
    /// TRACES: UR-075 | DR-175
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub favorites_scope: Option<SearchScope>,
}

impl Library {
    /// Build a library, deriving everything that follows from its collection
    /// type. Prefer this over the struct literal so a new derived field cannot
    /// be forgotten at one of the construction sites.
    pub fn new(
        id: String,
        name: String,
        collection_type: String,
        image_tag: Option<String>,
    ) -> Self {
        let favorites_scope = SearchScope::for_collection_type(&collection_type);
        Self {
            id,
            name,
            collection_type,
            image_tag,
            favorites_scope,
        }
    }
}

/// User-specific data for an item (playback state, favorites, etc.)
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct UserData {
    /// Legacy Jellyfin resume position in ticks. Being replaced by
    /// `playback_position_ms`; dual-carried while the frontend migrates
    /// (docs/specs/frontend-domain-model.md). New code should read the ms field.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub playback_position_ticks: Option<i64>,
    /// Resume position in milliseconds — the neutral replacement for
    /// `playback_position_ticks`. Populated from ticks by the mapping; the
    /// frontend never divides ticks itself.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub playback_position_ms: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_played: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_favorite: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub play_count: Option<i32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_played_date: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub playback_context_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub playback_context_id: Option<String>,
}

/// Artist item with ID and name (for clickable artist links)
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ArtistItem {
    #[serde(alias = "Id")]
    pub id: String,
    #[serde(alias = "Name")]
    pub name: String,
}

/// Person (cast/crew member) - for movies, series, and episodes
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Person {
    /// Deserializes from API's "Id" field (PascalCase), serializes as "id" (camelCase to frontend)
    #[serde(alias = "Id")]
    #[serde(default)]
    pub id: String,
    /// Deserializes from API's "Name" field (PascalCase), serializes as "name" (camelCase to frontend)
    #[serde(alias = "Name")]
    #[serde(default)]
    pub name: String,
    /// Person type from Jellyfin API (Actor, Director, Writer, etc.)
    /// Deserializes from API's "Type" field (PascalCase), serializes as "type" (camelCase to frontend)
    #[serde(rename = "type")]
    #[serde(alias = "Type")]
    #[serde(default)]
    pub person_type: String,
    /// Deserializes from API's "Role" field (PascalCase), serializes as "role" (camelCase to frontend)
    #[serde(alias = "Role")]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub role: Option<String>,
    /// Deserializes from API's "PrimaryImageTag" field (PascalCase), serializes as "primaryImageTag" (camelCase to frontend)
    #[serde(alias = "PrimaryImageTag")]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub primary_image_tag: Option<String>,
}

/// Media item
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct MediaItem {
    pub id: String,
    pub name: String,
    /// Legacy Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, …).
    ///
    /// Dual-carry migration (docs/specs/frontend-domain-model.md): `kind` below
    /// is the neutral replacement. This field stays while the frontend migrates
    /// off it, then is removed in a later phase. New Rust code should read
    /// `kind`, not this.
    #[serde(rename = "type")]
    pub item_type: String,
    /// Provider-neutral classification — the replacement for `item_type`.
    /// Populated by the Jellyfin mapping; defaults to `Other` for the handful of
    /// construction sites that have not been migrated yet.
    #[serde(default)]
    pub kind: crate::domain::MediaKind,
    /// Whether this item is a folder/container (vs a playable leaf). Used to
    /// decide whether a channel item drills into a list or plays directly.
    #[serde(default)]
    pub is_folder: bool,
    pub server_id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub library_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub overview: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub genres: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub production_year: Option<i32>,
    /// ISO-8601 release/air date (Jellyfin `PremiereDate`). Used to sort
    /// podcast episodes by release date.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub premiere_date: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub community_rating: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub official_rating: Option<String>,
    /// Legacy Jellyfin duration in ticks (100 ns units). Being replaced by
    /// `duration_ms`; dual-carried while the frontend migrates
    /// (docs/specs/frontend-domain-model.md). New code should read `duration_ms`.
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(rename = "runTimeTicks")]
    pub runtime_ticks: Option<i64>,
    /// Duration in milliseconds — the neutral replacement for `runtime_ticks`.
    /// Ticks never reach the frontend; this does.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub duration_ms: Option<i64>,
    /// Legacy Jellyfin primary image tag. Being replaced by `image_id`;
    /// dual-carried while the frontend migrates. New code should read `image_id`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub primary_image_tag: Option<String>,
    /// Neutral image identifier the frontend resolves to a URL via the image
    /// command — the replacement for `primary_image_tag`. Same value today
    /// (Jellyfin's tag is the id); the rename removes the provider term.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub image_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub backdrop_image_tags: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_backdrop_image_tags: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub album_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub album_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub album_artist: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub artists: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub artist_items: Option<Vec<ArtistItem>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub index_number: Option<i32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parent_index_number: Option<i32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub series_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub series_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub season_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub season_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user_data: Option<UserData>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub media_streams: Option<Vec<MediaStream>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub media_sources: Option<Vec<MediaSource>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub people: Option<Vec<Person>>,
}

/// Media stream information (audio, video, subtitle tracks)
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaStream {
    /// Legacy Jellyfin stream type string ("Audio"/"Video"/"Subtitle"). Being
    /// replaced by `kind`; dual-carried while the frontend migrates.
    #[serde(rename = "type")]
    pub stream_type: String,
    /// Provider-neutral stream classification — replaces `stream_type`.
    #[serde(default)]
    pub kind: crate::domain::StreamKind,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub codec: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub display_title: Option<String>,
    pub index: i32,
    pub is_default: bool,
    pub is_forced: bool,
    /// Whether this stream can reach the app as a sidecar it renders itself.
    ///
    /// `None` for anything that is not a subtitle — the question does not apply,
    /// and `false` there would read like a verdict. For a subtitle it is the
    /// difference between a track the app can draw and one only the server could
    /// have shown, by burning it into the picture (DR-176) — which this app never
    /// asks it to do. The vocabulary of *which formats those are* stays in Rust;
    /// the frontend only reads the answer.
    ///
    /// TRACES: UR-020 | DR-176 | UT-168
    #[serde(default)]
    pub supports_external_delivery: Option<bool>,
}

/// Media source information
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaSource {
    pub id: String,
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub container: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub size: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bitrate: Option<i32>,
    pub supports_direct_play: bool,
    pub supports_direct_stream: bool,
    pub supports_transcoding: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub direct_stream_url: Option<String>,
}

/// Search result with pagination
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchResult {
    pub items: Vec<MediaItem>,
    pub total_record_count: usize,
}

/// On-disk usage of downloaded content, for the Downloads surface.
///
/// `sizes` maps an item id (leaf *or* container) to its bytes on disk: a leaf's
/// own file size, a container's summed downloaded descendants. `device_total_bytes`
/// and `item_count` are the headline figures for the Downloaded surface top bar.
///
/// TRACES: UR-056 | DR-085
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadDiskUsage {
    /// item id → bytes on disk (leaf's own size, or a container's subtotal).
    pub sizes: std::collections::HashMap<String, i64>,
    /// Container id → true when it is only *partially* downloaded (has cached
    /// children that are not downloaded). Absent/false ⇒ fully downloaded. Lets
    /// the Downloaded surface badge partial vs. full containers.
    pub partial_containers: std::collections::HashMap<String, bool>,
    /// Sum of all downloaded leaf sizes — the device total.
    pub device_total_bytes: i64,
    /// Number of downloaded leaf items (not containers).
    pub item_count: u32,
}

/// Options for querying items
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct GetItemsOptions {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start_index: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub limit: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sort_by: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sort_order: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub include_item_types: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub recursive: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub fields: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub genres: Option<Vec<String>>,
    /// Restrict the listing to favourited items. Backs the per-library
    /// favourites toggle; composes with every other filter here.
    ///
    /// TRACES: UR-067 | DR-116 | UT-104
    #[serde(skip_serializing_if = "Option::is_none")]
    pub favorites_only: Option<bool>,
}

/// An opaque search scope the frontend selects; Rust owns what it *means*.
///
/// The expansion table below is Jellyfin domain vocabulary: it changes when
/// Jellyfin adds or renames an item type, never when the UI is redesigned. It
/// previously lived in the frontend (`searchScope.ts`), which is the boundary
/// leak documented in docs/specs/scoped-search-boundary.md. The frontend now
/// sends the enum and never names an item type in connection with search.
///
/// TRACES: UR-049 | DR-063
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SearchScope {
    All,
    Music,
    Movies,
    Tv,
}

impl SearchScope {
    /// The Jellyfin item types this scope requests, or `None` for `All`.
    ///
    /// `All` returns `None` rather than the union of every listed type on
    /// purpose: an explicit `includeItemTypes` list filters out anything not
    /// named in it, so a union would silently drop People, folders and any type
    /// nobody enumerated. Callers must omit the filter entirely on `None`.
    ///
    /// TRACES: UR-049 | DR-063
    pub fn item_types(self) -> Option<Vec<String>> {
        match self {
            SearchScope::All => None,
            SearchScope::Music => Some(
                ["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
                    .into_iter()
                    .map(String::from)
                    .collect(),
            ),
            SearchScope::Movies => Some(vec!["Movie".to_string()]),
            SearchScope::Tv => Some(
                ["Series", "Episode"]
                    .into_iter()
                    .map(String::from)
                    .collect(),
            ),
        }
    }

    /// The scope a library of this Jellyfin `CollectionType` belongs to, or
    /// `None` when its contents are not something favourites are browsed by.
    ///
    /// Same reasoning as `item_types`: this table is Jellyfin vocabulary and
    /// changes when Jellyfin renames a collection type, not when the library
    /// page is redesigned — so it lives here rather than in the UI that renders
    /// a per-library favourites tile.
    ///
    /// `All` is never returned: it is the *absence* of a category, offered
    /// alongside the libraries rather than derived from one.
    ///
    /// TRACES: UR-075 | DR-175 | UT-161
    pub fn for_collection_type(collection_type: &str) -> Option<SearchScope> {
        match collection_type {
            "movies" => Some(SearchScope::Movies),
            "tvshows" => Some(SearchScope::Tv),
            "music" => Some(SearchScope::Music),
            _ => None,
        }
    }
}

/// Options for search queries
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct SearchOptions {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub limit: Option<usize>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub include_item_types: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub search_term: Option<String>,
    /// Opaque scope selected by the UI. When set it **wins** over
    /// `include_item_types`, which remains for the non-search `get_items`
    /// callers that legitimately request a single concrete type.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub scope: Option<SearchScope>,
}

impl SearchOptions {
    /// Expand `scope` into `include_item_types` in place.
    ///
    /// Call this once, in the search command, *before* dispatching to the
    /// cache and server paths — both already honour `include_item_types`, and
    /// resolving in one place keeps online and offline results identical.
    ///
    /// TRACES: UR-049 | DR-063
    pub fn resolve_scope(&mut self) {
        if let Some(scope) = self.scope {
            // `All` yields None, which clears the filter — the correct
            // behaviour, not an omission.
            self.include_item_types = scope.item_types();
        }
    }
}

/// Playback information
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaybackInfo {
    pub media_source_id: String,
    pub play_session_id: String,
    pub stream_url: String,
    pub direct_play: bool,
    pub needs_transcoding: bool,
}

/// Live stream information returned from opening a Live TV / channel stream.
///
/// Unlike on-demand video, a live channel must be "opened" before it can be
/// streamed; the server returns a transcoding URL (already absolute) plus a
/// `live_stream_id` that can later be used to close the stream.
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveStreamInfo {
    pub stream_url: String,
    pub play_session_id: Option<String>,
    pub live_stream_id: Option<String>,
    pub media_source_id: Option<String>,
    /// How to open `stream_url`.
    ///
    /// A live channel is always an HLS transcode — the server has to repackage a
    /// broadcast mux into something a browser can play, and there is no static
    /// file to direct-play. Saying so here means the player page never has to
    /// work it out from the URL, which is the whole of DR-224.
    ///
    /// TRACES: UR-079 | DR-224
    pub transport: super::stream_selection::Transport,
}

/// Genre
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Genre {
    pub id: String,
    pub name: String,
    /// Number of albums tagged with this genre, when the backend can supply it
    /// (online only). Lets the frontend rank/pick genres without probing each
    /// one. `None` when unknown (e.g. offline).
    pub album_count: Option<u32>,
}

/// Image type
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
pub enum ImageType {
    Primary,
    Backdrop,
    Banner,
    Thumb,
    Logo,
}

impl ImageType {
    pub fn as_str(&self) -> &str {
        match self {
            ImageType::Primary => "Primary",
            ImageType::Backdrop => "Backdrop",
            ImageType::Banner => "Banner",
            ImageType::Thumb => "Thumb",
            ImageType::Logo => "Logo",
        }
    }
}

/// Image options
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct ImageOptions {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_width: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_height: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub quality: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tag: Option<String>,
}

/// Trait for checking if data has meaningful content
pub trait MeaningfulContent {
    fn has_content(&self) -> bool;
}

impl MeaningfulContent for Vec<Library> {
    fn has_content(&self) -> bool {
        !self.is_empty()
    }
}

impl MeaningfulContent for Vec<MediaItem> {
    fn has_content(&self) -> bool {
        !self.is_empty()
    }
}

impl MeaningfulContent for SearchResult {
    fn has_content(&self) -> bool {
        !self.items.is_empty()
    }
}

impl MeaningfulContent for MediaItem {
    fn has_content(&self) -> bool {
        true // A single item always has content if it exists
    }
}

impl MeaningfulContent for Vec<Genre> {
    fn has_content(&self) -> bool {
        !self.is_empty()
    }
}

impl MeaningfulContent for PlaybackInfo {
    fn has_content(&self) -> bool {
        !self.stream_url.is_empty()
    }
}

/// Playlist entry — wraps a MediaItem with the Jellyfin PlaylistItemId
/// needed for remove/reorder operations (distinct from the media item's ID)
///
/// @req: UR-014 - Make and edit playlists of music that sync back to Jellyfin
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistEntry {
    /// The playlist-scoped entry ID (Jellyfin's PlaylistItemId)
    pub playlist_item_id: String,
    /// The underlying media item
    #[serde(flatten)]
    pub item: MediaItem,
}

/// Result of creating a playlist
///
/// @req: JA-019 - Get/create/update playlists
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistCreatedResult {
    pub id: String,
}

impl MeaningfulContent for Vec<PlaylistEntry> {
    fn has_content(&self) -> bool {
        !self.is_empty()
    }
}

impl MeaningfulContent for PlaylistCreatedResult {
    fn has_content(&self) -> bool {
        !self.id.is_empty()
    }
}

#[cfg(test)]
mod search_scope_tests {
    use super::*;

    /// Music expands to the four Jellyfin types that make up the category.
    ///
    /// This table is the domain vocabulary that used to live in the frontend
    /// (`searchScope.ts`'s `SCOPE_ITEM_TYPES`) — the boundary leak that
    /// docs/specs/scoped-search-boundary.md was written about.
    ///
    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
    #[test]
    fn music_scope_expands_to_music_item_types() {
        assert_eq!(
            SearchScope::Music.item_types(),
            Some(vec![
                "MusicAlbum".to_string(),
                "MusicArtist".to_string(),
                "Audio".to_string(),
                "Playlist".to_string(),
            ])
        );
    }

    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
    #[test]
    fn movies_scope_expands_to_movie_only() {
        assert_eq!(
            SearchScope::Movies.item_types(),
            Some(vec!["Movie".to_string()])
        );
    }

    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
    #[test]
    fn tv_scope_expands_to_series_and_episode() {
        assert_eq!(
            SearchScope::Tv.item_types(),
            Some(vec!["Series".to_string(), "Episode".to_string()])
        );
    }

    /// `All` must send NO filter — not the union of the other scopes.
    ///
    /// Sending a union would silently drop every type nobody enumerated
    /// (Person, folders, …), which an explicit `includeItemTypes` list filters
    /// out. This is why `item_types()` returns Option rather than Vec.
    ///
    /// @req-test: UT-090 - All scope sends no item-type filter
    #[test]
    fn all_scope_sends_no_filter() {
        assert_eq!(SearchScope::All.item_types(), None);
    }

    /// Scope wins over an explicitly supplied include_item_types.
    ///
    /// @req-test: UT-091 - Scope takes precedence over include_item_types
    #[test]
    fn resolve_scope_overrides_include_item_types() {
        let mut options = SearchOptions {
            include_item_types: Some(vec!["Movie".to_string()]),
            scope: Some(SearchScope::Music),
            ..Default::default()
        };
        options.resolve_scope();

        assert_eq!(
            options.include_item_types,
            Some(vec![
                "MusicAlbum".to_string(),
                "MusicArtist".to_string(),
                "Audio".to_string(),
                "Playlist".to_string(),
            ])
        );
    }

    /// `All` clears any include_item_types so no filter reaches the query.
    ///
    /// @req-test: UT-090 - All scope sends no item-type filter
    #[test]
    fn resolve_all_scope_clears_include_item_types() {
        let mut options = SearchOptions {
            include_item_types: Some(vec!["Movie".to_string()]),
            scope: Some(SearchScope::All),
            ..Default::default()
        };
        options.resolve_scope();

        assert_eq!(options.include_item_types, None);
    }

    /// With no scope set, include_item_types passes through untouched — the
    /// non-search `getItems` callers rely on this.
    ///
    /// @req-test: UT-091 - Scope takes precedence over include_item_types
    #[test]
    fn resolve_without_scope_preserves_include_item_types() {
        let mut options = SearchOptions {
            include_item_types: Some(vec!["MusicAlbum".to_string()]),
            scope: None,
            ..Default::default()
        };
        options.resolve_scope();

        assert_eq!(
            options.include_item_types,
            Some(vec!["MusicAlbum".to_string()])
        );
    }

    /// The frontend sends the enum as camelCase over IPC.
    ///
    /// @req-test: UT-089 - SearchScope expands to Jellyfin item types
    #[test]
    fn scope_deserializes_from_camel_case() {
        let options: SearchOptions =
            serde_json::from_str(r#"{"scope": "music", "limit": 10}"#).unwrap();
        assert!(matches!(options.scope, Some(SearchScope::Music)));

        let all: SearchOptions = serde_json::from_str(r#"{"scope": "all"}"#).unwrap();
        assert!(matches!(all.scope, Some(SearchScope::All)));
    }

    /// TRACES: DR-175 | UT-161
    #[test]
    fn test_collection_type_maps_to_its_favorites_scope() {
        assert_eq!(
            SearchScope::for_collection_type("movies"),
            Some(SearchScope::Movies)
        );
        assert_eq!(
            SearchScope::for_collection_type("tvshows"),
            Some(SearchScope::Tv)
        );
        assert_eq!(
            SearchScope::for_collection_type("music"),
            Some(SearchScope::Music)
        );
    }

    /// A library kind favourites are not browsed by gets no tile at all, rather
    /// than one that opens an unfiltered list. `All` is never derived from a
    /// library — it is the cross-library entry offered beside them.
    ///
    /// TRACES: DR-175 | UT-161
    #[test]
    fn test_uncategorised_collection_types_have_no_favorites_scope() {
        for collection_type in ["livetv", "channels", "boxsets", "books", "unknown", ""] {
            assert_eq!(
                SearchScope::for_collection_type(collection_type),
                None,
                "{collection_type} should not carry a favourites scope"
            );
        }
    }

    /// TRACES: DR-175 | UT-161
    #[test]
    fn test_library_carries_its_favorites_scope_to_the_frontend() {
        let music = Library::new("1".into(), "Music".into(), "music".into(), None);
        assert_eq!(music.favorites_scope, Some(SearchScope::Music));

        let json = serde_json::to_value(&music).unwrap();
        assert_eq!(json["favoritesScope"], "music");

        // A library with no scope omits the field rather than sending null.
        let livetv = Library::new("2".into(), "Live TV".into(), "livetv".into(), None);
        let json = serde_json::to_value(&livetv).unwrap();
        assert!(json.get("favoritesScope").is_none());
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_artist_item_deserialize_pascal_case() {
        // Test that ArtistItem correctly deserializes PascalCase JSON from Jellyfin API
        let json = r#"{"Id": "artist123", "Name": "Bob Dylan"}"#;
        let result: Result<ArtistItem, _> = serde_json::from_str(json);

        assert!(result.is_ok());
        let artist = result.unwrap();
        assert_eq!(artist.id, "artist123");
        assert_eq!(artist.name, "Bob Dylan");
    }

    #[test]
    fn test_artist_item_deserialize_array() {
        // Test deserializing array of ArtistItems (common in API responses)
        let json = r#"[
            {"Id": "artist1", "Name": "Bob Dylan"},
            {"Id": "artist2", "Name": "Johnny Cash"}
        ]"#;
        let result: Result<Vec<ArtistItem>, _> = serde_json::from_str(json);

        assert!(result.is_ok());
        let artists = result.unwrap();
        assert_eq!(artists.len(), 2);
        assert_eq!(artists[0].id, "artist1");
        assert_eq!(artists[0].name, "Bob Dylan");
        assert_eq!(artists[1].id, "artist2");
        assert_eq!(artists[1].name, "Johnny Cash");
    }

    #[test]
    fn test_artist_item_serialize() {
        // ArtistItem serializes to camelCase for the frontend; it still accepts
        // Jellyfin's PascalCase on deserialize via #[serde(alias = ...)].
        let artist = ArtistItem {
            id: "test-id".to_string(),
            name: "Test Artist".to_string(),
        };

        let json = serde_json::to_string(&artist).expect("Failed to serialize");
        assert!(json.contains(r#""id":"test-id""#));
        assert!(json.contains(r#""name":"Test Artist""#));

        let from_pascal: ArtistItem =
            serde_json::from_str(r#"{"Id":"x","Name":"Y"}"#).expect("Failed to deserialize");
        assert_eq!(from_pascal.id, "x");
        assert_eq!(from_pascal.name, "Y");
    }

    #[test]
    fn test_media_item_with_primary_image_tag() {
        // Test that MediaItem correctly handles primary_image_tag
        let json = r#"{
            "id": "item123",
            "name": "Test Item",
            "type": "MusicAlbum",
            "serverId": "server1",
            "primaryImageTag": "tag123"
        }"#;

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

        let item = result.unwrap();
        assert_eq!(item.id, "item123");
        assert_eq!(item.name, "Test Item");
        assert_eq!(item.primary_image_tag, Some("tag123".to_string()));
    }

    #[test]
    fn test_media_item_with_artists() {
        // Test MediaItem with artists array
        let json = r#"{
            "id": "track1",
            "name": "Test Track",
            "type": "Audio",
            "serverId": "server1",
            "artists": ["Artist 1", "Artist 2"]
        }"#;

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

        let item = result.unwrap();
        let artists = item.artists.expect("Expected artists");
        assert_eq!(artists.len(), 2);
        assert_eq!(artists[0], "Artist 1");
        assert_eq!(artists[1], "Artist 2");
    }

    #[test]
    fn test_search_result_meaningful_content() {
        // Test MeaningfulContent trait for SearchResult
        let empty_result = SearchResult {
            items: vec![],
            total_record_count: 0,
        };
        assert!(!empty_result.has_content());

        let non_empty_result = SearchResult {
            items: vec![MediaItem {
                id: "1".to_string(),
                name: "Test".to_string(),
                item_type: "Audio".to_string(),
                kind: crate::domain::MediaKind::Track,
                is_folder: false,
                server_id: "server1".to_string(),
                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: None,
                image_id: None,
                backdrop_image_tags: None,
                parent_backdrop_image_tags: None,
                album_id: None,
                album_name: None,
                album_artist: None,
                artists: None,
                artist_items: None,
                index_number: None,
                parent_index_number: None,
                series_id: None,
                series_name: None,
                season_id: None,
                season_name: None,
                user_data: None,
                media_streams: None,
                media_sources: None,
                people: None,
            }],
            total_record_count: 1,
        };
        assert!(non_empty_result.has_content());
    }

    #[test]
    fn test_person_deserialize_complete() {
        // Test that Person deserializes correctly with all fields (PascalCase from Jellyfin API)
        let json = r#"{
            "Id": "person123",
            "Name": "Tom Hanks",
            "Type": "Actor",
            "Role": "Lead Actor",
            "PrimaryImageTag": "tag456"
        }"#;

        let result: Result<Person, _> = serde_json::from_str(json);
        assert!(result.is_ok(), "Failed to deserialize: {:?}", result.err());

        let person = result.unwrap();
        assert_eq!(person.id, "person123");
        assert_eq!(person.name, "Tom Hanks");
        assert_eq!(person.person_type, "Actor");
        assert_eq!(person.role, Some("Lead Actor".to_string()));
        assert_eq!(person.primary_image_tag, Some("tag456".to_string()));

        // Verify serialization uses camelCase for frontend
        let serialized = serde_json::to_string(&person).expect("Failed to serialize");
        assert!(
            serialized.contains(r#""type":"Actor""#),
            "Serialized form should use 'type' not 'Type'"
        );
        assert!(serialized.contains(r#""id":"person123""#));
        assert!(serialized.contains(r#""primaryImageTag":"tag456""#));
    }

    #[test]
    fn test_person_deserialize_minimal() {
        // Test that Person deserializes with missing optional fields (uses defaults)
        let json = r#"{
            "Id": "person456",
            "Name": "Meryl Streep",
            "Type": "Actress"
        }"#;

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

        let person = result.unwrap();
        assert_eq!(person.id, "person456");
        assert_eq!(person.name, "Meryl Streep");
        assert_eq!(person.person_type, "Actress");
        assert_eq!(person.role, None);
        assert_eq!(person.primary_image_tag, None);
    }

    #[test]
    fn test_person_array_deserialize() {
        // Test deserializing array of Person objects (common in Jellyfin API)
        let json = r#"[
            {"Id": "actor1", "Name": "Actor One", "Type": "Actor"},
            {"Id": "director1", "Name": "Director One", "Type": "Director", "Role": "Director"}
        ]"#;

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

        let people = result.unwrap();
        assert_eq!(people.len(), 2);
        assert_eq!(people[0].name, "Actor One");
        assert_eq!(people[1].person_type, "Director");
        assert_eq!(people[1].role, Some("Director".to_string()));
    }

    #[test]
    fn test_media_item_with_people() {
        // Test that MediaItem correctly deserializes with people array (from API in PascalCase)
        let json = r#"{
            "id": "movie1",
            "name": "Test Movie",
            "type": "Movie",
            "serverId": "server1",
            "people": [
                {"Id": "actor1", "Name": "John Doe", "Type": "Actor"},
                {"Id": "director1", "Name": "Jane Smith", "Type": "Director"}
            ]
        }"#;

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

        let item = result.unwrap();
        let people = item.people.as_ref().expect("Expected people array");
        assert_eq!(people.len(), 2);
        assert_eq!(people[0].name, "John Doe");
        assert_eq!(people[0].person_type, "Actor");
        assert_eq!(people[1].person_type, "Director");

        // Verify that when serialized to frontend, it uses camelCase
        let serialized = serde_json::to_string(&item).expect("Failed to serialize");
        let re_parsed: serde_json::Value =
            serde_json::from_str(&serialized).expect("Failed to parse serialized");
        let people_array = re_parsed["people"]
            .as_array()
            .expect("people should be array");
        assert!(
            people_array[0].get("type").is_some(),
            "Serialized person should have 'type' field"
        );
        assert_eq!(people_array[0]["type"].as_str().unwrap(), "Actor");
    }

    #[test]
    fn test_playlist_entry_serialization() {
        let entry = PlaylistEntry {
            playlist_item_id: "entry-abc-123".to_string(),
            item: MediaItem {
                id: "track1".to_string(),
                name: "Test Track".to_string(),
                item_type: "Audio".to_string(),
                kind: crate::domain::MediaKind::Track,
                is_folder: false,
                server_id: "server1".to_string(),
                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: None,
                image_id: None,
                backdrop_image_tags: None,
                parent_backdrop_image_tags: None,
                album_id: None,
                album_name: None,
                album_artist: None,
                artists: Some(vec!["Artist One".to_string()]),
                artist_items: None,
                index_number: None,
                parent_index_number: None,
                series_id: None,
                series_name: None,
                season_id: None,
                season_name: None,
                user_data: None,
                media_streams: None,
                media_sources: None,
                people: None,
            },
        };

        let json = serde_json::to_string(&entry).expect("Failed to serialize");
        // playlistItemId is camelCase
        assert!(json.contains(r#""playlistItemId":"entry-abc-123""#));
        // Flattened MediaItem fields appear at top level
        assert!(json.contains(r#""id":"track1""#));
        assert!(json.contains(r#""name":"Test Track""#));
        assert!(json.contains(r#""type":"Audio""#));
    }

    #[test]
    fn test_playlist_created_result_serialization() {
        let result = PlaylistCreatedResult {
            id: "playlist-new-123".to_string(),
        };
        let json = serde_json::to_string(&result).expect("Failed to serialize");
        assert!(json.contains(r#""id":"playlist-new-123""#));
    }

    #[test]
    fn test_playlist_entry_meaningful_content() {
        let empty: Vec<PlaylistEntry> = vec![];
        assert!(!empty.has_content());

        let non_empty = vec![PlaylistEntry {
            playlist_item_id: "e1".to_string(),
            item: MediaItem {
                id: "1".to_string(),
                name: "Track".to_string(),
                item_type: "Audio".to_string(),
                kind: crate::domain::MediaKind::Track,
                is_folder: false,
                server_id: "s1".to_string(),
                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: None,
                image_id: None,
                backdrop_image_tags: None,
                parent_backdrop_image_tags: None,
                album_id: None,
                album_name: None,
                album_artist: None,
                artists: None,
                artist_items: None,
                index_number: None,
                parent_index_number: None,
                series_id: None,
                series_name: None,
                season_id: None,
                season_name: None,
                user_data: None,
                media_streams: None,
                media_sources: None,
                people: None,
            },
        }];
        assert!(non_empty.has_content());
    }
}
