//! # Search Query Models and Builders
//!
//! This module provides ergonomic builders for constructing search queries against the Suno backend API.
//!
//! ## Important: Ranking Behavior by Search Type
//!
//! The backend API applies different constraints to results based on the `rank_by` parameter and search type.
//! This is particularly important for PUBLIC_SONG searches:
//!
//! ### PublicSong Search with Search Term
//! When you search for public songs WITH a search term (e.g., `SearchQuery::public_song("jazz")`):
//!
//! | Ranking | Min Upvotes | Use Case |
//! |---------|-------------|----------|
//! | `"most_relevant"` ✅ | >= 0 | **DEFAULT & RECOMMENDED** - Text relevance ranking |
//! | `"most_recent"` ❌ | >= 3 | ⚠️ Filters out low-upvoted songs - avoid for general search |
//! | `"upvote_count"` ✅ | >= 0 | Sort by popularity |
//! | `"play_count"` ✅ | >= 0 | Sort by listen count |
//! | `"trending"` ✅ | >= 0 | Trending content |
//! | `"by_hour"`, `"by_day"`, etc. | >= 1 | Time-based rankings |
//!
//! ### PublicSong Search WITHOUT a Search Term (Discover Mode)
//! When you search for public songs WITHOUT a search term:
//! - **All rankings require upvote_count >= 3**
//! - Returns only curated, high-engagement content
//! - Use `.rank_by("most_recent")` for latest popular songs
//!
//! ### Key Distinction
//! **DO NOT use `"most_recent"` for term-based PUBLIC_SONG searches** - it will return 0 results
//! for new or low-engagement songs even if they match the search term perfectly.
//! **Use `"most_relevant"` instead** (the default for `SearchQuery::public_song(term)`).
//!
//! ## Example
//! ```rust,no_run
//! use sunocore::models::search::SearchQuery;
//!
//! // ✅ Good: Uses "most_relevant" ranking (respects all upvote levels)
//! let query = SearchQuery::public_song("jazz").build()?;
//!
//! // ✅ Good: Explicitly choosing most_relevant
//! let query = SearchQuery::public_song("jazz")
//!     .rank_by("most_relevant")
//!     .build()?;
//!
//! // ❌ Bad: "most_recent" requires >= 3 upvotes
//! let query = SearchQuery::public_song("jazz")
//!     .rank_by("most_recent")
//!     .build()?;  // Will return 0 results for low-upvoted songs!
//! ```

use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize};
use std::collections::BTreeMap;
use uuid::Uuid;

// =============================================================================
// CUSTOM DESERIALIZERS
// =============================================================================

/// Deserialize a UUID, treating -1 as None (for ghost/deleted users)
fn deserialize_uuid_or_negative_one<'de, D>(deserializer: D) -> Result<Uuid, D::Error>
where
    D: Deserializer<'de>,
{
    use serde::de::Error;
    use serde_json::Value;

    let value = Value::deserialize(deserializer)?;

    match value {
        Value::String(s) => Uuid::parse_str(&s).map_err(D::Error::custom),
        Value::Number(n) => {
            // Handle -1 as a special case (deleted/ghost user)
            if n.as_i64() == Some(-1) {
                // Return nil UUID as a placeholder for invalid users
                Ok(Uuid::nil())
            } else {
                Err(D::Error::custom(format!(
                    "Expected UUID string or -1, got number: {}",
                    n
                )))
            }
        }
        _ => Err(D::Error::custom(format!(
            "Expected UUID string or -1, got: {}",
            value.get_type()
        ))),
    }
}

trait JsonValueExt {
    fn get_type(&self) -> &'static str;
}

impl JsonValueExt for serde_json::Value {
    fn get_type(&self) -> &'static str {
        match self {
            serde_json::Value::Null => "null",
            serde_json::Value::Bool(_) => "bool",
            serde_json::Value::Number(_) => "number",
            serde_json::Value::String(_) => "string",
            serde_json::Value::Array(_) => "array",
            serde_json::Value::Object(_) => "object",
        }
    }
}

// =============================================================================
// VALIDATION ERROR TYPE
// =============================================================================

/// Errors that can occur when building or validating search queries
#[derive(Debug, Clone)]
pub enum ValidationError {
    /// Missing a required field for a specific search type
    MissingRequiredField { field: String, search_type: String },

    /// Query name cannot be empty
    EmptyName,

    /// Invalid pagination parameters
    InvalidPagination { reason: String },

    /// Invalid page size (must be between 1 and 100)
    InvalidSize { size: i32 },

    /// Empty search request (no queries)
    EmptySearchRequest,
}

impl std::fmt::Display for ValidationError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::MissingRequiredField { field, search_type } => {
                write!(
                    f,
                    "Missing required field '{field}' for search type '{search_type}'"
                )
            }
            Self::EmptyName => write!(f, "Query name cannot be empty"),
            Self::InvalidPagination { reason } => write!(f, "Invalid pagination: {reason}"),
            Self::InvalidSize { size } => {
                write!(f, "Invalid size: {size} (must be between 1 and 100)")
            }
            Self::EmptySearchRequest => write!(f, "Search request has no queries"),
        }
    }
}

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

// =============================================================================
// REQUEST TYPES
// =============================================================================

/// Request for compound search endpoint
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchRequest {
    pub search_queries: Vec<SearchQuery>,
}

/// Individual search query in a compound search
///
/// # Examples
///
/// ## Simple search with auto-generated name
/// ```rust,no_run
/// use sunocore::models::search::SearchQuery;
///
/// let query = SearchQuery::public_song("jazz music")
///     .rank_by("trending")
///     .size(20)
///     .build()?;
/// ```
///
/// ## Search similar songs
/// ```rust,no_run
/// let query = SearchQuery::similar_to("song-id-here")
///     .size(10)
///     .build()?;
/// ```
///
/// ## Search with filters
/// ```rust,no_run
/// use sunocore::models::search::{SearchQuery, SearchFilters};
///
/// let filters = SearchFilters::builder()
///     .full_song()
///     .no_covers()
///     .build();
///
/// let query = SearchQuery::public_song("ambient")
///     .is_instrumental(true)
///     .filters(filters)
///     .build()?;
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SearchQuery {
    /// Unique name to identify the search
    pub name: String,

    /// Type of search to perform
    pub search_type: SearchType,

    /// Item type to search for (default: clip)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub item_type: Option<String>,

    /// Search term/query
    #[serde(skip_serializing_if = "Option::is_none")]
    pub term: Option<String>,

    /// Genre filter (optional)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub genre: Option<String>,

    /// Vector embedding for similarity search
    #[serde(skip_serializing_if = "Option::is_none")]
    pub vector: Option<Vec<f32>>,

    /// User ID filter
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user_id: Option<i64>,

    /// Project ID filter
    #[serde(skip_serializing_if = "Option::is_none")]
    pub project_id: Option<String>,

    /// Song/Clip ID for similarity searches
    #[serde(skip_serializing_if = "Option::is_none")]
    pub song_id: Option<String>,

    /// Is public filter
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_public: Option<bool>,

    /// Is liked filter
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_liked: Option<bool>,

    /// Is suno short (video) filter
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_suno_short: Option<bool>,

    /// Complex filters object
    #[serde(skip_serializing_if = "Option::is_none")]
    pub filters: Option<SearchFilters>,

    /// Ranking method
    ///
    /// Controls how results are ordered. Valid values depend on the search type and whether a search term is provided:
    ///
    /// **For PublicSong searches with a search term:**
    /// - `"most_relevant"` ✅ (RECOMMENDED) - Ranks by text relevance, works with any upvote count
    /// - `"most_recent"` ❌ - Requires upvote_count >= 3 (will return 0 results for low-upvoted songs)
    /// - `"upvote_count"` ✅ - Ranks by upvote count (requires upvotes >= 0)
    /// - `"play_count"` ✅ - Ranks by play count (requires upvotes >= 0)
    /// - `"trending"` ✅ - Trending content (requires upvotes >= 0)
    /// - `"by_hour"`, `"by_day"`, `"by_week"`, `"by_month"` ✅ - Time-based rankings (requires upvotes >= 1)
    ///
    /// **For PublicSong searches without a search term:**
    /// - All rankings require upvote_count >= 3
    ///
    /// **Important:** When using PUBLIC_SONG search type with a search term, prefer `"most_relevant"`
    /// over `"most_recent"` to avoid filtering out songs with low upvote counts.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rank_by: Option<String>,

    /// Sort order (asc/desc)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub order: Option<String>,

    /// User IDs for followers/following feed
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user_ids: Option<Vec<i64>>,

    /// Boosted user handles for mention suggestions
    #[serde(skip_serializing_if = "Option::is_none")]
    pub boosted_user_handles: Option<Vec<String>>,

    /// Languages to filter by
    #[serde(skip_serializing_if = "Option::is_none")]
    pub languages: Option<Vec<String>>,

    /// Pagination: offset (required)
    ///
    /// REQUIRED by the backend API. Specifies the starting index for pagination.
    /// Must always be >= 0. The backend uses this to determine which results to return.
    /// For the first page, always use 0.
    pub from_index: i32,

    /// Pagination: result count (required)
    ///
    /// REQUIRED by the backend API. Specifies how many results to return per request.
    /// Must be between 1 and the maximum allowed by the API (typically 100).
    /// Common values: 10 for UI lists, 20 for default, 100 for batch operations.
    pub size: i32,

    /// Song IDs to exclude from results
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exclude_song_ids: Option<Vec<String>>,

    /// Is instrumental filter
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_instrumental: Option<bool>,

    /// Model version filter
    #[serde(skip_serializing_if = "Option::is_none")]
    pub model_version: Option<String>,

    /// Maximum creation time filter
    #[serde(skip_serializing_if = "Option::is_none")]
    pub maximum_create_time: Option<String>,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum SearchType {
    PublicSong,
    SimilarSong,
    LibrarySong,
    TestLibrarySongDbQuery,
    LibraryPlaylist,
    TagSong,
    GenrePreviewSong,
    Playlist,
    FollowingClipFeed,
    User,
    UserCreateTags,
    SunoShorts,
    Project,
    LibraryPersona,
    PublicPersona,
    NewSongForYou,
    GenreEmbedding,
    GenreTerm,
    Lyrics,
    HashtagSong,
    GenreForYou,
    PlaylistForYou,
    UserToFollow,
    TopUserSongs,
    SimilarPopularSongs,
    PopularSongTag,
    PopularSongGenreKnn,
    PlatformGenre,
    UserGenreSongSearch,
    HooksFromClipsSimilarToListeningHistory,
    HybridSearch,
}

impl SearchType {
    /// Convert SearchType to its string representation (for name generation)
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::PublicSong => "public_song",
            Self::SimilarSong => "similar_song",
            Self::LibrarySong => "library_song",
            Self::TestLibrarySongDbQuery => "test_library_song_db_query",
            Self::LibraryPlaylist => "library_playlist",
            Self::TagSong => "tag_song",
            Self::GenrePreviewSong => "genre_preview_song",
            Self::Playlist => "playlist",
            Self::FollowingClipFeed => "following_clip_feed",
            Self::User => "user",
            Self::UserCreateTags => "user_create_tags",
            Self::SunoShorts => "suno_shorts",
            Self::Project => "project",
            Self::LibraryPersona => "library_persona",
            Self::PublicPersona => "public_persona",
            Self::NewSongForYou => "new_song_for_you",
            Self::GenreEmbedding => "genre_embedding",
            Self::GenreTerm => "genre_term",
            Self::Lyrics => "lyrics",
            Self::HashtagSong => "hashtag_song",
            Self::GenreForYou => "genre_for_you",
            Self::PlaylistForYou => "playlist_for_you",
            Self::UserToFollow => "user_to_follow",
            Self::TopUserSongs => "top_user_songs",
            Self::SimilarPopularSongs => "similar_popular_songs",
            Self::PopularSongTag => "popular_song_tag",
            Self::PopularSongGenreKnn => "popular_song_genre_knn",
            Self::PlatformGenre => "platform_genre",
            Self::UserGenreSongSearch => "user_genre_song_search",
            Self::HooksFromClipsSimilarToListeningHistory => {
                "hooks_from_clips_similar_to_listening_history"
            }
            Self::HybridSearch => "hybrid_search",
        }
    }
}

/// Helper for building SearchQuery with fluent API
///
/// # Examples
///
/// ```rust,no_run
/// use sunocore::models::search::SearchQuery;
///
/// // Simple public song search
/// let query = SearchQuery::public_song("ambient music")
///     .rank_by("most_relevant")
///     .size(20)
///     .build()?;
///
/// // Search with filters
/// let query = SearchQuery::similar_to("song-uuid")
///     .size(10)
///     .build()?;
/// ```
pub struct SearchQueryBuilder {
    query: SearchQuery,
    auto_name: bool,
}

impl SearchQueryBuilder {
    /// Create a new builder for the given search type
    ///
    /// Default pagination values:
    /// - `from_index`: 0 (start from first result)
    /// - `size`: 20 (return 20 results per page)
    ///
    /// These can be overridden with `.from_index()` and `.size()` methods.
    pub fn new(search_type: SearchType) -> Self {
        Self {
            query: SearchQuery {
                name: String::new(),
                search_type,
                item_type: None,
                term: None,
                genre: None,
                vector: None,
                user_id: None,
                project_id: None,
                song_id: None,
                is_public: None,
                is_liked: None,
                is_suno_short: None,
                filters: None,
                rank_by: None,
                order: None,
                user_ids: None,
                boosted_user_handles: None,
                languages: None,
                from_index: 0,
                size: 20,
                exclude_song_ids: None,
                is_instrumental: None,
                model_version: None,
                maximum_create_time: None,
            },
            auto_name: true,
        }
    }

    /// Override the auto-generated name (disables automatic generation)
    pub fn name(mut self, name: impl Into<String>) -> Self {
        self.query.name = name.into();
        self.auto_name = false;
        self
    }

    /// Set the search term
    pub fn term(mut self, term: impl Into<String>) -> Self {
        self.query.term = Some(term.into());
        self
    }

    /// Set the item type
    pub fn item_type(mut self, item_type: impl Into<String>) -> Self {
        self.query.item_type = Some(item_type.into());
        self
    }

    /// Set genre filter
    pub fn genre(mut self, genre: impl Into<String>) -> Self {
        self.query.genre = Some(genre.into());
        self
    }

    /// Set vector embedding for similarity search
    pub fn vector(mut self, vector: Vec<f32>) -> Self {
        self.query.vector = Some(vector);
        self
    }

    /// Set user ID filter
    pub fn user_id(mut self, user_id: i64) -> Self {
        self.query.user_id = Some(user_id);
        self
    }

    /// Set project ID filter
    pub fn project_id(mut self, project_id: impl Into<String>) -> Self {
        self.query.project_id = Some(project_id.into());
        self
    }

    /// Set song/clip ID (for similarity searches)
    pub fn song_id(mut self, song_id: impl Into<String>) -> Self {
        self.query.song_id = Some(song_id.into());
        self
    }

    /// Set public filter
    pub fn is_public(mut self, is_public: bool) -> Self {
        self.query.is_public = Some(is_public);
        self
    }

    /// Set liked filter
    pub fn is_liked(mut self, is_liked: bool) -> Self {
        self.query.is_liked = Some(is_liked);
        self
    }

    /// Set suno short filter
    pub fn is_suno_short(mut self, is_suno_short: bool) -> Self {
        self.query.is_suno_short = Some(is_suno_short);
        self
    }

    /// Set complex filters
    pub fn filters(mut self, filters: SearchFilters) -> Self {
        self.query.filters = Some(filters);
        self
    }

    /// Set ranking method
    ///
    /// See module documentation for `rank_by` field for detailed constraints and recommendations.
    ///
    /// **Common values:**
    /// - `"most_relevant"` - Text relevance (recommended for term searches)
    /// - `"most_recent"` - Latest (⚠️ requires upvotes >= 3 with PUBLIC_SONG + term)
    /// - `"upvote_count"` - Most popular
    /// - `"play_count"` - Most listened
    /// - `"trending"` - Trending content
    /// - `"by_hour"`, `"by_day"`, `"by_week"`, `"by_month"` - Time-based
    pub fn rank_by(mut self, rank_by: impl Into<String>) -> Self {
        self.query.rank_by = Some(rank_by.into());
        self
    }

    /// Set sort order ("asc" or "desc")
    pub fn order(mut self, order: impl Into<String>) -> Self {
        self.query.order = Some(order.into());
        self
    }

    /// Set user IDs for followers/following feed
    pub fn user_ids(mut self, user_ids: Vec<i64>) -> Self {
        self.query.user_ids = Some(user_ids);
        self
    }

    /// Set boosted user handles for mention suggestions
    pub fn boosted_user_handles(mut self, handles: Vec<String>) -> Self {
        self.query.boosted_user_handles = Some(handles);
        self
    }

    /// Set languages to filter by
    pub fn languages(mut self, languages: Vec<String>) -> Self {
        self.query.languages = Some(languages);
        self
    }

    /// Override pagination offset (required field)
    ///
    /// Default is 0. This value specifies the starting index for paginated results.
    /// For the first page, use 0. For subsequent pages, increment by the page size.
    pub fn from_index(mut self, from_index: i32) -> Self {
        self.query.from_index = from_index;
        self
    }

    /// Override page size / result count (required field)
    ///
    /// Default is 20. Specifies how many results to return in this request.
    /// Valid range: 1 to API maximum (typically 100).
    pub fn size(mut self, size: i32) -> Self {
        self.query.size = size;
        self
    }

    /// Set song IDs to exclude from results
    pub fn exclude_song_ids(mut self, song_ids: Vec<String>) -> Self {
        self.query.exclude_song_ids = Some(song_ids);
        self
    }

    /// Set instrumental filter
    pub fn is_instrumental(mut self, is_instrumental: bool) -> Self {
        self.query.is_instrumental = Some(is_instrumental);
        self
    }

    /// Set model version filter
    pub fn model_version(mut self, model_version: impl Into<String>) -> Self {
        self.query.model_version = Some(model_version.into());
        self
    }

    /// Set maximum creation time filter
    pub fn maximum_create_time(mut self, time: impl Into<String>) -> Self {
        self.query.maximum_create_time = Some(time.into());
        self
    }

    /// Set pagination using a Pagination helper
    pub fn pagination(mut self, pagination: Pagination) -> Self {
        self.query.from_index = pagination.from_index;
        self.query.size = pagination.size;
        self
    }

    /// Build and validate the query
    pub fn build(mut self) -> Result<SearchQuery, ValidationError> {
        // Auto-generate name if enabled
        if self.auto_name {
            self.query.name = self.query.auto_generate_name();
        }

        // Validate
        self.query.validate()?;

        Ok(self.query)
    }
}

impl Default for SearchQueryBuilder {
    fn default() -> Self {
        Self::new(SearchType::PublicSong)
    }
}

/// Complex search filters
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SearchFilters {
    /// Is full-length song
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_full_song: Option<bool>,

    /// Is a cover version
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_cover: Option<bool>,

    /// Is extended version
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_extend: Option<bool>,

    /// Is infilled song
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_infill: Option<bool>,

    /// Is persona-generated
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_persona: Option<bool>,

    /// Is scene/image-to-song
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_scene: Option<bool>,

    /// Is upsampled
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_upsample: Option<bool>,

    /// Is video-to-song specifically
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_video_to_song: Option<bool>,

    /// Hide generated stems
    #[serde(skip_serializing_if = "Option::is_none")]
    pub hide_gen_stems: Option<bool>,

    /// Is generated stem only
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_gen_stem: Option<bool>,
}

impl SearchFilters {
    /// Create a new empty filters builder
    pub fn builder() -> SearchFiltersBuilder {
        SearchFiltersBuilder::default()
    }
}

/// Helper for building SearchFilters with fluent API
///
/// # Examples
///
/// ```rust,no_run
/// use sunocore::models::search::SearchFilters;
///
/// let filters = SearchFilters::builder()
///     .full_song()
///     .no_covers()
///     .build();
/// ```
#[derive(Debug, Clone, Default)]
pub struct SearchFiltersBuilder {
    filters: SearchFilters,
}

impl SearchFiltersBuilder {
    /// Create a new filters builder
    pub fn new() -> Self {
        Self::default()
    }

    /// Set is_full_song to true
    pub fn full_song(mut self) -> Self {
        self.filters.is_full_song = Some(true);
        self
    }

    /// Set is_full_song
    pub fn is_full_song(mut self, val: bool) -> Self {
        self.filters.is_full_song = Some(val);
        self
    }

    /// Set is_cover to false
    pub fn no_covers(mut self) -> Self {
        self.filters.is_cover = Some(false);
        self
    }

    /// Set is_cover
    pub fn is_cover(mut self, val: bool) -> Self {
        self.filters.is_cover = Some(val);
        self
    }

    /// Set is_extend
    pub fn is_extend(mut self, val: bool) -> Self {
        self.filters.is_extend = Some(val);
        self
    }

    /// Set is_infill
    pub fn is_infill(mut self, val: bool) -> Self {
        self.filters.is_infill = Some(val);
        self
    }

    /// Set is_persona
    pub fn is_persona(mut self, val: bool) -> Self {
        self.filters.is_persona = Some(val);
        self
    }

    /// Set is_scene
    pub fn is_scene(mut self, val: bool) -> Self {
        self.filters.is_scene = Some(val);
        self
    }

    /// Set is_upsample
    pub fn is_upsample(mut self, val: bool) -> Self {
        self.filters.is_upsample = Some(val);
        self
    }

    /// Set is_video_to_song
    pub fn is_video_to_song(mut self, val: bool) -> Self {
        self.filters.is_video_to_song = Some(val);
        self
    }

    /// Set hide_gen_stems
    pub fn hide_gen_stems(mut self, val: bool) -> Self {
        self.filters.hide_gen_stems = Some(val);
        self
    }

    /// Set is_gen_stem
    pub fn is_gen_stem(mut self, val: bool) -> Self {
        self.filters.is_gen_stem = Some(val);
        self
    }

    /// Build the filters
    pub fn build(self) -> SearchFilters {
        self.filters
    }
}

/// Pagination helper for managing page navigation
///
/// # Examples
///
/// ```rust,no_run
/// use sunocore::models::search::Pagination;
///
/// let page1 = Pagination::first_page(20)?;
/// let page2 = page1.next_page();
/// let page0 = page2.prev_page();
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
#[derive(Debug, Clone, Copy)]
pub struct Pagination {
    /// Offset in results (0-indexed)
    pub from_index: i32,
    /// Number of results per page
    pub size: i32,
}

impl Pagination {
    /// Create a pagination with explicit offset and size
    pub fn new(from_index: i32, size: i32) -> Result<Self, ValidationError> {
        if from_index < 0 {
            return Err(ValidationError::InvalidPagination {
                reason: "from_index must be >= 0".to_string(),
            });
        }
        if size < 1 || size > 100 {
            return Err(ValidationError::InvalidSize { size });
        }
        Ok(Self { from_index, size })
    }

    /// Create pagination for the first page with given size
    pub fn first_page(size: i32) -> Result<Self, ValidationError> {
        Self::new(0, size)
    }

    /// Get the next page
    pub fn next_page(&self) -> Self {
        Self {
            from_index: self.from_index + self.size,
            size: self.size,
        }
    }

    /// Get the previous page
    pub fn prev_page(&self) -> Self {
        Self {
            from_index: (self.from_index - self.size).max(0),
            size: self.size,
        }
    }

    /// Get a specific page number (1-indexed)
    pub fn page(page_num: i32, size: i32) -> Result<Self, ValidationError> {
        if page_num < 1 {
            return Err(ValidationError::InvalidPagination {
                reason: "page_num must be >= 1".to_string(),
            });
        }
        Self::new((page_num - 1) * size, size)
    }
}

impl Default for Pagination {
    fn default() -> Self {
        Self {
            from_index: 0,
            size: 20,
        }
    }
}

impl SearchQuery {
    /// Auto-generate name from search_type and term (frontend behavior)
    fn auto_generate_name(&self) -> String {
        let type_str = self.search_type.as_str();
        let term = self.term.as_deref().unwrap_or("");
        format!("{}{}", type_str, term)
    }

    /// Validate the query based on search type
    fn validate(&self) -> Result<(), ValidationError> {
        // Validate name is not empty
        if self.name.is_empty() {
            return Err(ValidationError::EmptyName);
        }

        // Search-type-specific validation
        match self.search_type {
            SearchType::SimilarSong => {
                if self.song_id.is_none() {
                    return Err(ValidationError::MissingRequiredField {
                        field: "song_id".to_string(),
                        search_type: "SimilarSong".to_string(),
                    });
                }
            }
            SearchType::PublicSong
            | SearchType::LibrarySong
            | SearchType::TestLibrarySongDbQuery => {
                if self.term.is_none() && self.filters.is_none() {
                    return Err(ValidationError::MissingRequiredField {
                        field: "term or filters".to_string(),
                        search_type: format!("{:?}", self.search_type),
                    });
                }
            }
            _ => {}
        }

        // Validate pagination (required fields)
        if self.size < 1 || self.size > 100 {
            return Err(ValidationError::InvalidSize { size: self.size });
        }

        if self.from_index < 0 {
            return Err(ValidationError::InvalidPagination {
                reason: "from_index must be >= 0".to_string(),
            });
        }

        Ok(())
    }

    // =============================================================================
    // CONVENIENCE CONSTRUCTORS
    // =============================================================================

    /// Create a builder for searching public songs
    ///
    /// **Default ranking: `"most_relevant"`** - This is chosen because it works with search terms
    /// and doesn't filter out songs with low upvote counts. See `rank_by` field documentation for
    /// the distinction between `"most_relevant"` and `"most_recent"` behavior.
    ///
    /// Override with `.rank_by()` if you need different sorting:
    /// ```rust,no_run
    /// use sunocore::models::search::SearchQuery;
    ///
    /// // Most relevant to search term
    /// let query = SearchQuery::public_song("jazz")
    ///     .rank_by("most_relevant")
    ///     .build()?;
    ///
    /// // By play count (still requires upvotes >= 0 with search term)
    /// let query = SearchQuery::public_song("jazz")
    ///     .rank_by("play_count")
    ///     .build()?;
    /// ```
    ///
    /// # Caution: "most_recent" Ranking
    /// Avoid `.rank_by("most_recent")` for PUBLIC_SONG searches with a search term,
    /// as it requires upvote_count >= 3 and will return 0 results for newly created or low-engagement songs.
    /// Use `"most_relevant"` instead.
    pub fn public_song(term: impl Into<String>) -> SearchQueryBuilder {
        SearchQueryBuilder::new(SearchType::PublicSong)
            .term(term)
            .rank_by("most_relevant")
            .size(20)
    }

    /// Create a builder for searching library songs
    pub fn library_song(term: impl Into<String>) -> SearchQueryBuilder {
        SearchQueryBuilder::new(SearchType::LibrarySong)
            .term(term)
            .rank_by("most_recent")
            .size(20)
    }

    /// Create a builder for running the test library song DB query search
    pub fn test_library_song_db_query(term: impl Into<String>) -> SearchQueryBuilder {
        SearchQueryBuilder::new(SearchType::TestLibrarySongDbQuery)
            .term(term)
            .size(20)
    }

    /// Create a builder for finding similar songs
    ///
    /// # Examples
    /// ```rust,no_run
    /// use sunocore::models::search::SearchQuery;
    ///
    /// let query = SearchQuery::similar_to("song-uuid").build()?;
    /// ```
    pub fn similar_to(song_id: impl Into<String>) -> SearchQueryBuilder {
        SearchQueryBuilder::new(SearchType::SimilarSong)
            .song_id(song_id)
            .size(20)
    }

    /// Create a builder for searching playlists
    pub fn playlist(term: impl Into<String>) -> SearchQueryBuilder {
        SearchQueryBuilder::new(SearchType::Playlist)
            .term(term)
            .size(20)
    }

    /// Create a builder for searching users
    pub fn user(term: impl Into<String>) -> SearchQueryBuilder {
        SearchQueryBuilder::new(SearchType::User)
            .term(term)
            .size(20)
    }

    /// Create a builder for searching Suno Shorts (videos)
    pub fn suno_shorts(term: impl Into<String>) -> SearchQueryBuilder {
        SearchQueryBuilder::new(SearchType::SunoShorts)
            .term(term)
            .size(20)
    }

    /// Create a builder for searching songs by tag/genre
    pub fn tag_song(term: impl Into<String>) -> SearchQueryBuilder {
        SearchQueryBuilder::new(SearchType::TagSong)
            .term(term)
            .size(20)
    }

    /// Create a builder for searching songs by lyrics
    pub fn lyrics(lyrics: impl Into<String>) -> SearchQueryBuilder {
        SearchQueryBuilder::new(SearchType::Lyrics)
            .term(lyrics)
            .size(20)
    }

    /// Create a builder for searching songs by hashtag
    pub fn hashtag_song(term: impl Into<String>) -> SearchQueryBuilder {
        SearchQueryBuilder::new(SearchType::HashtagSong)
            .term(term)
            .size(20)
    }

    /// Create a builder for searching top songs by a specific user
    pub fn top_user_songs_for_user(user_id: i64) -> SearchQueryBuilder {
        SearchQueryBuilder::new(SearchType::TopUserSongs)
            .user_id(user_id)
            .size(20)
    }

    /// Create a builder for hybrid semantic search (text + embeddings)
    pub fn hybrid_search(term: impl Into<String>) -> SearchQueryBuilder {
        SearchQueryBuilder::new(SearchType::HybridSearch)
            .term(term)
            .size(20)
    }

    /// Create a builder for custom search type
    pub fn custom(search_type: SearchType) -> SearchQueryBuilder {
        SearchQueryBuilder::new(search_type)
    }
}

impl SearchRequest {
    /// Create a builder for compound search requests
    pub fn builder() -> SearchRequestBuilder {
        SearchRequestBuilder::new()
    }

    /// Create a simple public song search request
    ///
    /// # Examples
    /// ```rust,no_run
    /// use sunocore::models::search::SearchRequest;
    ///
    /// let request = SearchRequest::simple_public_song("lo-fi beats")?;
    /// ```
    pub fn simple_public_song(term: impl Into<String>) -> Result<Self, ValidationError> {
        Ok(Self {
            search_queries: vec![SearchQuery::public_song(term).build()?],
        })
    }
}

/// Helper for building compound search requests with multiple queries
///
/// # Examples
///
/// ```rust,no_run
/// use sunocore::models::search::SearchRequest;
///
/// let request = SearchRequest::builder()
///     .public_song("jazz")
///     .user("artist_name")
///     .build()?;
/// ```
pub struct SearchRequestBuilder {
    queries: Vec<SearchQuery>,
}

impl SearchRequestBuilder {
    /// Create a new request builder
    pub fn new() -> Self {
        Self {
            queries: Vec::new(),
        }
    }

    /// Add a pre-built query
    pub fn add_query(mut self, query: SearchQuery) -> Self {
        self.queries.push(query);
        self
    }

    /// Add a query using a builder
    pub fn add_query_builder<F>(mut self, f: F) -> Self
    where
        F: FnOnce(SearchQueryBuilder) -> SearchQueryBuilder,
    {
        let builder = SearchQueryBuilder::new(SearchType::PublicSong);
        let builder = f(builder);
        if let Ok(query) = builder.build() {
            self.queries.push(query);
        }
        self
    }

    /// Convenience: add public song search
    pub fn public_song(self, term: impl Into<String>) -> Self {
        if let Ok(query) = SearchQuery::public_song(term).build() {
            self.add_query(query)
        } else {
            self
        }
    }

    /// Convenience: add library search
    pub fn library_song(self, term: impl Into<String>) -> Self {
        if let Ok(query) = SearchQuery::library_song(term).build() {
            self.add_query(query)
        } else {
            self
        }
    }

    /// Convenience: add similar songs search
    pub fn similar_to(self, song_id: impl Into<String>) -> Self {
        if let Ok(query) = SearchQuery::similar_to(song_id).build() {
            self.add_query(query)
        } else {
            self
        }
    }

    /// Convenience: add user search
    pub fn user(self, term: impl Into<String>) -> Self {
        if let Ok(query) = SearchQuery::user(term).build() {
            self.add_query(query)
        } else {
            self
        }
    }

    /// Convenience: add playlist search
    pub fn playlist(self, term: impl Into<String>) -> Self {
        if let Ok(query) = SearchQuery::playlist(term).build() {
            self.add_query(query)
        } else {
            self
        }
    }

    /// Build the request
    pub fn build(self) -> Result<SearchRequest, ValidationError> {
        if self.queries.is_empty() {
            return Err(ValidationError::EmptySearchRequest);
        }

        Ok(SearchRequest {
            search_queries: self.queries,
        })
    }
}

impl Default for SearchRequestBuilder {
    fn default() -> Self {
        Self::new()
    }
}

/// Request for user search endpoint
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserSearchRequest {
    pub term: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub boosted_user_handles: Option<Vec<String>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub excluded_user_handles: Option<Vec<String>>,
}

/// Request for hashtag search endpoint
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HashtagSearchRequest {
    pub term: String,
}

/// Request for lyrics search endpoint
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LyricsSearchRequest {
    pub lyrics: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub created_before: Option<String>,
}

// =============================================================================
// RESPONSE TYPES
// =============================================================================

/// Response from search endpoint (compound search)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResponse {
    pub result: BTreeMap<String, SearchResult>,
}

/// Individual search result for a named query
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
    pub total_hits: i32,

    pub result: Vec<SearchResultItem>,

    pub from_index: i32,

    pub page_size: i32,
}

/// Search result item (polymorphic)
///
/// Uses the `entity_type` field to determine which variant to deserialize:
/// - `"song_schema"` -> Song(SearchClip)
/// - `"playlist_schema"` -> Playlist(SearchPlaylist)
/// - `"simple_profile_schema"` -> Profile(SearchProfile)
/// - `"genre_schema"` -> Genre(GenreItem)
/// - `"persona_schema"` -> Persona(PersonaItem)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "entity_type")]
pub enum SearchResultItem {
    #[serde(rename = "song_schema")]
    Song(SearchClip),

    #[serde(rename = "playlist_schema")]
    Playlist(SearchPlaylist),

    #[serde(rename = "simple_profile_schema")]
    Profile(SearchProfile),

    #[serde(rename = "genre_schema")]
    Genre(GenreItem),

    #[serde(rename = "persona_schema")]
    Persona(PersonaItem),
}

/// Clip result in search
///
/// Note: The API response may contain additional fields that are not used by sunocore.
/// These are silently ignored during deserialization to maintain forward compatibility.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchClip {
    pub id: Uuid,

    #[serde(skip)]
    pub entity_type: String,

    pub title: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub status: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub audio_url: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub image_url: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub image_large_url: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub video_url: Option<String>,

    pub user_id: Uuid,

    pub handle: String,

    pub display_name: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub avatar_image_url: Option<String>,

    pub play_count: i32,

    pub upvote_count: i32,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub comment_count: Option<i32>,

    pub created_at: DateTime<Utc>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_liked: Option<bool>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_public: Option<bool>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_trashed: Option<bool>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata: Option<BTreeMap<String, serde_json::Value>>,
}

/// Playlist result in search
///
/// Note: The API response may contain additional fields that are not used by sunocore.
/// These are silently ignored during deserialization to maintain forward compatibility.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchPlaylist {
    pub id: String,

    #[serde(skip)]
    pub entity_type: String,

    pub name: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub image_url: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub cover_url: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub playlist_clips_count: Option<i32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub upvote_count: Option<i32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub play_count: Option<i32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub created_at: Option<DateTime<Utc>>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_liked: Option<bool>,
}

/// Profile result in search
///
/// Note: The API response may contain additional fields that are not used by sunocore.
/// These are silently ignored during deserialization to maintain forward compatibility.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchProfile {
    #[serde(deserialize_with = "deserialize_uuid_or_negative_one")]
    pub user_id: Uuid,

    #[serde(skip)]
    pub entity_type: String,

    pub handle: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub display_name: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub avatar_image_url: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub bio: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub follower_count: Option<i32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_followed: Option<bool>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_self: Option<bool>,
}

/// Genre item in search
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GenreItem {
    pub entity_type: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub image: Option<String>,

    pub genre: String,
}

/// Persona item in search
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PersonaItem {
    pub id: String,

    pub entity_type: String,

    pub name: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub image_url: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_public: Option<bool>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_loved: Option<bool>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub clip_count: Option<i32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub user_handle: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub user_display_name: Option<String>,
}

// =============================================================================
// HASHTAG RESPONSE
// =============================================================================

/// Response from hashtag search endpoints
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HashtagSearchResponse {
    pub hashtags: Vec<HashtagItem>,
}

/// Individual hashtag item
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HashtagItem {
    pub hashtag: String,

    pub count: i32,
}

/// Request payload for the trending hashtag endpoint.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HashtagTrendingRequest {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub days: Option<i32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub size: Option<i32>,
}

/// Item types returned by omnisearch sections.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OmnisearchResultType {
    #[serde(alias = "ItemTypeEnum.CLIP")]
    Clip,
    #[serde(alias = "ItemTypeEnum.PLAYLIST")]
    Playlist,
    #[serde(alias = "ItemTypeEnum.USER")]
    User,
    #[serde(alias = "ItemTypeEnum.PERSONA")]
    Persona,
    #[serde(alias = "ItemTypeEnum.GENRE")]
    Genre,
    #[serde(alias = "ItemTypeEnum.HOOK")]
    Hook,
}

/// Request payload for the omnisearch endpoint.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OmnisearchRequest {
    pub term: String,
}

/// Section returned by the omnisearch endpoint.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OmnisearchSection {
    pub title: String,
    pub result_type: OmnisearchResultType,
    pub result: Vec<SearchResultItem>,
    pub total_hits: i32,
}

/// Response payload for the omnisearch endpoint.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OmnisearchResponse {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub top_result: Option<SearchResultItem>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub top_result_type: Option<OmnisearchResultType>,

    pub sections: Vec<OmnisearchSection>,
}

// =============================================================================
// LYRICS SEARCH RESPONSE (uses generalized clip type)
// =============================================================================

pub type LyricsSearchResponse = Vec<SearchClip>;

// =============================================================================
// TESTS
// =============================================================================

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

    // =========================================================================
    // ValidationError Tests
    // =========================================================================

    #[test]
    fn test_validation_error_display() {
        let err = ValidationError::EmptyName;
        assert_eq!(err.to_string(), "Query name cannot be empty");

        let err = ValidationError::MissingRequiredField {
            field: "song_id".to_string(),
            search_type: "SimilarSong".to_string(),
        };
        assert!(err.to_string().contains("song_id"));
        assert!(err.to_string().contains("SimilarSong"));
    }

    // =========================================================================
    // SearchType Tests
    // =========================================================================

    #[test]
    fn test_search_type_as_str() {
        assert_eq!(SearchType::PublicSong.as_str(), "public_song");
        assert_eq!(SearchType::LibrarySong.as_str(), "library_song");
        assert_eq!(SearchType::SimilarSong.as_str(), "similar_song");
        assert_eq!(
            SearchType::TestLibrarySongDbQuery.as_str(),
            "test_library_song_db_query"
        );
        assert_eq!(SearchType::User.as_str(), "user");
        assert_eq!(SearchType::Playlist.as_str(), "playlist");
    }

    // =========================================================================
    // Pagination Tests
    // =========================================================================

    #[test]
    fn test_pagination_first_page() {
        let page = Pagination::first_page(20).unwrap();
        assert_eq!(page.from_index, 0);
        assert_eq!(page.size, 20);
    }

    #[test]
    fn test_pagination_next_page() {
        let page1 = Pagination::first_page(20).unwrap();
        let page2 = page1.next_page();
        assert_eq!(page2.from_index, 20);
        assert_eq!(page2.size, 20);
    }

    #[test]
    fn test_pagination_prev_page() {
        let page2 = Pagination::new(20, 20).unwrap();
        let page1 = page2.prev_page();
        assert_eq!(page1.from_index, 0);
        assert_eq!(page1.size, 20);
    }

    #[test]
    fn test_pagination_prev_page_clamps_to_zero() {
        let page = Pagination::new(5, 20).unwrap();
        let prev = page.prev_page();
        assert_eq!(prev.from_index, 0);
    }

    #[test]
    fn test_pagination_by_page_number() {
        let page1 = Pagination::page(1, 20).unwrap();
        assert_eq!(page1.from_index, 0);

        let page2 = Pagination::page(2, 20).unwrap();
        assert_eq!(page2.from_index, 20);

        let page3 = Pagination::page(3, 20).unwrap();
        assert_eq!(page3.from_index, 40);
    }

    #[test]
    fn test_pagination_invalid_size_too_large() {
        let result = Pagination::new(0, 101);
        assert!(matches!(result, Err(ValidationError::InvalidSize { .. })));
    }

    #[test]
    fn test_pagination_invalid_size_zero() {
        let result = Pagination::new(0, 0);
        assert!(matches!(result, Err(ValidationError::InvalidSize { .. })));
    }

    #[test]
    fn test_pagination_default() {
        let page = Pagination::default();
        assert_eq!(page.from_index, 0);
        assert_eq!(page.size, 20);
    }

    // =========================================================================
    // SearchFiltersBuilder Tests
    // =========================================================================

    #[test]
    fn test_filters_builder_full_song() {
        let filters = SearchFilters::builder().full_song().build();
        assert_eq!(filters.is_full_song, Some(true));
    }

    #[test]
    fn test_filters_builder_no_covers() {
        let filters = SearchFilters::builder().no_covers().build();
        assert_eq!(filters.is_cover, Some(false));
    }

    #[test]
    fn test_filters_builder_combination() {
        let filters = SearchFilters::builder()
            .full_song()
            .no_covers()
            .is_extend(true)
            .build();

        assert_eq!(filters.is_full_song, Some(true));
        assert_eq!(filters.is_cover, Some(false));
        assert_eq!(filters.is_extend, Some(true));
    }

    #[test]
    fn test_filters_builder_default() {
        let builder = SearchFiltersBuilder::default();
        let filters = builder.build();
        assert!(filters.is_full_song.is_none());
        assert!(filters.is_cover.is_none());
    }

    // =========================================================================
    // SearchQueryBuilder Tests
    // =========================================================================

    #[test]
    fn test_query_builder_auto_name() {
        let query = SearchQuery::public_song("rock")
            .term("rock music")
            .build()
            .unwrap();

        // Name should be auto-generated: "public_song" + "rock music"
        assert_eq!(query.name, "public_songrock music");
    }

    #[test]
    fn test_query_builder_manual_name_override() {
        let query = SearchQuery::public_song("rock")
            .name("my_custom_name")
            .build()
            .unwrap();

        assert_eq!(query.name, "my_custom_name");
    }

    #[test]
    fn test_query_builder_public_song() {
        let query = SearchQuery::public_song("jazz")
            .rank_by("trending")
            .size(30)
            .build()
            .unwrap();

        assert_eq!(query.search_type, SearchType::PublicSong);
        assert_eq!(query.term, Some("jazz".to_string()));
        assert_eq!(query.rank_by, Some("trending".to_string()));
        assert_eq!(query.size, 30);
    }

    #[test]
    fn test_query_builder_library_song() {
        let query = SearchQuery::library_song("ambient").build().unwrap();

        assert_eq!(query.search_type, SearchType::LibrarySong);
        assert_eq!(query.term, Some("ambient".to_string()));
        assert_eq!(query.rank_by, Some("most_recent".to_string()));
    }

    #[test]
    fn test_query_builder_similar_to() {
        let song_id = "uuid-123";
        let query = SearchQuery::similar_to(song_id).build().unwrap();

        assert_eq!(query.search_type, SearchType::SimilarSong);
        assert_eq!(query.song_id, Some(song_id.to_string()));
    }

    #[test]
    fn test_query_builder_user() {
        let query = SearchQuery::user("artist_name").build().unwrap();

        assert_eq!(query.search_type, SearchType::User);
        assert_eq!(query.term, Some("artist_name".to_string()));
    }

    #[test]
    fn test_query_builder_with_pagination() {
        let pagination = Pagination::first_page(50).unwrap();
        let query = SearchQuery::public_song("jazz")
            .pagination(pagination)
            .build()
            .unwrap();

        assert_eq!(query.from_index, 0);
        assert_eq!(query.size, 50);
    }

    #[test]
    fn test_query_builder_with_filters() {
        let filters = SearchFilters::builder().full_song().no_covers().build();
        let query = SearchQuery::public_song("ambient")
            .filters(filters)
            .build()
            .unwrap();

        assert!(query.filters.is_some());
        let f = query.filters.unwrap();
        assert_eq!(f.is_full_song, Some(true));
        assert_eq!(f.is_cover, Some(false));
    }

    #[test]
    fn test_query_builder_validation_missing_song_id() {
        let result = SearchQueryBuilder::new(SearchType::SimilarSong)
            .term("test")
            .build();

        assert!(matches!(
            result,
            Err(ValidationError::MissingRequiredField { .. })
        ));
    }

    #[test]
    fn test_query_builder_validation_missing_term() {
        let result = SearchQueryBuilder::new(SearchType::PublicSong).build();

        assert!(matches!(
            result,
            Err(ValidationError::MissingRequiredField { .. })
        ));
    }

    #[test]
    fn test_query_builder_validation_invalid_size() {
        let result = SearchQueryBuilder::new(SearchType::PublicSong)
            .term("test")
            .size(101)
            .build();

        assert!(matches!(result, Err(ValidationError::InvalidSize { .. })));
    }

    #[test]
    fn test_query_builder_all_fields() {
        let query = SearchQueryBuilder::new(SearchType::PublicSong)
            .term("test")
            .item_type("clip")
            .genre("pop")
            .user_id(123)
            .project_id("proj-1")
            .is_public(true)
            .is_liked(false)
            .is_instrumental(true)
            .rank_by("trending")
            .order("desc")
            .from_index(0)
            .size(20)
            .model_version("v4.5")
            .build()
            .unwrap();

        assert_eq!(query.item_type, Some("clip".to_string()));
        assert_eq!(query.genre, Some("pop".to_string()));
        assert_eq!(query.user_id, Some(123));
        assert_eq!(query.is_public, Some(true));
        assert_eq!(query.is_instrumental, Some(true));
        assert_eq!(query.rank_by, Some("trending".to_string()));
        assert_eq!(query.order, Some("desc".to_string()));
    }

    // =========================================================================
    // SearchRequestBuilder Tests
    // =========================================================================

    #[test]
    fn test_request_builder_simple() {
        let request = SearchRequest::simple_public_song("ambient").unwrap();
        assert_eq!(request.search_queries.len(), 1);
        assert_eq!(
            request.search_queries[0].search_type,
            SearchType::PublicSong
        );
    }

    #[test]
    fn test_request_builder_multiple_queries() {
        let request = SearchRequest::builder()
            .public_song("jazz")
            .user("artist")
            .playlist("chill")
            .build()
            .unwrap();

        assert_eq!(request.search_queries.len(), 3);
        assert_eq!(
            request.search_queries[0].search_type,
            SearchType::PublicSong
        );
        assert_eq!(request.search_queries[1].search_type, SearchType::User);
        assert_eq!(request.search_queries[2].search_type, SearchType::Playlist);
    }

    #[test]
    fn test_request_builder_empty_fails() {
        let result = SearchRequest::builder().build();
        assert!(matches!(result, Err(ValidationError::EmptySearchRequest)));
    }

    #[test]
    fn test_request_builder_add_query() {
        let query = SearchQuery::public_song("test").build().unwrap();
        let request = SearchRequest::builder().add_query(query).build().unwrap();

        assert_eq!(request.search_queries.len(), 1);
    }

    #[test]
    fn test_request_builder_compound_search() {
        let request = SearchRequest::builder()
            .public_song("lo-fi")
            .similar_to("song-123")
            .user("producer")
            .build()
            .unwrap();

        assert_eq!(request.search_queries.len(), 3);
        // Verify the names are auto-generated
        assert!(request.search_queries[0].name.starts_with("public_song"));
        assert!(request.search_queries[1].name.starts_with("similar_song"));
        assert!(request.search_queries[2].name.starts_with("user"));
    }

    // =========================================================================
    // Serialization Tests
    // =========================================================================

    #[test]
    fn test_search_query_serialization() {
        let query = SearchQuery::public_song("rock")
            .rank_by("trending")
            .build()
            .unwrap();

        let json = serde_json::to_string(&query).unwrap();
        assert!(json.contains("public_songrock")); // auto-generated name
        assert!(json.contains("public_song"));
        assert!(json.contains("rock"));
        assert!(json.contains("trending"));
    }

    #[test]
    fn test_search_request_serialization() {
        let request = SearchRequest::simple_public_song("ambient").unwrap();
        let json = serde_json::to_string(&request).unwrap();
        assert!(json.contains("search_queries"));
        assert!(json.contains("ambient"));
    }

    #[test]
    fn test_search_query_skips_none_fields() {
        let query = SearchQuery::public_song("test").build().unwrap();
        let json = serde_json::to_string(&query).unwrap();
        // None fields should be skipped due to #[serde(skip_serializing_if = "Option::is_none")]
        assert!(!json.contains("\"genre\":null"));
        assert!(!json.contains("\"vector\":null"));
    }
}
