//! Music source implementation for Qobuz
//!
//! This module implements the [`pmosource::MusicSource`] trait for Qobuz,
//! providing a complete music catalog browsing and searching experience.

use crate::client::QobuzClient;
use crate::didl::{format_duration, ToDIDL};
use crate::lazy_provider::QobuzLazyProvider;
use crate::models::Track;
use pmoaudiocache::{AudioMetadata, Cache as AudioCache};
use pmocovers::Cache as CoverCache;
use pmodidl::{Container, Item};
use pmosource::SourceCacheManager;
use pmosource::{async_trait, BrowseResult, MediaSearchType, MusicSource, MusicSourceError, Result, SearchQuery, SearchScope};
use serde_json::json;
use std::sync::Arc;
use std::time::SystemTime;

/// TTL pour les playlists d'albums (7 jours)

/// Trait pour les types dont on peut cacher la cover image.
trait CoverCacheable {
    fn image_url(&self) -> Option<&str>;
    fn set_image_cached(&mut self, url: String);
}

impl CoverCacheable for crate::models::Album {
    fn image_url(&self) -> Option<&str> { self.image.as_deref() }
    fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); }
}

impl CoverCacheable for crate::models::Playlist {
    fn image_url(&self) -> Option<&str> { self.image.as_deref() }
    fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); }
}

impl CoverCacheable for crate::models::Artist {
    fn image_url(&self) -> Option<&str> { self.image.as_deref() }
    fn set_image_cached(&mut self, url: String) { self.image_cached = Some(url); }
}

/// Pour Track, la cover est celle de l'album.
impl CoverCacheable for crate::models::Track {
    fn image_url(&self) -> Option<&str> {
        self.album.as_ref()?.image.as_deref()
    }
    fn set_image_cached(&mut self, url: String) {
        if let Some(ref mut album) = self.album {
            album.image_cached = Some(url);
        }
    }
}

/// Default image for Qobuz (300x300 WebP, embedded in binary)
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");

/// Qobuz music source with full MusicSource trait implementation
///
/// This struct combines a [`QobuzClient`] for API access with browsing and
/// navigation capabilities, implementing the complete [`MusicSource`] trait.
///
/// # Features
///
/// - **Catalog Navigation**: Browse albums, artists, playlists, favorites
/// - **Search**: Full-text search across the Qobuz catalog
/// - **URI Resolution**: Resolves track streaming URIs with authentication
/// - **DIDL-Lite Export**: Converts albums, tracks, and playlists to UPnP formats
/// - **Caching**: Integrated with QobuzClient's cache for performance
///
/// # Architecture
///
/// Unlike streaming sources like Radio Paradise, Qobuz is a catalog-based source:
/// - Root container has multiple sub-containers (Albums, Artists, Favorites, etc.)
/// - No FIFO support (it's a static catalog, not a dynamic stream)
/// - Hierarchical browsing: Root → Category → Albums → Tracks
///
/// # Examples
///
/// ```no_run
/// use std::sync::Arc;
/// use pmoaudiocache::cache as audio_cache;
/// use pmocovers::cache as cover_cache;
/// use pmoqobuz::{QobuzSource, QobuzClient};
/// use pmosource::MusicSource;
///
/// #[tokio::main]
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
///     let client = QobuzClient::from_config().await?;
///     let cover_cache = Arc::new(cover_cache::new_cache("/tmp/qobuz_covers", 256)?);
///     let audio_cache = Arc::new(audio_cache::new_cache("/tmp/qobuz_audio", 64)?);
///     let source = QobuzSource::new(client, cover_cache, audio_cache, "http://localhost:8080");
///
///     println!("Source: {}", source.name());
///     println!("Supports FIFO: {}", source.supports_fifo());
///
///     // Browse root container
///     let root = source.root_container().await?;
///     println!("Root: {} with {} children", root.title, root.child_count.unwrap_or_default());
///
///     Ok(())
/// }
/// ```
#[derive(Clone)]
pub struct QobuzSource {
    inner: Arc<QobuzSourceInner>,
}

struct QobuzSourceInner {
    /// Qobuz API client
    client: Arc<QobuzClient>,

    /// Cache manager (centralisé)
    cache_manager: SourceCacheManager,

    /// Base URL for streaming server (e.g., "http://192.168.0.138:8080")
    base_url: String,

    /// Nombre de workers concurrents pour register_tracks_lazy (configurable)
    register_concurrency: usize,

    /// Update tracking
    update_counter: tokio::sync::RwLock<u32>,
    last_change: tokio::sync::RwLock<SystemTime>,
}

impl std::fmt::Debug for QobuzSource {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("QobuzSource").finish()
    }
}

impl QobuzSource {
    /// Create a new Qobuz source from the cache registry
    ///
    /// This is the recommended way to create a source when using the UPnP server.
    /// The caches are automatically retrieved from the global registry.
    ///
    /// # Arguments
    ///
    /// * `client` - Authenticated Qobuz API client
    /// * `base_url` - Base URL for streaming server (e.g., "http://192.168.0.138:8080")
    ///
    /// # Errors
    ///
    /// Returns an error if the caches are not initialized in the registry
    #[cfg(feature = "server")]
    pub fn from_registry(client: QobuzClient, base_url: impl Into<String>) -> Result<Self> {
        use crate::config_ext::QobuzConfigExt;
        let cache_manager = SourceCacheManager::from_registry("qobuz".to_string())?;
        let client = Arc::new(client);
        cache_manager.register_lazy_provider(Arc::new(QobuzLazyProvider::new(client.clone())));
        let register_concurrency = pmoconfig::get_config().get_qobuz_register_concurrency();

        Ok(Self {
            inner: Arc::new(QobuzSourceInner {
                client,
                cache_manager,
                base_url: base_url.into(),
                register_concurrency,
                update_counter: tokio::sync::RwLock::new(0),
                last_change: tokio::sync::RwLock::new(SystemTime::now()),
            }),
        })
    }

    /// Create a new Qobuz source with explicit caches (for tests)
    ///
    /// # Arguments
    ///
    /// * `client` - Authenticated Qobuz API client
    /// * `cover_cache` - Cover image cache (required)
    /// * `audio_cache` - Audio cache (required)
    /// * `base_url` - Base URL for streaming server (e.g., "http://192.168.0.138:8080")
    pub fn new(
        client: QobuzClient,
        cover_cache: Arc<CoverCache>,
        audio_cache: Arc<AudioCache>,
        base_url: impl Into<String>,
    ) -> Self {
        let cache_manager = SourceCacheManager::new("qobuz".to_string(), cover_cache, audio_cache);
        let client = Arc::new(client);
        cache_manager.register_lazy_provider(Arc::new(QobuzLazyProvider::new(client.clone())));

        Self {
            inner: Arc::new(QobuzSourceInner {
                client,
                cache_manager,
                base_url: base_url.into(),
                register_concurrency: 4,
                update_counter: tokio::sync::RwLock::new(0),
                last_change: tokio::sync::RwLock::new(SystemTime::now()),
            }),
        }
    }

    /// Get the Qobuz client
    pub fn client(&self) -> &QobuzClient {
        &self.inner.client
    }

    /// Add a track from Qobuz with caching
    ///
    /// This method downloads and caches both cover art and audio data.
    pub async fn add_track(&self, track: &Track) -> Result<String> {
        let track_id = format!("qobuz://track/{}", track.id);

        // Get streaming URL
        let stream_url = self
            .inner
            .client
            .get_stream_url(&track.id)
            .await
            .map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))?;

        // 1. Cache cover via manager
        let cached_cover_pk = if let Some(ref album) = track.album {
            if let Some(ref image_url) = album.image {
                self.inner.cache_manager.cache_cover(image_url).await.ok()
            } else {
                None
            }
        } else {
            None
        };

        // 2. Prepare rich metadata from Qobuz track
        let metadata = AudioMetadata {
            title: Some(track.title.clone()),
            artist: track.performer.as_ref().map(|p| p.name.clone()),
            album: track.album.as_ref().map(|a| a.title.clone()),
            duration_secs: Some(track.duration as u64),
            year: track.album.as_ref().and_then(|a| {
                a.release_date
                    .as_ref()
                    .and_then(|d| d.split('-').next()?.parse().ok())
            }),
            track_number: Some(track.track_number),
            track_total: track.album.as_ref().and_then(|a| a.tracks_count),
            disc_number: Some(track.media_number),
            disc_total: None,
            genre: track.album.as_ref().and_then(|a| {
                if !a.genres.is_empty() {
                    Some(a.genres.join(", "))
                } else {
                    None
                }
            }),
            sample_rate: track.sample_rate,
            channels: track.channels,
            bitrate: None,
            conversion: None,
        };

        // 3. Cache audio via manager
        let cached_audio_pk = self
            .inner
            .cache_manager
            .cache_audio(&stream_url, Some(metadata))
            .await
            .ok();

        if let (Some(audio_pk), Some(cover_pk)) = (&cached_audio_pk, &cached_cover_pk) {
            let _ =
                self.inner
                    .cache_manager
                    .set_audio_metadata(audio_pk, "cover_pk", json!(cover_pk));
        }

        // 4. Store metadata
        self.inner
            .cache_manager
            .update_metadata(
                track_id.clone(),
                pmosource::TrackMetadata {
                    original_uri: stream_url,
                    cached_audio_pk,
                    cached_cover_pk,
                },
            )
            .await;

        Ok(track_id)
    }

    /// Add track with lazy audio caching (cover eager, audio lazy)
    ///
    /// This method caches cover art immediately (small, needed for UI) but
    /// defers audio download until the track is actually played.
    ///
    /// # Arguments
    ///
    /// * `track` - The Qobuz track to add
    ///
    /// # Returns
    ///
    /// `(track_id, lazy_pk)` where `track_id` is the logical Qobuz URI and
    /// `lazy_pk` the cache identifier stored in pmocache.
    pub async fn add_track_lazy(&self, track: &Track) -> Result<(String, String)> {
        let track_id = format!("qobuz://track/{}", track.id);

        let lazy_pk = format!("QOBUZ:{}", track.id);

        // Get streaming URL (for metadata fallback)
        let stream_url = self
            .inner
            .client
            .get_stream_url(&track.id)
            .await
            .map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))?;

        // 1. Cache cover EAGERLY (small, UI needs it)
        let cached_cover_pk = if let Some(ref album) = track.album {
            if let Some(ref image_url) = album.image {
                self.inner.cache_manager.cache_cover(image_url).await.ok()
            } else {
                None
            }
        } else {
            None
        };

        // 2. Prepare rich metadata from Qobuz track
        let metadata = AudioMetadata {
            title: Some(track.title.clone()),
            artist: track.performer.as_ref().map(|p| p.name.clone()),
            album: track.album.as_ref().map(|a| a.title.clone()),
            duration_secs: Some(track.duration as u64),
            year: track.album.as_ref().and_then(|a| {
                a.release_date
                    .as_ref()
                    .and_then(|d| d.split('-').next()?.parse().ok())
            }),
            track_number: Some(track.track_number),
            track_total: track.album.as_ref().and_then(|a| a.tracks_count),
            disc_number: Some(track.media_number),
            disc_total: None,
            genre: track.album.as_ref().and_then(|a| {
                if !a.genres.is_empty() {
                    Some(a.genres.join(", "))
                } else {
                    None
                }
            }),
            sample_rate: track.sample_rate,
            channels: track.channels,
            bitrate: None,
            conversion: None,
        };

        // 3. Cache audio LAZILY avec un provider
        let cached_audio_pk = self
            .inner
            .cache_manager
            .cache_audio_lazy_with_provider(
                &lazy_pk,
                Some(metadata.clone()),
                cached_cover_pk.clone(),
            )
            .await
            .map_err(|e| {
                MusicSourceError::CacheError(format!(
                    "Failed to register lazy track {}: {}",
                    track.title, e
                ))
            })?;

        if let Some(ref cover_pk) = cached_cover_pk {
            let _ = self.inner.cache_manager.set_audio_metadata(
                &cached_audio_pk,
                "cover_pk",
                json!(cover_pk),
            );
        }

        // Stocker le track_id Qobuz pour reconstruction DIDL ultérieure
        let _ = self.inner.cache_manager.set_audio_metadata(
            &cached_audio_pk,
            "qobuz_track_id",
            json!(track.id),
        );

        // 4. Store metadata
        self.inner
            .cache_manager
            .update_metadata(
                track_id.clone(),
                pmosource::TrackMetadata {
                    original_uri: stream_url,
                    cached_audio_pk: Some(cached_audio_pk.clone()),
                    cached_cover_pk,
                },
            )
            .await;

        Ok((track_id, cached_audio_pk))
    }

    /// Load full album into pmoplaylist with lazy audio
    ///
    /// This method fetches all tracks from a Qobuz album and adds them to a playlist
    /// with lazy audio loading. Covers are downloaded eagerly, audio lazily.
    ///
    /// # Arguments
    ///
    /// * `playlist_id` - ID of the target playlist
    /// * `album_id` - Qobuz album ID
    ///
    /// # Returns
    ///
    /// Number of tracks successfully added
    pub async fn add_album_to_playlist(&self, playlist_id: &str, album_id: &str) -> Result<usize> {
        use tracing::{debug, info, warn};

        // 1. Get tracks from Qobuz (goes through rate limiter)
        let tracks = self
            .inner
            .client
            .get_album_tracks(album_id)
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

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

        info!(
            "Adding album {} ({} tracks) to playlist {} with lazy audio",
            album_id,
            tracks.len(),
            playlist_id
        );

        // 2. Add each track lazily + collect lazy PKs
        let mut lazy_pks = Vec::with_capacity(tracks.len());

        for (i, track) in tracks.iter().enumerate() {
            match self.add_track_lazy(track).await {
                Ok((_track_id, lazy_pk)) => {
                    debug!(
                        "Track {}/{}: {} (lazy pk {})",
                        i + 1,
                        tracks.len(),
                        track.title,
                        &lazy_pk
                    );
                    lazy_pks.push(lazy_pk);
                }
                Err(e) => {
                    warn!("Failed to add track {} ({}): {}", i + 1, track.title, e);
                    // Continue with other tracks
                }
            }
        }

        // 3. Batch insert into playlist (single DB transaction)
        let playlist_manager = pmoplaylist::PlaylistManager();
        let writer = playlist_manager
            .get_persistent_write_handle(playlist_id.to_string())
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;

        writer
            .set_role(pmoplaylist::PlaylistRole::Album)
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;

        writer
            .push_lazy_batch(lazy_pks.clone())
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;

        // 4. Enable lazy mode with lookahead of 2 tracks
        playlist_manager.enable_lazy_mode(playlist_id, 2);

        info!(
            "Album {} added: {}/{} tracks",
            album_id,
            lazy_pks.len(),
            tracks.len()
        );

        Ok(lazy_pks.len())
    }

    /// Load Qobuz playlist into pmoplaylist with lazy audio
    ///
    /// This method fetches all tracks from a Qobuz playlist and adds them to a pmoplaylist
    /// with lazy audio loading. Covers are downloaded eagerly, audio lazily.
    ///
    /// # Arguments
    ///
    /// * `playlist_id` - ID of the target pmoplaylist
    /// * `qobuz_playlist_id` - Qobuz playlist ID
    ///
    /// # Returns
    ///
    /// Number of tracks successfully added
    pub async fn add_qobuz_playlist_to_playlist(
        &self,
        playlist_id: &str,
        qobuz_playlist_id: &str,
    ) -> Result<usize> {
        use tracing::{debug, info, warn};

        // 1. Get tracks from Qobuz playlist (goes through rate limiter)
        let tracks = self
            .inner
            .client
            .get_playlist_tracks(qobuz_playlist_id)
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

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

        info!(
            "Adding Qobuz playlist {} ({} tracks) to pmoplaylist {} with lazy audio",
            qobuz_playlist_id,
            tracks.len(),
            playlist_id
        );

        // 2. Add each track lazily + collect lazy PKs
        let mut lazy_pks = Vec::with_capacity(tracks.len());

        for (i, track) in tracks.iter().enumerate() {
            match self.add_track_lazy(track).await {
                Ok((_track_id, lazy_pk)) => {
                    debug!(
                        "Track {}/{}: {} (lazy pk {})",
                        i + 1,
                        tracks.len(),
                        track.title,
                        &lazy_pk
                    );
                    lazy_pks.push(lazy_pk);
                }
                Err(e) => {
                    warn!("Failed to add track {} ({}): {}", i + 1, track.title, e);
                    // Continue with other tracks
                }
            }
        }

        // 3. Batch insert into playlist (single DB transaction)
        let playlist_manager = pmoplaylist::PlaylistManager();
        let writer = playlist_manager
            .get_persistent_write_handle(playlist_id.to_string())
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;

        writer
            .push_lazy_batch(lazy_pks.clone())
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;

        // 4. Enable lazy mode with lookahead of 2 tracks
        playlist_manager.enable_lazy_mode(playlist_id, 2);

        info!(
            "Qobuz playlist {} added: {}/{} tracks",
            qobuz_playlist_id,
            lazy_pks.len(),
            tracks.len()
        );

        Ok(lazy_pks.len())
    }

    /// Vérifie si une playlist d'album existe et est valide (non expirée ET non vide)
    /// Retourne la source_version d'une pmoplaylist si elle existe en mémoire ou en DB.
    async fn cached_source_version(&self, playlist_id: &str) -> Option<String> {
        let playlist_manager = pmoplaylist::PlaylistManager();
        if !playlist_manager.exists(playlist_id).await {
            return None;
        }
        playlist_manager
            .get_read_handle(playlist_id)
            .await
            .ok()?
            .source_version()
            .await
    }

    /// Adapte des Items DIDL issus d'une pmoplaylist pour le schéma UPnP Qobuz.
    ///
    /// - Résout l'ID de la track (`qobuz:track:<id>`) depuis les métadonnées cache
    /// - Rend absolues les URLs relatives (resource + cover)
    /// - Affecte le `parent_id` fourni
    async fn adapt_items_to_qobuz(
        &self,
        items: Vec<Item>,
        parent_id: &str,
    ) -> Result<Vec<Item>> {
        use tracing::warn;

        let mut adapted = Vec::with_capacity(items.len());

        for mut item in items {
            let cache_pk = item
                .resources
                .first()
                .and_then(|r| r.url.strip_prefix("/audio/flac/").map(|s| s.to_string()));

            if let Some(pk) = cache_pk {
                if let Ok(Some(track_id_value)) = self
                    .inner
                    .cache_manager
                    .get_audio_metadata(&pk, "qobuz_track_id")
                {
                    if let Some(track_id) = track_id_value.as_str() {
                        item.id = format!("qobuz:track:{}", track_id);
                    } else {
                        warn!("qobuz_track_id not a string for {}", pk);
                    }
                } else {
                    warn!("No qobuz_track_id metadata for {}", pk);
                }

                if let Some(resource) = item.resources.first_mut() {
                    if resource.url.starts_with('/') {
                        resource.url = format!("{}{}", self.inner.base_url, resource.url);
                    }
                }
            }

            if let Some(art) = item.album_art.as_mut() {
                if art.starts_with('/') {
                    *art = format!("{}{}", self.inner.base_url, art);
                }
            }

            item.parent_id = parent_id.to_string();
            adapted.push(item);
        }

        Ok(adapted)
    }

    /// Ouvre un WriteHandle sur une pmoplaylist persistante, en la créant si absente
    /// ou en la vidant (flush) si elle existe déjà.
    async fn get_or_flush_playlist_writer(
        &self,
        playlist_id: &str,
        role: pmoplaylist::PlaylistRole,
    ) -> Result<pmoplaylist::WriteHandle> {
        let playlist_manager = pmoplaylist::PlaylistManager();

        if playlist_manager.exists(playlist_id).await {
            let writer = playlist_manager
                .get_persistent_write_handle(playlist_id.to_string())
                .await
                .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
            writer
                .flush()
                .await
                .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
            Ok(writer)
        } else {
            playlist_manager
                .create_persistent_playlist_with_role(playlist_id.to_string(), role)
                .await
                .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))
        }
    }

    /// Met en cache la cover si l'URL est présente.
    async fn cache_cover_opt(&self, image_url: Option<&str>) -> Option<String> {
        self.inner
            .cache_manager
            .cache_cover(image_url?)
            .await
            .ok()
    }

    /// Récupère ou crée une playlist lazy pour un album, avec invalidation par version.
    ///
    /// `source_version` = `"{released_at}_{tracks_count}"` — change uniquement en cas de
    /// réédition avec pistes supplémentaires. Cache valide à vie si la version correspond.
    async fn get_or_create_album_playlist_items(
        &self,
        album_id: &str,
        limit: usize,
    ) -> Result<Vec<Item>> {
        use tracing::{debug, info};

        let playlist_id = format!("qobuz-album-{}", album_id);
        let playlist_manager = pmoplaylist::PlaylistManager();
        let parent_id = format!("qobuz:album:{}", album_id);

        let cached_version = self.cached_source_version(&playlist_id).await;

        // Charger les métadonnées album (contient déjà les tracks)
        let album = self
            .inner
            .client
            .get_album(album_id)
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

        let album_version = Some(format!(
            "{}_{}",
            album.released_at.unwrap_or(0),
            album.tracks_count.unwrap_or(0)
        ));

        if cached_version.is_some() && cached_version == album_version {
            debug!("Album playlist {} cache valid (version {:?})", playlist_id, album_version);
            let reader = playlist_manager
                .get_read_handle(&playlist_id)
                .await
                .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
            let items = reader
                .to_items(limit)
                .await
                .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
            return self.adapt_items_to_qobuz(items, &parent_id).await;
        }

        info!("Album playlist {} creating/refreshing (version {:?})", playlist_id, album_version);

        let cover_pk = self.cache_cover_opt(album.image.as_deref()).await;

        let writer = self
            .get_or_flush_playlist_writer(&playlist_id, pmoplaylist::PlaylistRole::Album)
            .await?;

        writer
            .set_title(album.title.clone())
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
        writer
            .set_artist(Some(album.artist.name.clone()))
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
        writer
            .set_source(Some("qobuz".to_string()))
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
        writer
            .set_source_version(album_version)
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
        if let Some(pk) = cover_pk {
            writer
                .set_cover_pk(Some(pk))
                .await
                .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
        }

        // IMPORTANT: Libérer le write lock avant d'appeler add_album_to_playlist
        drop(writer);

        self.add_album_to_playlist(&playlist_id, album_id).await?;

        let reader = playlist_manager
            .get_read_handle(&playlist_id)
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
        let items = reader
            .to_items(limit)
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;

        self.adapt_items_to_qobuz(items, &parent_id).await
    }

    /// Récupère ou crée une pmoplaylist pour une playlist Qobuz, avec invalidation par updated_at.
    ///
    /// - Si la playlist est en cache et que `updated_at` n'a pas changé → retourne le cache.
    /// - Sinon → recharge toutes les tracks depuis Qobuz et reconstruit la pmoplaylist.
    async fn get_or_create_qobuz_playlist_items(
        &self,
        qobuz_playlist_id: &str,
    ) -> Result<Vec<Item>> {
        use tracing::{debug, info, warn};

        let pmo_playlist_id = format!("qobuz-playlist-{}", qobuz_playlist_id);
        let playlist_manager = pmoplaylist::PlaylistManager();

        let cached_version = self.cached_source_version(&pmo_playlist_id).await;

        // Obtenir les métadonnées depuis Qobuz pour comparer updated_at
        let qobuz_meta: crate::models::Playlist = self
            .inner
            .client
            .get_playlist(qobuz_playlist_id)
            .await
            .map_err(|e: crate::error::QobuzError| MusicSourceError::BrowseError(e.to_string()))?;

        let qobuz_version = qobuz_meta.updated_at.map(|t: i64| t.to_string());

        // Cache valide si version correspond
        let cache_valid = cached_version.is_some() && cached_version == qobuz_version;

        let parent_id = format!("qobuz:playlist:{}", qobuz_playlist_id);

        if cache_valid {
            debug!("Qobuz playlist {} cache valid (version {:?})", pmo_playlist_id, qobuz_version);
            let reader = playlist_manager
                .get_read_handle(&pmo_playlist_id)
                .await
                .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
            let (items, total) = reader
                .to_items_paged(0, usize::MAX)
                .await
                .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
            // Si des PKs ne sont pas enregistrés dans le cache audio (e.g. playlist créée
            // avec l'ancien code), on force un refresh pour les ré-enregistrer proprement.
            if items.len() == total {
                return self.adapt_items_to_qobuz(items, &parent_id).await;
            }
            info!(
                "Qobuz playlist {} has {}/{} valid items, forcing refresh to repair missing entries",
                pmo_playlist_id, items.len(), total
            );
        }

        info!("Qobuz playlist {} creating/refreshing (version {:?})", pmo_playlist_id, qobuz_version);

        let tracks = self
            .inner
            .client
            .get_playlist_tracks(qobuz_playlist_id)
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

        let cover_pk = self.cache_cover_opt(qobuz_meta.image.as_deref()).await;

        let writer = self
            .get_or_flush_playlist_writer(&pmo_playlist_id, pmoplaylist::PlaylistRole::Source)
            .await?;

        writer
            .set_title(qobuz_meta.name.clone())
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
        writer
            .set_source(Some("qobuz".to_string()))
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
        writer
            .set_source_version(qobuz_version)
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
        if let Some(pk) = cover_pk {
            writer
                .set_cover_pk(Some(pk))
                .await
                .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
        }

        let lazy_pks = self.register_tracks_lazy(&tracks).await;
        let all_registered = lazy_pks.len() == tracks.len();
        if !all_registered {
            warn!(
                "Qobuz playlist {}: only {}/{} tracks registered, will not mark cache as complete",
                qobuz_playlist_id, lazy_pks.len(), tracks.len()
            );
            // Invalider la source_version pour forcer un refresh au prochain accès
            writer
                .set_source_version(None)
                .await
                .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
        }
        writer
            .push_lazy_batch(lazy_pks)
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
        drop(writer);

        let reader = playlist_manager
            .get_read_handle(&pmo_playlist_id)
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
        let (items, _total) = reader
            .to_items_paged(0, usize::MAX)
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;

        self.adapt_items_to_qobuz(items, &parent_id).await
    }

    /// Increment update counter (called on catalog changes)
    async fn increment_update_id(&self) {
        let mut counter = self.inner.update_counter.write().await;
        *counter = counter.wrapping_add(1);
        let mut last = self.inner.last_change.write().await;
        *last = SystemTime::now();
    }

    /// Construit le container Discover Catalog
    fn build_discover_catalog_container(&self) -> Container {
        Container {
            id: "qobuz:discover".to_string(),
            parent_id: "qobuz".to_string(),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Discover Catalog".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    /// Construit le container Discover Genres
    fn build_discover_genres_container(&self) -> Container {
        Container {
            id: "qobuz:genres".to_string(),
            parent_id: "qobuz".to_string(),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Discover Genres".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    /// Construit le container Favourites (My Music)
    fn build_favourites_container(&self) -> Container {
        Container {
            id: "qobuz:favorites".to_string(),
            parent_id: "qobuz".to_string(),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "My Music".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    /// Construit le container Albums favoris
    fn build_favourite_albums_container(&self) -> Container {
        Container {
            id: "qobuz:favorites:albums".to_string(),
            parent_id: "qobuz:favorites".to_string(),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Albums".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    /// Construit le container Tracks favoris
    fn build_favourite_tracks_container(&self) -> Container {
        Container {
            id: "qobuz:favorites:tracks".to_string(),
            parent_id: "qobuz:favorites".to_string(),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Tracks".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    /// Construit le container Artists favoris
    fn build_favourite_artists_container(&self) -> Container {
        Container {
            id: "qobuz:favorites:artists".to_string(),
            parent_id: "qobuz:favorites".to_string(),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Artists".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    /// Construit le container Playlists favoris
    fn build_favourite_playlists_container(&self) -> Container {
        Container {
            id: "qobuz:favorites:playlists".to_string(),
            parent_id: "qobuz:favorites".to_string(),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Playlists".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    /// Cache les covers d'une liste d'items en parallèle (générique via `CoverCacheable`).
    async fn cache_covers<T>(&self, items: Vec<T>) -> Vec<T>
    where
        T: CoverCacheable + Send + 'static,
    {
        let futs: Vec<_> = items.into_iter().map(|mut item| {
            let source = self.clone();
            async move {
                if let Some(image_url) = item.image_url().map(str::to_string) {
                    if let Ok(pk) = source.inner.cache_manager.cache_cover(&image_url).await {
                        if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) {
                            item.set_image_cached(url);
                        }
                    }
                }
                item
            }
        }).collect();
        tokio::task::JoinSet::from_iter(futs).join_all().await
    }

    async fn cache_album_covers(&self, albums: Vec<crate::models::Album>) -> Vec<crate::models::Album> {
        self.cache_covers(albums).await
    }

    async fn cache_playlist_covers(&self, playlists: Vec<crate::models::Playlist>) -> Vec<crate::models::Playlist> {
        self.cache_covers(playlists).await
    }

    /// Cache les covers d'une liste de tracks en parallèle (via l'image de l'album).
    async fn cache_track_covers(&self, tracks: Vec<crate::models::Track>) -> Vec<crate::models::Track> {
        let futs: Vec<_> = tracks.into_iter().map(|mut track| {
            let source = self.clone();
            async move {
                if let Some(ref mut album) = track.album {
                    if let Some(ref image_url) = album.image.clone() {
                        if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await {
                            if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) {
                                album.image_cached = Some(url);
                            }
                        }
                    }
                }
                track
            }
        }).collect();
        tokio::task::JoinSet::from_iter(futs)
            .join_all()
            .await
    }

    /// Enregistre une liste de tracks comme lazy entries dans l'audio cache (en parallèle).
    ///
    /// Contrairement à `add_track_lazy`, cette méthode ne fait PAS d'appel à `get_stream_url`
    /// (coûteux pour de grandes playlists). L'URL audio est résolue à la demande via
    /// `QobuzLazyProvider` lors de la première lecture.
    ///
    /// Pour chaque track : cache la cover, enregistre la lazy entry, stocke les métadonnées.
    /// Retourne la liste des lazy PKs enregistrés avec succès.
    async fn register_tracks_lazy(&self, tracks: &[crate::models::Track]) -> Vec<String> {
        // Configurable via accounts.qobuz.register_concurrency (défaut 4).
        // SQLite sérialise les écritures — au-delà de ~4 workers on accumule
        // des threads en attente du mutex DB sans gain de débit.
        let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(self.inner.register_concurrency));

        // On attache l'index original à chaque future pour pouvoir retrier dans l'ordre
        // d'origine après complétion parallèle (JoinSet retourne dans l'ordre de fin).
        let futs: Vec<_> = tracks.iter().enumerate().map(|(idx, track)| {
            let source = self.clone();
            let sem = sem.clone();
            let track_id = track.id.clone();
            let track_title = track.title.clone();
            let cover_image_url = track.album.as_ref().and_then(|a| a.image.clone());
            let metadata = pmoaudiocache::AudioMetadata {
                title: Some(track.title.clone()),
                artist: track.performer.as_ref().map(|p| p.name.clone()),
                album: track.album.as_ref().map(|a| a.title.clone()),
                duration_secs: Some(track.duration as u64),
                year: track.album.as_ref().and_then(|a| {
                    a.release_date.as_ref().and_then(|d| d.split('-').next()?.parse().ok())
                }),
                track_number: Some(track.track_number),
                track_total: track.album.as_ref().and_then(|a| a.tracks_count),
                disc_number: Some(track.media_number),
                disc_total: None,
                genre: track.album.as_ref().and_then(|a| {
                    if !a.genres.is_empty() { Some(a.genres.join(", ")) } else { None }
                }),
                sample_rate: track.sample_rate,
                channels: track.channels,
                bitrate: None,
                conversion: None,
            };
            async move {
                let _permit = sem.acquire().await.ok()?;
                let lazy_pk = format!("QOBUZ:{}", track_id);

                // 1. Cache cover eagerly
                let cover_pk = if let Some(ref url) = cover_image_url {
                    source.inner.cache_manager.cache_cover(url).await.ok()
                } else {
                    None
                };

                // 2. Register lazy entry + set cover_pk + seed metadata.
                // Si le performer est absent (réponse API incomplète), on passe None pour
                // que le provider appelle get_track et récupère les métadonnées complètes.
                if metadata.artist.is_none() {
                    tracing::warn!(
                        "register_tracks_lazy: no performer for track {} (id={}), will call provider",
                        track_title, track_id
                    );
                }
                if cover_pk.is_none() && cover_image_url.is_some() {
                    tracing::warn!(
                        "register_tracks_lazy: cover download failed for track {} (id={}), will call provider for cover",
                        track_title, track_id
                    );
                }
                let meta_hint = if metadata.artist.is_some() { Some(metadata) } else { None };
                match source.inner.cache_manager
                    .cache_audio_lazy_with_provider(&lazy_pk, meta_hint, cover_pk)
                    .await
                {
                    Ok(pk) => {
                        let _ = source.inner.cache_manager.set_audio_metadata(
                            &pk, "qobuz_track_id", json!(track_id),
                        );
                        Some((idx, pk))
                    }
                    Err(e) => {
                        tracing::warn!("Failed to register lazy track {}: {}", track_title, e);
                        None
                    }
                }
            }
        }).collect();

        // Retrier par index original pour conserver l'ordre de la playlist Qobuz
        let mut results: Vec<(usize, String)> = tokio::task::JoinSet::from_iter(futs)
            .join_all()
            .await
            .into_iter()
            .flatten()
            .collect();
        results.sort_unstable_by_key(|(i, _)| *i);
        results.into_iter().map(|(_, pk)| pk).collect()
    }

    /// Cache les covers d'une liste d'artistes en parallèle.
    async fn cache_artist_covers(&self, artists: Vec<crate::models::Artist>) -> Vec<crate::models::Artist> {
        let futs: Vec<_> = artists.into_iter().map(|mut artist| {
            let source = self.clone();
            async move {
                if let Some(ref image_url) = artist.image.clone() {
                    if let Ok(pk) = source.inner.cache_manager.cache_cover(image_url).await {
                        if let Ok(url) = source.inner.cache_manager.cover_url(&pk, None) {
                            artist.image_cached = Some(url);
                        }
                    }
                }
                artist
            }
        }).collect();
        tokio::task::JoinSet::from_iter(futs)
            .join_all()
            .await
    }

    /// Browse Favourites - retourne 4 sous-containers
    async fn browse_favourites(&self) -> Result<BrowseResult> {
        let containers = vec![
            self.build_favourite_albums_container(),
            self.build_favourite_tracks_container(),
            self.build_favourite_artists_container(),
            self.build_favourite_playlists_container(),
        ];
        Ok(BrowseResult::Containers(containers))
    }

    /// Browse Favourite Albums
    async fn browse_favourite_albums(&self) -> Result<BrowseResult> {
        let albums = self
            .inner
            .client
            .get_favorite_albums()
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

        let albums = self.cache_album_covers(albums).await;
        let containers: Vec<Container> = albums
            .into_iter()
            .filter_map(|album| album.to_didl_container("qobuz:favorites:albums").ok())
            .collect();

        Ok(BrowseResult::Containers(containers))
    }

    /// Browse Favourite Tracks
    async fn browse_favourite_tracks(&self) -> Result<BrowseResult> {
        let tracks = self
            .inner
            .client
            .get_favorite_tracks()
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

        let tracks = self.cache_covers(tracks).await;
        let items: Vec<Item> = tracks
            .into_iter()
            .filter_map(|track| track.to_didl_item("qobuz:favorites:tracks").ok())
            .collect();

        Ok(BrowseResult::Items(items))
    }

    /// Browse Favourite Artists
    async fn browse_favourite_artists(&self) -> Result<BrowseResult> {
        let artists = self
            .inner
            .client
            .get_favorite_artists()
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

        let artists = self.cache_covers(artists).await;
        let containers: Vec<Container> = artists
            .into_iter()
            .map(|artist| Container {
                id: format!("qobuz:artist:{}", artist.id),
                parent_id: "qobuz:favorites:artists".to_string(),
                restricted: Some("1".to_string()),
                child_count: None,
                searchable: Some("1".to_string()),
                title: artist.name.clone(),
                class: "object.container".to_string(),
                artist: Some(artist.name.clone()),
                album_art: artist.image_cached.clone(),
                containers: vec![],
                items: vec![],
            })
            .collect();

        Ok(BrowseResult::Containers(containers))
    }

    /// Browse Favourite Playlists
    async fn browse_favourite_playlists(&self) -> Result<BrowseResult> {
        let playlists = self
            .inner
            .client
            .get_user_playlists()
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

        let playlists = self.cache_playlist_covers(playlists).await;
        let containers: Vec<Container> = playlists
            .into_iter()
            .filter_map(|playlist| playlist.to_didl_container("qobuz:favorites:playlists").ok())
            .collect();

        Ok(BrowseResult::Containers(containers))
    }

    // ===== DISCOVER CATALOG =====

    /// Browse Discover Catalog - retourne 5 containers principaux + 10 playlists par tag
    async fn browse_discover_catalog(&self) -> Result<BrowseResult> {
        use crate::models::PlaylistTag;

        let mut containers = vec![
            self.build_discover_playlists_container(),
            self.build_discover_albums_ideal_container(),
            self.build_discover_albums_qobuzissime_container(),
            self.build_discover_albums_new_container(),
            self.build_discover_artists_container(),
        ];

        // Ajouter les 10 tags de playlists
        for tag in PlaylistTag::all() {
            containers.push(Container {
                id: format!("qobuz:discover:playlists:{}", tag.api_id()),
                parent_id: "qobuz:discover".to_string(),
                restricted: Some("1".to_string()),
                child_count: None,
                searchable: Some("1".to_string()),
                title: tag.display_name().to_string(),
                class: "object.container".to_string(),
                artist: None,
                album_art: None,
                containers: vec![],
                items: vec![],
            });
        }

        Ok(BrowseResult::Containers(containers))
    }

    fn build_discover_playlists_container(&self) -> Container {
        Container {
            id: "qobuz:discover:playlists".to_string(),
            parent_id: "qobuz:discover".to_string(),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Playlists".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    fn build_discover_albums_ideal_container(&self) -> Container {
        Container {
            id: "qobuz:discover:albums:ideal".to_string(),
            parent_id: "qobuz:discover".to_string(),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Albums (Ideal Discography)".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    fn build_discover_albums_qobuzissime_container(&self) -> Container {
        Container {
            id: "qobuz:discover:albums:qobuzissime".to_string(),
            parent_id: "qobuz:discover".to_string(),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Albums (Qobuzissime)".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    fn build_discover_albums_new_container(&self) -> Container {
        Container {
            id: "qobuz:discover:albums:new".to_string(),
            parent_id: "qobuz:discover".to_string(),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Albums (New Releases)".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    fn build_discover_artists_container(&self) -> Container {
        Container {
            id: "qobuz:discover:artists".to_string(),
            parent_id: "qobuz:discover".to_string(),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Artists".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    /// Helper : récupère des albums featured, cache les covers, retourne des containers DIDL
    async fn browse_featured_albums(&self, genre_id: Option<&str>, filter: &str, parent_id: &str) -> Result<BrowseResult> {
        let albums = self
            .inner
            .client
            .get_featured_albums(genre_id, filter)
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

        let albums = self.cache_album_covers(albums).await;
        let containers: Vec<Container> = albums
            .into_iter()
            .filter_map(|album| album.to_didl_container(parent_id).ok())
            .collect();

        Ok(BrowseResult::Containers(containers))
    }

    /// Helper : récupère des playlists featured, cache les covers, retourne des containers DIDL
    async fn browse_featured_playlists(&self, genre_id: Option<&str>, parent_id: &str) -> Result<BrowseResult> {
        let playlists = self
            .inner
            .client
            .get_featured_playlists(genre_id, None)
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

        let playlists = self.cache_playlist_covers(playlists).await;
        let containers: Vec<Container> = playlists
            .into_iter()
            .filter_map(|playlist| playlist.to_didl_container(parent_id).ok())
            .collect();

        Ok(BrowseResult::Containers(containers))
    }

    async fn browse_discover_playlists(&self) -> Result<BrowseResult> {
        self.browse_featured_playlists(None, "qobuz:discover:playlists").await
    }

    async fn browse_discover_albums_ideal(&self) -> Result<BrowseResult> {
        self.browse_featured_albums(None, "ideal-discography", "qobuz:discover:albums:ideal").await
    }

    async fn browse_discover_albums_qobuzissime(&self) -> Result<BrowseResult> {
        self.browse_featured_albums(None, "qobuzissims", "qobuz:discover:albums:qobuzissime").await
    }

    async fn browse_discover_albums_new(&self) -> Result<BrowseResult> {
        self.browse_featured_albums(None, "new-releases", "qobuz:discover:albums:new").await
    }

    /// Browse Discover Artists (Featured Artists)
    async fn browse_discover_artists(&self) -> Result<BrowseResult> {
        let artists = self
            .inner
            .client
            .get_featured_artists(None, None, None)
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

        let artists = self.cache_covers(artists).await;
        let containers: Vec<Container> = artists
            .into_iter()
            .map(|artist| Container {
                id: format!("qobuz:artist:{}", artist.id),
                parent_id: "qobuz:discover:artists".to_string(),
                restricted: Some("1".to_string()),
                child_count: None,
                searchable: Some("1".to_string()),
                title: artist.name.clone(),
                class: "object.container".to_string(),
                artist: Some(artist.name.clone()),
                album_art: artist.image_cached.clone(),
                containers: vec![],
                items: vec![],
            })
            .collect();

        Ok(BrowseResult::Containers(containers))
    }

    /// Browse Discover Playlists by tag
    async fn browse_discover_playlists_tag(&self, tag: &str) -> Result<BrowseResult> {
        let playlists = self
            .inner
            .client
            .get_featured_playlists(None, Some(tag))
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

        let playlists = self.cache_covers(playlists).await;
        let parent_id = format!("qobuz:discover:playlists:{}", tag);
        let containers: Vec<Container> = playlists
            .into_iter()
            .filter_map(|playlist| playlist.to_didl_container(&parent_id).ok())
            .collect();

        Ok(BrowseResult::Containers(containers))
    }

    // ===== DISCOVER GENRES =====

    /// Browse Discover Genres - liste des genres
    async fn browse_discover_genres(&self) -> Result<BrowseResult> {
        let genres = self
            .inner
            .client
            .get_genres()
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

        let containers: Vec<Container> = genres
            .into_iter()
            .filter_map(|genre| {
                // Filtrer les genres sans ID
                genre.id.map(|id| Container {
                    id: format!("qobuz:genre:{}", id),
                    parent_id: "qobuz:genres".to_string(),
                    restricted: Some("1".to_string()),
                    child_count: None,
                    searchable: Some("1".to_string()),
                    title: genre.name.clone(),
                    class: "object.container".to_string(),
                    artist: None,
                    album_art: None,
                    containers: vec![],
                    items: vec![],
                })
            })
            .collect();

        Ok(BrowseResult::Containers(containers))
    }

    /// Browse un genre spécifique - retourne 6 sous-containers
    async fn browse_genre(&self, genre_id: &str) -> Result<BrowseResult> {
        let containers = vec![
            self.build_genre_new_releases_container(genre_id),
            self.build_genre_ideal_discography_container(genre_id),
            self.build_genre_qobuzissime_container(genre_id),
            self.build_genre_editor_picks_container(genre_id),
            self.build_genre_press_awards_container(genre_id),
            self.build_genre_playlists_container(genre_id),
        ];

        Ok(BrowseResult::Containers(containers))
    }

    fn build_genre_new_releases_container(&self, genre_id: &str) -> Container {
        Container {
            id: format!("qobuz:genre:{}:new-releases", genre_id),
            parent_id: format!("qobuz:genre:{}", genre_id),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "New Releases".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    fn build_genre_ideal_discography_container(&self, genre_id: &str) -> Container {
        Container {
            id: format!("qobuz:genre:{}:ideal", genre_id),
            parent_id: format!("qobuz:genre:{}", genre_id),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Ideal Discography".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    fn build_genre_qobuzissime_container(&self, genre_id: &str) -> Container {
        Container {
            id: format!("qobuz:genre:{}:qobuzissime", genre_id),
            parent_id: format!("qobuz:genre:{}", genre_id),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Qobuzissime".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    fn build_genre_editor_picks_container(&self, genre_id: &str) -> Container {
        Container {
            id: format!("qobuz:genre:{}:editor-picks", genre_id),
            parent_id: format!("qobuz:genre:{}", genre_id),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Editor Picks".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    fn build_genre_press_awards_container(&self, genre_id: &str) -> Container {
        Container {
            id: format!("qobuz:genre:{}:press-awards", genre_id),
            parent_id: format!("qobuz:genre:{}", genre_id),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Press Awards".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    fn build_genre_playlists_container(&self, genre_id: &str) -> Container {
        Container {
            id: format!("qobuz:genre:{}:playlists", genre_id),
            parent_id: format!("qobuz:genre:{}", genre_id),
            restricted: Some("1".to_string()),
            child_count: None,
            searchable: Some("1".to_string()),
            title: "Qobuz Playlists".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        }
    }

    async fn browse_genre_new_releases(&self, genre_id: &str) -> Result<BrowseResult> {
        self.browse_featured_albums(Some(genre_id), "new-releases", &format!("qobuz:genre:{}:new-releases", genre_id)).await
    }

    async fn browse_genre_ideal_discography(&self, genre_id: &str) -> Result<BrowseResult> {
        self.browse_featured_albums(Some(genre_id), "ideal-discography", &format!("qobuz:genre:{}:ideal", genre_id)).await
    }

    async fn browse_genre_qobuzissime(&self, genre_id: &str) -> Result<BrowseResult> {
        self.browse_featured_albums(Some(genre_id), "qobuzissims", &format!("qobuz:genre:{}:qobuzissime", genre_id)).await
    }

    async fn browse_genre_editor_picks(&self, genre_id: &str) -> Result<BrowseResult> {
        self.browse_featured_albums(Some(genre_id), "editor-picks", &format!("qobuz:genre:{}:editor-picks", genre_id)).await
    }

    async fn browse_genre_press_awards(&self, genre_id: &str) -> Result<BrowseResult> {
        self.browse_featured_albums(Some(genre_id), "press-awards", &format!("qobuz:genre:{}:press-awards", genre_id)).await
    }

    async fn browse_genre_playlists(&self, genre_id: &str) -> Result<BrowseResult> {
        self.browse_featured_playlists(Some(genre_id), &format!("qobuz:genre:{}:playlists", genre_id)).await
    }

    /// Parse object_id to determine what to browse
    ///
    /// Object IDs follow these patterns:
    /// - "qobuz" or "0" → Root container
    /// - "qobuz:discover" → Discover Catalog
    /// - "qobuz:genres" → Discover Genres
    /// - "qobuz:favorites" → My Music
    /// - "qobuz:album:{id}" → Tracks in album
    /// - "qobuz:playlist:{id}" → Tracks in playlist
    /// - etc.
    fn parse_object_id(&self, object_id: &str) -> ObjectIdType {
        tracing::debug!(object_id, "parse_object_id");
        if object_id == "qobuz" || object_id == "0" {
            return ObjectIdType::Root;
        }

        // Virtual search result containers: qobuz:search:{scope}:{type}:{query}
        // Use splitn(5) so the query (last segment) can contain ':' without ambiguity.
        let search_parts: Vec<&str> = object_id.splitn(5, ':').collect();
        tracing::debug!(len = search_parts.len(), parts = ?search_parts, "parse_object_id splitn");
        if search_parts.len() == 5 && search_parts[0] == "qobuz" && search_parts[1] == "search" {
            let scope = match search_parts[2] {
                "favorites" => SearchScope::UserLibrary,
                _ => SearchScope::Catalog,
            };
            let media_type = match search_parts[3] {
                "albums" => MediaSearchType::Albums,
                "tracks" => MediaSearchType::Tracks,
                "artists" => MediaSearchType::Artists,
                "playlists" => MediaSearchType::Playlists,
                _ => MediaSearchType::All,
            };
            tracing::debug!(scope = ?scope, media_type = ?media_type, query = search_parts[4], "parse_object_id → SearchResult");
            return ObjectIdType::SearchResult(scope, media_type, search_parts[4].to_string());
        }

        let parts: Vec<&str> = object_id.split(':').collect();
        match parts.as_slice() {
            // Discover Catalog
            ["qobuz", "discover"] => ObjectIdType::DiscoverCatalog,
            ["qobuz", "discover", "playlists"] => ObjectIdType::DiscoverPlaylists,
            ["qobuz", "discover", "albums", "ideal"] => ObjectIdType::DiscoverAlbumsIdeal,
            ["qobuz", "discover", "albums", "qobuzissime"] => {
                ObjectIdType::DiscoverAlbumsQobuzissime
            }
            ["qobuz", "discover", "albums", "new"] => ObjectIdType::DiscoverAlbumsNew,
            ["qobuz", "discover", "artists"] => ObjectIdType::DiscoverArtists,
            ["qobuz", "discover", "playlists", tag] => {
                ObjectIdType::DiscoverPlaylistsByTag(tag.to_string())
            }

            // Discover Genres
            ["qobuz", "genres"] => ObjectIdType::DiscoverGenres,
            ["qobuz", "genre", id] => ObjectIdType::GenreRoot(id.to_string()),
            ["qobuz", "genre", id, "new-releases"] => {
                ObjectIdType::GenreNewReleases(id.to_string())
            }
            ["qobuz", "genre", id, "ideal"] => ObjectIdType::GenreIdealDiscography(id.to_string()),
            ["qobuz", "genre", id, "qobuzissime"] => ObjectIdType::GenreQobuzissime(id.to_string()),
            ["qobuz", "genre", id, "editor-picks"] => {
                ObjectIdType::GenreEditorPicks(id.to_string())
            }
            ["qobuz", "genre", id, "press-awards"] => {
                ObjectIdType::GenrePressAwards(id.to_string())
            }
            ["qobuz", "genre", id, "playlists"] => ObjectIdType::GenrePlaylists(id.to_string()),

            // Favourites
            ["qobuz", "favorites"] => ObjectIdType::Favourites,
            ["qobuz", "favorites", "albums"] => ObjectIdType::FavouriteAlbums,
            ["qobuz", "favorites", "tracks"] => ObjectIdType::FavouriteTracks,
            ["qobuz", "favorites", "artists"] => ObjectIdType::FavouriteArtists,
            ["qobuz", "favorites", "playlists"] => ObjectIdType::FavouritePlaylists,

            // Items (existant)
            ["qobuz", "album", id] => ObjectIdType::Album(id.to_string()),
            ["qobuz", "playlist", id] => ObjectIdType::Playlist(id.to_string()),
            ["qobuz", "artist", id] => ObjectIdType::Artist(id.to_string()),
            ["qobuz", "track", id] => ObjectIdType::Track(id.to_string()),

            _ => ObjectIdType::Unknown,
        }
    }
}

#[derive(Debug)]
enum ObjectIdType {
    Root,

    // Discover Catalog
    DiscoverCatalog,
    DiscoverPlaylists,
    DiscoverAlbumsIdeal,
    DiscoverAlbumsQobuzissime,
    DiscoverAlbumsNew,
    DiscoverArtists,
    DiscoverPlaylistsByTag(String), // tag

    // Discover Genres
    DiscoverGenres,
    GenreRoot(String),             // genre_id
    GenreNewReleases(String),      // genre_id
    GenreIdealDiscography(String), // genre_id
    GenreQobuzissime(String),      // genre_id
    GenreEditorPicks(String),      // genre_id
    GenrePressAwards(String),      // genre_id
    GenrePlaylists(String),        // genre_id

    // Favourites
    Favourites,
    FavouriteAlbums,
    FavouriteTracks,
    FavouriteArtists,
    FavouritePlaylists,

    // Items (existant)
    Album(String),
    Playlist(String),
    Artist(String),
    Track(String),

    // Containers virtuels de résultats de recherche
    // (scope, media_type, query)
    SearchResult(SearchScope, MediaSearchType, String),

    Unknown,
}

#[async_trait]
impl MusicSource for QobuzSource {
    fn name(&self) -> &str {
        "Qobuz"
    }

    fn id(&self) -> &str {
        "qobuz"
    }

    fn default_image(&self) -> &[u8] {
        DEFAULT_IMAGE
    }

    async fn root_container(&self) -> Result<Container> {
        // Create the root container with 3 main branches
        Ok(Container {
            id: "qobuz".to_string(),
            parent_id: "0".to_string(),
            restricted: Some("1".to_string()),
            child_count: Some("3".to_string()),
            searchable: Some("1".to_string()),
            title: "Qobuz".to_string(),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![
                self.build_discover_catalog_container(),
                self.build_discover_genres_container(),
                self.build_favourites_container(),
            ],
            items: vec![],
        })
    }

    async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
        match self.parse_object_id(object_id) {
            ObjectIdType::Root => {
                // Return the root container's children
                let root = self.root_container().await?;
                Ok(BrowseResult::Containers(root.containers))
            }

            // Discover Catalog
            ObjectIdType::DiscoverCatalog => self.browse_discover_catalog().await,
            ObjectIdType::DiscoverPlaylists => self.browse_discover_playlists().await,
            ObjectIdType::DiscoverAlbumsIdeal => self.browse_discover_albums_ideal().await,
            ObjectIdType::DiscoverAlbumsQobuzissime => {
                self.browse_discover_albums_qobuzissime().await
            }
            ObjectIdType::DiscoverAlbumsNew => self.browse_discover_albums_new().await,
            ObjectIdType::DiscoverArtists => self.browse_discover_artists().await,
            ObjectIdType::DiscoverPlaylistsByTag(tag) => {
                self.browse_discover_playlists_tag(&tag).await
            }

            // Discover Genres
            ObjectIdType::DiscoverGenres => self.browse_discover_genres().await,
            ObjectIdType::GenreRoot(id) => self.browse_genre(&id).await,
            ObjectIdType::GenreNewReleases(id) => self.browse_genre_new_releases(&id).await,
            ObjectIdType::GenreIdealDiscography(id) => {
                self.browse_genre_ideal_discography(&id).await
            }
            ObjectIdType::GenreQobuzissime(id) => self.browse_genre_qobuzissime(&id).await,
            ObjectIdType::GenreEditorPicks(id) => self.browse_genre_editor_picks(&id).await,
            ObjectIdType::GenrePressAwards(id) => self.browse_genre_press_awards(&id).await,
            ObjectIdType::GenrePlaylists(id) => self.browse_genre_playlists(&id).await,

            // Favourites
            ObjectIdType::Favourites => self.browse_favourites().await,
            ObjectIdType::FavouriteAlbums => self.browse_favourite_albums().await,
            ObjectIdType::FavouriteTracks => self.browse_favourite_tracks().await,
            ObjectIdType::FavouriteArtists => self.browse_favourite_artists().await,
            ObjectIdType::FavouritePlaylists => self.browse_favourite_playlists().await,

            // Items (existant)
            ObjectIdType::Album(album_id) => {
                let items = self
                    .get_or_create_album_playlist_items(&album_id, usize::MAX)
                    .await?;

                Ok(BrowseResult::Items(items))
            }

            ObjectIdType::Playlist(playlist_id) => {
                let items = self
                    .get_or_create_qobuz_playlist_items(&playlist_id)
                    .await?;

                Ok(BrowseResult::Items(items))
            }

            ObjectIdType::Artist(artist_id) => {
                let albums = self
                    .inner
                    .client
                    .get_artist_albums(&artist_id)
                    .await
                    .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

                let albums = self.cache_covers(albums).await;
                let containers: Vec<Container> = albums
                    .into_iter()
                    .filter_map(|album| {
                        album
                            .to_didl_container(&format!("qobuz:artist:{}", artist_id))
                            .ok()
                    })
                    .collect();

                Ok(BrowseResult::Containers(containers))
            }

            ObjectIdType::SearchResult(scope, media_type, query) => {
                tracing::debug!(scope = ?scope, media_type = ?media_type, query, "browse → search");
                let is_all_search = media_type == MediaSearchType::All;
                let sq = SearchQuery {
                    text: query,
                    media_type,
                    scope,
                    limit: 200,
                    offset: 0,
                };
                if is_all_search {
                    self.search_grouped(&sq).await
                } else {
                    self.execute_search(&sq).await
                }
            }

            ObjectIdType::Track(_) => {
                // Track object_ids ne sont pas browsables, retourner une erreur
                Err(MusicSourceError::NotSupported(
                    "Tracks are not browsable containers".to_string(),
                ))
            }

            ObjectIdType::Unknown => Err(MusicSourceError::ObjectNotFound(object_id.to_string())),
        }
    }

    async fn get_container(&self, object_id: &str) -> Result<Option<pmodidl::Container>> {
        use crate::didl::ToDIDL;
        match self.parse_object_id(object_id) {
            ObjectIdType::Album(album_id) => {
                let album = self
                    .inner
                    .client
                    .get_album(&album_id)
                    .await
                    .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
                let album = self.cache_album_covers(vec![album]).await.into_iter().next().unwrap();
                let container = album
                    .to_didl_container("qobuz")
                    .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
                Ok(Some(container))
            }

            ObjectIdType::Playlist(playlist_id) => {
                let playlist = self
                    .inner
                    .client
                    .get_playlist(&playlist_id)
                    .await
                    .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
                let container = playlist
                    .to_didl_container("qobuz")
                    .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
                Ok(Some(container))
            }

            ObjectIdType::Artist(artist_id) => {
                // Pas d'endpoint artist direct — on tire le nom/image depuis les albums
                let albums = self
                    .inner
                    .client
                    .get_artist_albums(&artist_id)
                    .await
                    .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
                let first = albums.first();
                let artist_name = first
                    .map(|a| a.artist.name.clone())
                    .unwrap_or_else(|| format!("Artiste {}", artist_id));
                let album_art = first.and_then(|a| a.image_cached.clone().or_else(|| a.image.clone()));
                let container = pmodidl::Container {
                    id: object_id.to_string(),
                    parent_id: "qobuz".to_string(),
                    restricted: Some("1".to_string()),
                    child_count: Some(albums.len().to_string()),
                    searchable: Some("1".to_string()),
                    title: artist_name.clone(),
                    class: "object.container.person.musicArtist".to_string(),
                    artist: Some(artist_name),
                    album_art,
                    containers: vec![],
                    items: vec![],
                };
                Ok(Some(container))
            }

            _ => Ok(None),
        }
    }

    async fn resolve_uri(&self, object_id: &str) -> Result<String> {
        // Try cache manager first
        if let Ok(uri) = self.inner.cache_manager.resolve_uri(object_id).await {
            return Ok(uri);
        }

        // If not cached, extract track ID and get streaming URL from Qobuz
        let track_id = object_id
            .strip_prefix("qobuz://track/")
            .unwrap_or(object_id);

        self.inner
            .client
            .get_stream_url(track_id)
            .await
            .map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))
    }

    async fn get_item(&self, object_id: &str) -> Result<Item> {
        // Parse object_id to extract track ID
        let track_id = match self.parse_object_id(object_id) {
            ObjectIdType::Track(id) => id,
            _ => {
                return Err(MusicSourceError::ObjectNotFound(format!(
                    "Not a track: {}",
                    object_id
                )))
            }
        };

        // Get track from Qobuz API
        let track = self
            .inner
            .client
            .get_track(&track_id)
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

        // Register track in cache with lazy loading (same as albums)
        let (_track_uri, cache_pk) = self.add_track_lazy(&track).await?;

        // Build Item with HTTP URL pointing to cache
        let parent_id = track
            .album
            .as_ref()
            .map(|a| format!("qobuz:album:{}", a.id))
            .unwrap_or_else(|| "qobuz".to_string());

        // Get cover URL from cache if available
        let cover_url = if let Some(ref album) = track.album {
            if let Some(ref image) = album.image {
                if let Ok(pk) = self.inner.cache_manager.cache_cover(image).await {
                    self.inner.cache_manager.cover_url(&pk, None).ok()
                } else {
                    Some(image.clone())
                }
            } else {
                None
            }
        } else {
            None
        };

        // Build the resource with absolute HTTP URL
        let audio_url = format!("{}/audio/flac/{}", self.inner.base_url, cache_pk);

        let resource = pmodidl::Resource {
            protocol_info: format!(
                "http-get:*:{}:*",
                track.mime_type.as_deref().unwrap_or("audio/flac")
            ),
            bits_per_sample: track.bit_depth.map(|b| b.to_string()),
            sample_frequency: track.sample_rate.map(|r| r.to_string()),
            nr_audio_channels: track.channels.map(|c| c.to_string()),
            duration: Some(format_duration(track.duration)),
            url: audio_url,
        };

        Ok(Item {
            id: format!("qobuz:track:{}", track.id),
            parent_id,
            restricted: Some("1".to_string()),
            title: track.title.clone(),
            creator: track.display_artist().map(|a| a.name.clone()),
            class: "object.item.audioItem.musicTrack".to_string(),
            artist: track.display_artist().map(|a| a.name.clone()),
            album: track.album_name().map(|s| s.to_string()),
            genre: None,
            album_art: cover_url,
            album_art_pk: None,
            date: track.album.as_ref().and_then(|a| a.release_date.clone()),
            original_track_number: Some(track.track_number.to_string()),
            resources: vec![resource],
            descriptions: Vec::new(),
        })
    }

    fn supports_fifo(&self) -> bool {
        // Qobuz is a catalog, not a dynamic stream
        false
    }

    async fn append_track(&self, _track: Item) -> Result<()> {
        Err(MusicSourceError::FifoNotSupported)
    }

    async fn remove_oldest(&self) -> Result<Option<Item>> {
        Err(MusicSourceError::FifoNotSupported)
    }

    async fn update_id(&self) -> u32 {
        *self.inner.update_counter.read().await
    }

    async fn last_change(&self) -> Option<SystemTime> {
        Some(*self.inner.last_change.read().await)
    }

    async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
        // For Qobuz, "get_items" returns favorite tracks with pagination
        let all_tracks = self
            .inner
            .client
            .get_favorite_tracks()
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

        let all_tracks = self.cache_covers(all_tracks).await;
        let items: Vec<Item> = all_tracks
            .into_iter()
            .skip(offset)
            .take(count)
            .filter_map(|track| track.to_didl_item("qobuz:favorites").ok())
            .collect();

        Ok(items)
    }

    async fn search(&self, query: &SearchQuery) -> Result<BrowseResult> {
        use tracing::debug;
        debug!(text = %query.text, scope = ?query.scope, media_type = ?query.media_type, "Qobuz search");

        if query.media_type == MediaSearchType::All {
            self.search_grouped(query).await
        } else {
            self.execute_search(query).await
        }
    }

    // ============= Extended Features Implementation =============

    fn capabilities(&self) -> pmosource::SourceCapabilities {
        pmosource::SourceCapabilities {
            supports_fifo: false,
            supports_search: true,
            supports_favorites: true,
            supports_playlists: true,
            supports_user_content: false,
            supports_high_res_audio: true,
            max_sample_rate: Some(192_000), // Qobuz supports up to 192kHz
            supports_multiple_formats: true,
            supports_advanced_search: true,
            supports_pagination: true,
            handles_url_input: false,
        }
    }

    async fn get_available_formats(&self, object_id: &str) -> Result<Vec<pmosource::AudioFormat>> {
        use pmosource::AudioFormat;

        // Extract track ID from object_id
        let track_id = if let Some(id) = object_id.strip_prefix("qobuz://track/") {
            id
        } else {
            object_id
        };

        // Get track details from Qobuz
        let track = self
            .inner
            .client
            .get_track(track_id)
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

        // Qobuz provides multiple formats based on subscription
        let mut formats = vec![];

        // MP3 320 (format_id 5) - available to all
        formats.push(AudioFormat {
            format_id: "mp3-320".to_string(),
            mime_type: "audio/mpeg".to_string(),
            sample_rate: Some(44100),
            bit_depth: None,
            bitrate: Some(320),
            channels: Some(2),
        });

        // FLAC 16/44.1 (format_id 6) - CD quality
        formats.push(AudioFormat {
            format_id: "flac-16-44".to_string(),
            mime_type: "audio/flac".to_string(),
            sample_rate: Some(44100),
            bit_depth: Some(16),
            bitrate: None,
            channels: Some(2),
        });

        // Hi-Res formats (if available for this track)
        if let Some(sample_rate) = track.sample_rate {
            if sample_rate > 44100 {
                // FLAC 24-bit Hi-Res
                let bit_depth = track.bit_depth.map(|d| d as u8).or(Some(24));

                formats.push(AudioFormat {
                    format_id: format!("flac-{}-{}", bit_depth.unwrap_or(24), sample_rate / 1000),
                    mime_type: "audio/flac".to_string(),
                    sample_rate: Some(sample_rate),
                    bit_depth,
                    bitrate: None,
                    channels: track.channels,
                });
            }
        }

        Ok(formats)
    }

    async fn get_cache_status(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
        self.inner.cache_manager.get_cache_status(object_id).await
    }

    async fn cache_item(&self, object_id: &str) -> Result<pmosource::CacheStatus> {
        // Extract track ID
        let track_id = object_id
            .strip_prefix("qobuz://track/")
            .unwrap_or(object_id);

        // Get track details from Qobuz
        let track = self
            .inner
            .client
            .get_track(track_id)
            .await
            .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

        // Add track to cache (via manager)
        let cached_id = self.add_track(&track).await?;

        // Return the cache status
        self.get_cache_status(&cached_id).await
    }

    async fn add_favorite(&self, object_id: &str) -> Result<()> {
        // Parse object_id to determine type
        let parts: Vec<&str> = object_id.split(':').collect();

        match parts.as_slice() {
            ["qobuz", "album", id] | ["qobuz://album", id] => {
                self.inner
                    .client
                    .add_favorite_album(id)
                    .await
                    .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
            }
            ["qobuz", "track", id] | ["qobuz://track", id] => {
                self.inner
                    .client
                    .add_favorite_track(id)
                    .await
                    .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
            }
            _ => {
                return Err(MusicSourceError::NotSupported(
                    "Favorites only supported for albums and tracks".to_string(),
                ));
            }
        }

        self.increment_update_id().await;
        Ok(())
    }

    async fn remove_favorite(&self, object_id: &str) -> Result<()> {
        // Parse object_id to determine type
        let parts: Vec<&str> = object_id.split(':').collect();

        match parts.as_slice() {
            ["qobuz", "album", id] | ["qobuz://album", id] => {
                self.inner
                    .client
                    .remove_favorite_album(id)
                    .await
                    .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
            }
            ["qobuz", "track", id] | ["qobuz://track", id] => {
                self.inner
                    .client
                    .remove_favorite_track(id)
                    .await
                    .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;
            }
            _ => {
                return Err(MusicSourceError::NotSupported(
                    "Favorites only supported for albums and tracks".to_string(),
                ));
            }
        }

        self.increment_update_id().await;
        Ok(())
    }

    async fn is_favorite(&self, object_id: &str) -> Result<bool> {
        // Parse object_id to determine type
        let parts: Vec<&str> = object_id.split(':').collect();

        match parts.as_slice() {
            ["qobuz", "album", id] | ["qobuz://album", id] => {
                let favorites = self
                    .inner
                    .client
                    .get_favorite_albums()
                    .await
                    .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;

                Ok(favorites.iter().any(|album| album.id == *id))
            }
            ["qobuz", "track", id] | ["qobuz://track", id] => {
                let favorites = self
                    .inner
                    .client
                    .get_favorite_tracks()
                    .await
                    .map_err(|e| MusicSourceError::FavoritesError(e.to_string()))?;

                Ok(favorites.iter().any(|track| track.id == *id))
            }
            _ => Err(MusicSourceError::NotSupported(
                "Favorites only supported for albums and tracks".to_string(),
            )),
        }
    }

    async fn get_user_playlists(&self) -> Result<Vec<Container>> {
        let playlists = self
            .inner
            .client
            .get_user_playlists()
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;

        let playlists = self.cache_covers(playlists).await;
        let containers: Vec<Container> = playlists
            .into_iter()
            .filter_map(|playlist| playlist.to_didl_container("qobuz").ok())
            .collect();

        Ok(containers)
    }

    async fn add_to_playlist(&self, playlist_id: &str, item_id: &str) -> Result<()> {
        // Extract track ID from item_id
        let track_id = if let Some(id) = item_id.strip_prefix("qobuz://track/") {
            id
        } else if let Some(id) = item_id.strip_prefix("qobuz:track:") {
            id
        } else {
            item_id
        };

        self.inner
            .client
            .add_to_playlist(playlist_id, track_id)
            .await
            .map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;

        self.increment_update_id().await;
        Ok(())
    }

    async fn get_item_count(&self, object_id: &str) -> Result<usize> {
        match self.parse_object_id(object_id) {
            ObjectIdType::Album(album_id) => {
                let album = self
                    .inner
                    .client
                    .get_album(&album_id)
                    .await
                    .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

                Ok(album.tracks_count.unwrap_or(0) as usize)
            }
            ObjectIdType::Playlist(playlist_id) => {
                let playlist = self
                    .inner
                    .client
                    .get_playlist(&playlist_id)
                    .await
                    .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

                Ok(playlist.tracks_count.unwrap_or(0) as usize)
            }
            _ => {
                // Fall back to default implementation
                let result = self.browse(object_id).await?;
                Ok(result.count())
            }
        }
    }

    async fn browse_paginated(
        &self,
        object_id: &str,
        offset: usize,
        limit: usize,
    ) -> Result<BrowseResult> {
        match self.parse_object_id(object_id) {
            ObjectIdType::Album(album_id) => {
                let all_items = self
                    .get_or_create_album_playlist_items(&album_id, usize::MAX)
                    .await?;

                let items: Vec<Item> = all_items.into_iter().skip(offset).take(limit).collect();

                Ok(BrowseResult::Items(items))
            }
            ObjectIdType::FavouriteAlbums => {
                let albums = self
                    .inner
                    .client
                    .get_favorite_albums()
                    .await
                    .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;

                let albums = self.cache_covers(albums).await;
                let containers: Vec<Container> = albums
                    .into_iter()
                    .skip(offset)
                    .take(limit)
                    .filter_map(|album| album.to_didl_container("qobuz:favorites:albums").ok())
                    .collect();

                Ok(BrowseResult::Containers(containers))
            }
            _ => {
                // Fall back to default implementation
                self.browse(object_id).await
            }
        }
    }

    async fn statistics(&self) -> Result<pmosource::SourceStatistics> {
        let mut stats = pmosource::SourceStatistics::default();

        // Try to get favorite counts
        if let Ok(albums) = self.inner.client.get_favorite_albums().await {
            stats.total_containers = Some(albums.len());
        }

        if let Ok(tracks) = self.inner.client.get_favorite_tracks().await {
            stats.total_items = Some(tracks.len());
        }

        // Get cache statistics from manager
        let cache_stats = self.inner.cache_manager.statistics().await;
        stats.cached_items = Some(cache_stats.cached_tracks);

        Ok(stats)
    }
}

// Search helpers — inherent methods, called from both `search()` and `browse()`.
impl QobuzSource {
    /// Recherche groupée : retourne des containers virtuels navigables (un par type).
    pub(crate) async fn search_grouped(&self, query: &SearchQuery) -> Result<BrowseResult> {
        use tracing::debug;
        let scope_str = match query.scope {
            SearchScope::Catalog => "catalog",
            SearchScope::UserLibrary => "favorites",
        };

        let counts = if query.scope == SearchScope::UserLibrary {
            self.search_favorites_counts(&query.text).await
        } else {
            self.search_catalog_counts(&query.text).await
        };

        let (n_albums, n_artists, n_tracks, n_playlists) = counts;
        let parent_id = format!("qobuz:search:{}:all:{}", scope_str, query.text);

        let mk_container = |type_str: &str, title: &str, count: usize| Container {
            id: format!("qobuz:search:{}:{}:{}", scope_str, type_str, query.text),
            parent_id: parent_id.clone(),
            restricted: Some("1".to_string()),
            child_count: Some(count.to_string()),
            searchable: None,
            title: format!("{} ({}{})", title, count, if count >= 1000 { "+" } else { "" }),
            class: "object.container".to_string(),
            artist: None,
            album_art: None,
            containers: vec![],
            items: vec![],
        };

        let mut containers = Vec::new();
        if n_albums > 0 { containers.push(mk_container("albums", "Albums", n_albums)); }
        if n_artists > 0 { containers.push(mk_container("artists", "Artistes", n_artists)); }
        if n_tracks > 0 { containers.push(mk_container("tracks", "Titres", n_tracks)); }
        if n_playlists > 0 { containers.push(mk_container("playlists", "Playlists", n_playlists)); }

        debug!(containers = containers.len(), "Search grouped result");
        Ok(BrowseResult::Containers(containers))
    }

    async fn search_catalog_counts(&self, text: &str) -> (usize, usize, usize, usize) {
        match self.inner.client.search_totals(text).await {
            Ok((albums, artists, tracks, playlists)) => {
                tracing::debug!(albums, artists, tracks, playlists, "search_catalog_counts totals");
                (albums as usize, artists as usize, tracks as usize, playlists as usize)
            }
            Err(e) => {
                tracing::warn!(error = %e, "search_catalog_counts failed");
                (0, 0, 0, 0)
            }
        }
    }

    async fn search_favorites_counts(&self, text: &str) -> (usize, usize, usize, usize) {
        let q = text.to_lowercase();
        let albums = self.inner.client.get_favorite_albums().await.unwrap_or_default()
            .into_iter().filter(|a| a.title.to_lowercase().contains(&q) || a.artist.name.to_lowercase().contains(&q)).count();
        let tracks = self.inner.client.get_favorite_tracks().await.unwrap_or_default()
            .into_iter().filter(|t| t.title.to_lowercase().contains(&q) || t.performer.as_ref().map(|p| p.name.to_lowercase().contains(&q)).unwrap_or(false)).count();
        let artists = self.inner.client.get_favorite_artists().await.unwrap_or_default()
            .into_iter().filter(|a| a.name.to_lowercase().contains(&q)).count();
        let playlists = self.inner.client.get_user_playlists().await.unwrap_or_default()
            .into_iter().filter(|p| p.name.to_lowercase().contains(&q)).count();
        (albums, artists, tracks, playlists)
    }

    /// Exécute une recherche typée (non-All) et retourne les items/containers directement.
    pub(crate) async fn execute_search(&self, query: &SearchQuery) -> Result<BrowseResult> {
        use tracing::debug;
        debug!(text = %query.text, scope = ?query.scope, media_type = ?query.media_type, "execute_search entry");
        let text = &query.text;
        let scope_str = match query.scope {
            SearchScope::Catalog => "catalog",
            SearchScope::UserLibrary => "favorites",
        };
        let type_str = match query.media_type {
            MediaSearchType::Albums => "albums",
            MediaSearchType::Artists => "artists",
            MediaSearchType::Tracks => "tracks",
            MediaSearchType::Playlists => "playlists",
            MediaSearchType::All => "all",
        };
        let parent_id = format!("qobuz:search:{}:{}:{}", scope_str, type_str, query.text);

        match (&query.scope, &query.media_type) {
            (SearchScope::UserLibrary, MediaSearchType::Albums) => {
                let q = text.to_lowercase();
                let albums = self.inner.client.get_favorite_albums().await
                    .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?
                    .into_iter().filter(|a| a.title.to_lowercase().contains(&q) || a.artist.name.to_lowercase().contains(&q))
                    .collect::<Vec<_>>();
                let albums = self.cache_album_covers(albums).await;
                Ok(BrowseResult::Containers(albums.into_iter().filter_map(|a| a.to_didl_container(&parent_id).ok()).collect()))
            }
            (SearchScope::UserLibrary, MediaSearchType::Tracks) => {
                let q = text.to_lowercase();
                let tracks = self.inner.client.get_favorite_tracks().await
                    .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?
                    .into_iter().filter(|t| t.title.to_lowercase().contains(&q) || t.performer.as_ref().map(|p| p.name.to_lowercase().contains(&q)).unwrap_or(false))
                    .collect::<Vec<_>>();
                let tracks = self.cache_track_covers(tracks).await;
                Ok(BrowseResult::Items(tracks.into_iter().filter_map(|t| t.to_didl_item(&parent_id).ok()).collect()))
            }
            (SearchScope::UserLibrary, MediaSearchType::Artists) => {
                let q = text.to_lowercase();
                let artists = self.inner.client.get_favorite_artists().await
                    .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?
                    .into_iter().filter(|a| a.name.to_lowercase().contains(&q))
                    .collect::<Vec<_>>();
                let artists = self.cache_artist_covers(artists).await;
                let containers = artists.into_iter().map(|a| Container {
                    id: format!("qobuz:artist:{}", a.id),
                    parent_id: parent_id.clone(),
                    restricted: Some("1".to_string()),
                    child_count: None, searchable: Some("1".to_string()),
                    title: a.name.clone(), class: "object.container.person.musicArtist".to_string(),
                    artist: Some(a.name.clone()), album_art: a.image_cached,
                    containers: vec![], items: vec![],
                }).collect();
                Ok(BrowseResult::Containers(containers))
            }

            (SearchScope::UserLibrary, MediaSearchType::Playlists) => {
                let q = text.to_lowercase();
                let playlists = self.inner.client.get_user_playlists().await
                    .map_err(|e| MusicSourceError::BrowseError(e.to_string()))?
                    .into_iter().filter(|p| p.name.to_lowercase().contains(&q))
                    .collect::<Vec<_>>();
                let playlists = self.cache_playlist_covers(playlists).await;
                Ok(BrowseResult::Containers(playlists.into_iter().filter_map(|p| p.to_didl_container(&parent_id).ok()).collect()))
            }
            (SearchScope::Catalog, MediaSearchType::Albums) => {
                let result = self.inner.client.search(text, Some("albums"))
                    .await.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
                let albums = self.cache_album_covers(result.albums).await;
                let containers: Vec<Container> = albums.into_iter()
                    .filter_map(|a| a.to_didl_container(&parent_id).ok()).collect();
                debug!(count = containers.len(), "search albums result");
                Ok(BrowseResult::Containers(containers))
            }
            (SearchScope::Catalog, MediaSearchType::Tracks) => {
                let result = self.inner.client.search(text, Some("tracks"))
                    .await.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
                let tracks = self.cache_track_covers(result.tracks).await;
                let items: Vec<Item> = tracks.into_iter()
                    .filter_map(|t| t.to_didl_item(&parent_id).ok()).collect();
                debug!(count = items.len(), "search tracks result");
                Ok(BrowseResult::Items(items))
            }
            (SearchScope::Catalog, MediaSearchType::Artists) => {
                let result = self.inner.client.search(text, Some("artists"))
                    .await.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
                let artists = self.cache_artist_covers(result.artists).await;
                let containers: Vec<Container> = artists.into_iter().map(|a| Container {
                    id: format!("qobuz:artist:{}", a.id),
                    parent_id: parent_id.clone(),
                    restricted: Some("1".to_string()),
                    child_count: None, searchable: None,
                    title: a.name.clone(), class: "object.container.person.musicArtist".to_string(),
                    artist: Some(a.name.clone()), album_art: a.image_cached,
                    containers: vec![], items: vec![],
                }).collect();
                let first_titles: Vec<&str> = containers.iter().take(4).map(|c| c.title.as_str()).collect();
                debug!(count = containers.len(), first4 = ?first_titles, "search artists result");
                Ok(BrowseResult::Containers(containers))
            }
            (SearchScope::Catalog, MediaSearchType::Playlists) => {
                let result = self.inner.client.search(text, Some("playlists"))
                    .await.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
                let playlists = self.cache_playlist_covers(result.playlists).await;
                let containers: Vec<Container> = playlists.into_iter()
                    .filter_map(|p| p.to_didl_container(&parent_id).ok()).collect();
                debug!(count = containers.len(), "search playlists result");
                Ok(BrowseResult::Containers(containers))
            }
            _ => Ok(BrowseResult::Items(vec![])),
        }
    }
}

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

    #[test]
    fn test_default_image_present() {
        assert!(DEFAULT_IMAGE.len() > 0, "Default image should not be empty");

        // Check WebP magic bytes (RIFF...WEBP)
        assert!(
            DEFAULT_IMAGE.len() >= 12,
            "Image too small to be valid WebP"
        );
        assert_eq!(&DEFAULT_IMAGE[0..4], b"RIFF", "Missing RIFF header");
        assert_eq!(&DEFAULT_IMAGE[8..12], b"WEBP", "Missing WEBP signature");
    }

    // Note: We can't easily test parse_object_id without creating a real client
    // which requires authentication. The parsing logic is simple enough that
    // it's covered by integration tests.
}
