//! Module de gestion du cache générique
//!
//! Ce module fournit une interface générique pour gérer un cache de fichiers
//! avec métadonnées dans une base de données SQLite.

use crate::cache_trait::FileCache;
use crate::db::DB;
use crate::download::{
    download_with_transformer, ingest_with_transformer, Download, StreamTransformer,
};
use crate::lazy::{lazy_prefix_from_pk, LazyEntryRemoteData, LazyProvider};
use anyhow::{anyhow, bail, Result};
use serde_json::{Number, Value};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock as StdRwLock};
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::sync::{broadcast, RwLock};
use tracing;

enum FinalizeMode<'a> {
    InsertNew,
    ConvertLazy { lazy_pk: &'a str },
}

// ============================================================================
// LAZY PK SUPPORT
// ============================================================================

/// Préfixe magique pour identifier les lazy PK
const LAZY_PK_PREFIX: &str = "L:";

/// Génère un lazy PK à partir d'une URL
///
/// Le lazy PK est calculable sans télécharger le fichier, ce qui permet
/// de créer des URLs UPnP stables avant tout téléchargement.
///
/// Format: "L:" + hex(sha256(url)[..16])
pub fn generate_lazy_pk(url: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(url.as_bytes());
    let hash = hasher.finalize();
    format!("{}{}", LAZY_PK_PREFIX, hex::encode(&hash[..16]))
}

/// Vérifie si un PK est en mode lazy
pub fn is_lazy_pk(pk: &str) -> bool {
    if pk.starts_with(LAZY_PK_PREFIX) {
        return true;
    }

    lazy_prefix_from_pk(pk).is_some()
}

/// Events émis par le cache pour notifier les changements d'état
#[derive(Debug, Clone)]
pub enum CacheEvent {
    /// Un fichier a été servi via HTTP
    Served { pk: String, format: String },
    /// Un fichier lazy a été téléchargé et est maintenant disponible
    LazyDownloaded { lazy_pk: String, real_pk: String },
}

/// Informations transmises lors de la diffusion d'un élément du cache via HTTP.
///
/// - Emis uniquement quand une réponse 2xx est renvoyée par les routes HTTP générées
///   (fichier complet, stream progressif ou variante générée).
/// - Inclut le qualifier utilisé pour la requête afin de distinguer `orig`, `stream`, etc.
/// - Peut être utilisé pour synchroniser des clients (ex: WebSocket) ou tracer les hits.
#[derive(Debug, Clone)]
pub struct CacheBroadcastEvent {
    /// Identifiant unique du fichier servi.
    pub pk: String,
    /// Qualifier (paramètre de route) utilisé pour cette diffusion.
    pub qualifier: String,
    /// Nom logique du cache (`CacheConfig::cache_name`).
    pub cache_name: &'static str,
    /// Type du cache (`CacheConfig::cache_type`).
    pub cache_type: &'static str,
}

/// Handle retourné lors de l'abonnement à un évènement de diffusion.
///
/// Conservez-le pour pouvoir vous désabonner explicitement via
/// [`Cache::unsubscribe_broadcast`]. Le couple `(pk, id)` identifie de manière
/// unique la callback enregistrée.
#[derive(Debug, Clone)]
pub struct CacheSubscription {
    pub pk: String,
    pub id: u64,
}

type CacheServeCallback = Arc<dyn Fn(&CacheBroadcastEvent) -> bool + Send + Sync>;

/// Taille minimale de prébuffering par défaut (512 KB = ~5 secondes de FLAC)
pub const DEFAULT_PREBUFFER_SIZE: u64 = 512 * 1024;

/// Paramètres statiques d'un cache spécialisé.
pub trait CacheConfig: Send + Sync {
    /// Extension des fichiers générés (ex: `"webp"`, `"flac"`).
    fn file_extension() -> &'static str;
    /// Type logique exposé (ex: `"audio"`, `"image"`). Sert notamment pour les routes HTTP.
    fn cache_type() -> &'static str {
        "file"
    }
    /// Nom du cache (ex: `"covers"`, `"audio"`). Utilisé pour composer les chemins d'accès.
    fn cache_name() -> &'static str {
        "cache"
    }
    /// Qualifier par défaut associé au fichier original (ex: `"orig"`).
    fn default_param() -> &'static str {
        "orig"
    }
}

/// Cache générique pour stocker des fichiers avec métadonnées
///
/// Gère le téléchargement, le stockage et la récupération de fichiers
/// avec une base de données SQLite pour les métadonnées.
///
/// # Paramètres de type
///
/// * `C` - Configuration du cache (implémente `CacheConfig`)
///
/// Note : Ce type est conçu pour être utilisé derrière un `Arc<Cache>`.
/// La synchronisation est gérée par le Mutex interne de la base de données SQLite
/// et par le RwLock pour la map des downloads.
pub struct Cache<C: CacheConfig> {
    /// Répertoire de stockage
    dir: PathBuf,
    /// Limite de taille du cache (nombre d'éléments)
    limit: usize,
    /// Base de données SQLite
    pub db: Arc<DB>,
    /// Map des downloads en cours (pk -> Download)
    downloads: Arc<RwLock<HashMap<String, Arc<Download>>>>,
    /// Callback(s) à déclencher lorsqu'un élément est servi via HTTP (pk -> callbacks)
    serve_subscribers: Arc<RwLock<HashMap<String, Vec<(u64, CacheServeCallback)>>>>,
    /// Générateur d'identifiants uniques pour les abonnements
    subscriber_counter: AtomicU64,
    /// Factory pour créer des transformers (optionnel)
    transformer_factory: Option<Arc<dyn Fn() -> StreamTransformer + Send + Sync>>,
    /// Taille minimale de prébuffering en octets (0 = désactivé)
    min_prebuffer_size: u64,
    /// LAZY PK SUPPORT: Channel pour broadcaster les events (lazy downloads, etc.)
    served_tx: Option<broadcast::Sender<CacheEvent>>,
    /// Providers responsables de préfixes lazy spécifiques
    lazy_providers: StdRwLock<HashMap<String, Arc<dyn LazyProvider>>>,
    /// Phantom data pour le type de configuration
    _phantom: std::marker::PhantomData<C>,
}

impl<C: CacheConfig + 'static> Cache<C> {
    /// Retourne le chemin du fichier marker de complétion
    fn get_completion_marker_path(&self, pk: &str) -> PathBuf {
        self.get_file_path(pk)
            .with_extension(format!("{}.complete", C::file_extension()))
    }

    /// Vérifie si un fichier est en cache et complet
    ///
    /// # Returns
    ///
    /// - `Ok(true)` si le fichier est en cache et complet (fichier .complete existe)
    /// - `Ok(false)` si le fichier n'est pas en cache ou incomplet (et supprime les fichiers incomplets SI aucun download en cours)
    /// - `Err` en cas d'erreur
    async fn check_cached_and_complete(&self, pk: &str) -> Result<bool> {
        if self.db.get(pk, false).is_ok() {
            let file_path = self.get_file_path(pk);
            let completion_marker = self.get_completion_marker_path(pk);

            if file_path.exists() {
                // Vérifier si le fichier marker de complétion existe
                if completion_marker.exists() {
                    tracing::debug!("File with pk {} is complete (marker exists)", pk);
                    return Ok(true);
                } else {
                    // Vérifier si un download est en cours avant de supprimer
                    let is_downloading = {
                        let downloads = self.downloads.read().await;
                        downloads.contains_key(pk)
                    };

                    if is_downloading {
                        tracing::debug!(
                            "File with pk {} has no completion marker but download is in progress, waiting",
                            pk
                        );
                        return Ok(false);
                    }

                    tracing::warn!(
                        "File with pk {} in cache has no completion marker and no download in progress, will re-download/re-ingest",
                        pk
                    );
                    // Supprimer le fichier incomplet ET l'entrée DB seulement si pas de download en cours
                    let _ = std::fs::remove_file(&file_path);
                    let _ = std::fs::remove_file(&completion_marker); // Nettoyer aussi le marker s'il existe
                    if let Err(e) = self.db.delete(pk) {
                        tracing::warn!(
                            "Failed to delete DB entry for incomplete file {}: {}",
                            pk,
                            e
                        );
                    }
                    return Ok(false);
                }
            }
        }
        Ok(false)
    }

    /// Vérifie si un download est en cours et attend le prébuffering si nécessaire
    ///
    /// # Returns
    ///
    /// - `Ok(Some(pk))` si un download est en cours (et prébuffering terminé)
    /// - `Ok(None)` si aucun download en cours
    /// - `Err` en cas d'erreur de prébuffering
    async fn check_ongoing_download(&self, pk: &str) -> Result<Option<String>> {
        let download_handle = {
            let downloads = self.downloads.read().await;
            downloads.get(pk).cloned()
        };

        if let Some(download) = download_handle {
            tracing::debug!(
                "Download already in progress for pk {}, waiting for prebuffering",
                pk
            );

            if self.min_prebuffer_size > 0 {
                download
                    .wait_until_min_size(self.min_prebuffer_size)
                    .await
                    .map_err(|e| anyhow!("Prebuffering failed: {}", e))?;
                tracing::debug!("Prebuffering complete for pk {}", pk);
            }

            return Ok(Some(pk.to_string()));
        }

        Ok(None)
    }

    /// Finalise l'ajout d'un fichier au cache
    ///
    /// Cette fonction helper gère le prébuffering et le nettoyage en background
    async fn finalize_download(
        &self,
        pk: &str,
        download: Arc<Download>,
        collection: Option<&str>,
        origin_url: Option<&str>,
        mode: FinalizeMode<'_>,
    ) -> Result<String> {
        // Attendre le prébuffering (pour le cache progressif)
        if self.min_prebuffer_size > 0 {
            download
                .wait_until_min_size(self.min_prebuffer_size)
                .await
                .map_err(|e| anyhow!("Prebuffering failed: {}", e))?;
            tracing::debug!(
                "Prebuffering complete for pk {} ({} bytes)",
                pk,
                self.min_prebuffer_size
            );
        }

        // Ajouter ou commuter la DB selon le mode
        match mode {
            FinalizeMode::InsertNew => {
                self.db.add(pk, None, collection)?;
                if let Some(url) = origin_url {
                    self.db.set_origin_url(pk, url)?;
                }
            }
            FinalizeMode::ConvertLazy { lazy_pk } => {
                self.db.update_lazy_to_downloaded(lazy_pk, pk)?;
            }
        }

        // Sauvegarder les métadonnées techniques du transformer
        if let Some(transform) = download.transform_metadata().await {
            tracing::debug!(
                "Cache: Got transform metadata for pk {}: sr={:?}, bps={:?}, ch={:?}, ts={:?}",
                pk,
                transform.sample_rate,
                transform.bits_per_sample,
                transform.channels,
                transform.total_samples
            );

            if let Some(sr) = transform.sample_rate {
                self.db
                    .set_a_metadata(pk, "sample_rate", serde_json::json!(sr))?;
            }
            if let Some(bps) = transform.bits_per_sample {
                self.db
                    .set_a_metadata(pk, "bits_per_sample", serde_json::json!(bps))?;
            }
            if let Some(ch) = transform.channels {
                self.db
                    .set_a_metadata(pk, "channels", serde_json::json!(ch))?;
            }
            if let Some(ts) = transform.total_samples {
                self.db
                    .set_a_metadata(pk, "total_samples", serde_json::json!(ts))?;

                // Calculer la durée à partir de total_samples et sample_rate
                if let Some(sr) = transform.sample_rate {
                    if sr > 0 {
                        let secs = (ts as f64 / sr as f64).round() as u64;
                        self.db
                            .set_a_metadata(pk, "duration_secs", serde_json::json!(secs))?;
                    }
                }
            }
        } else {
            tracing::debug!("Cache: No transform metadata available for pk {}", pk);
        }

        if let Err(e) = self.enforce_limit().await {
            tracing::warn!("Error enforcing cache limit: {}", e);
        }

        // Lancer une tâche de nettoyage et marquage de complétion en background
        let downloads_clone = self.downloads.clone();
        let pk_clone = pk.to_string();
        let completion_marker = self.get_completion_marker_path(pk);

        tokio::spawn(async move {
            let result = download.wait_until_finished().await;
            downloads_clone.write().await.remove(&pk_clone);

            // Créer le fichier marker de complétion si le téléchargement a réussi
            if result.is_ok() {
                if let Err(e) = std::fs::write(&completion_marker, "") {
                    tracing::warn!(
                        "Failed to create completion marker for pk {}: {}",
                        pk_clone,
                        e
                    );
                } else {
                    tracing::debug!("Created completion marker for pk {}", pk_clone);
                }
            }
        });

        Ok(pk.to_string())
    }

    /// Crée un nouveau cache sans transformer
    ///
    /// # Arguments
    ///
    /// * `dir` - Répertoire de stockage du cache
    /// * `limit` - Limite de taille du cache (nombre d'éléments)
    pub fn new(dir: &str, limit: usize) -> Result<Self> {
        Self::with_transformer(dir, limit, None)
    }

    /// Crée un nouveau cache avec un transformer optionnel
    ///
    /// # Arguments
    ///
    /// * `dir` - Répertoire de stockage du cache
    /// * `limit` - Limite de taille du cache (nombre d'éléments)
    /// * `transformer_factory` - Factory pour créer des transformers à chaque téléchargement
    ///
    /// # Exemple
    ///
    /// ```rust,ignore
    /// use pmocache::{Cache, CacheConfig, StreamTransformer};
    /// use std::sync::Arc;
    ///
    /// struct MyConfig;
    /// impl CacheConfig for MyConfig {
    ///     fn file_extension() -> &'static str { "dat" }
    /// }
    ///
    /// let transformer_factory = Arc::new(|| {
    ///     // Créer un transformer qui effectue une opération personnalisée
    ///     Box::new(|_input, _file, _ctx| {
    ///         Box::pin(async move {
    ///             // Transformation personnalisée
    ///             Ok(())
    ///         })
    ///     }) as StreamTransformer
    /// });
    ///
    /// let cache = Cache::<MyConfig>::with_transformer(
    ///     "./cache",
    ///     1000,
    ///     Some(transformer_factory)
    /// ).unwrap();
    /// ```
    pub fn with_transformer(
        dir: &str,
        limit: usize,
        transformer_factory: Option<Arc<dyn Fn() -> StreamTransformer + Send + Sync>>,
    ) -> Result<Self> {
        let directory = PathBuf::from(dir);
        std::fs::create_dir_all(&directory)?;
        let (db, was_reset) = DB::init(&directory.join("cache.db"))?;

        // Si le schéma a changé, effacer tous les fichiers du cache (cohérence DB/fichiers)
        if was_reset {
            tracing::warn!("Cache schema changed, clearing all cache files in {:?}", directory);
            if let Ok(entries) = std::fs::read_dir(&directory) {
                for entry in entries.flatten() {
                    let path = entry.path();
                    if path.is_file() && path.file_name().map_or(false, |n| n != "cache.db") {
                        std::fs::remove_file(&path).ok();
                    }
                }
            }
        }

        // Créer un channel pour les events (capacité de 100 events en buffer)
        let (served_tx, _) = broadcast::channel(100);

        Ok(Self {
            dir: directory,
            limit,
            db: Arc::new(db),
            downloads: Arc::new(RwLock::new(HashMap::new())),
            serve_subscribers: Arc::new(RwLock::new(HashMap::new())),
            subscriber_counter: AtomicU64::new(1),
            transformer_factory,
            min_prebuffer_size: DEFAULT_PREBUFFER_SIZE,
            served_tx: Some(served_tx),
            lazy_providers: StdRwLock::new(HashMap::new()),
            _phantom: std::marker::PhantomData,
        })
    }

    /// Lance une consolidation en arrière-plan pour un cache existant
    ///
    /// Cette fonction utilitaire lance une tâche asynchrone qui consolide le cache
    /// (supprime les fichiers incomplets sans marker de complétion) et retourne
    /// immédiatement le cache fourni en paramètre.
    ///
    /// Idéal pour les crates spécialisées qui veulent offrir une fonction
    /// `new_cache_with_consolidation` sans dupliquer la logique de lancement.
    ///
    /// # Arguments
    ///
    /// * `cache` - Instance du cache à consolider
    ///
    /// # Returns
    ///
    /// Le même `Arc<Cache<C>>` fourni en paramètre
    ///
    /// # Exemple
    ///
    /// ```rust,ignore
    /// use pmocache::{Cache, CacheConfig};
    /// use std::sync::Arc;
    ///
    /// struct MyConfig;
    /// impl CacheConfig for MyConfig {
    ///     fn file_extension() -> &'static str { "dat" }
    /// }
    ///
    /// async fn create_cache_with_cleanup() -> anyhow::Result<Arc<Cache<MyConfig>>> {
    ///     let cache = Arc::new(Cache::new("./cache", 1000)?);
    ///     Ok(Cache::with_consolidation(cache).await)
    /// }
    /// ```
    pub async fn with_consolidation(cache: Arc<Cache<C>>) -> Arc<Cache<C>> {
        let cache_clone = cache.clone();
        tokio::spawn(async move {
            if let Err(e) = cache_clone.consolidate().await {
                tracing::warn!(
                    "Failed to consolidate {} cache on startup: {}",
                    C::cache_name(),
                    e
                );
            } else {
                tracing::info!(
                    "{} cache consolidated successfully on startup",
                    C::cache_name()
                );
            }
        });
        cache
    }

    /// Configure la taille minimale de prébuffering
    ///
    /// # Arguments
    ///
    /// * `size` - Taille minimale en octets (0 = désactivé)
    ///
    /// # Exemple
    ///
    /// ```rust,no_run
    /// use pmocache::{Cache, CacheConfig};
    ///
    /// struct MyConfig;
    /// impl CacheConfig for MyConfig {
    ///     fn file_extension() -> &'static str { "dat" }
    /// }
    ///
    /// let mut cache = Cache::<MyConfig>::new("./cache", 1000).unwrap();
    /// cache.set_prebuffer_size(1024 * 1024); // 1 MB de prébuffering
    /// ```
    pub fn set_prebuffer_size(&mut self, size: u64) {
        self.min_prebuffer_size = size;
    }

    /// Retourne la taille minimale de prébuffering configurée
    pub fn get_prebuffer_size(&self) -> u64 {
        self.min_prebuffer_size
    }

    /// Enregistre un provider responsable d'un préfixe de lazy PK.
    pub fn register_lazy_provider(&self, provider: Arc<dyn LazyProvider>) {
        let prefix = provider.lazy_prefix().to_string();
        let mut guard = self
            .lazy_providers
            .write()
            .expect("lazy provider registry poisoned");
        guard.insert(prefix, provider);
    }

    /// Désenregistre un provider à partir de son préfixe.
    pub fn unregister_lazy_provider(&self, prefix: &str) {
        let mut guard = self
            .lazy_providers
            .write()
            .expect("lazy provider registry poisoned");
        guard.remove(prefix);
    }

    fn provider_for_lazy_pk(&self, lazy_pk: &str) -> Option<Arc<dyn LazyProvider>> {
        let prefix = lazy_prefix_from_pk(lazy_pk)?;
        let guard = self
            .lazy_providers
            .read()
            .expect("lazy provider registry poisoned");
        guard.get(prefix).cloned()
    }

    /// S'abonne aux diffusions HTTP pour un `pk` donné.
    ///
    /// La callback est appelée à chaque fois qu'un élément est servi avec succès via les routes
    /// HTTP du cache. Si la callback retourne `false`, elle est automatiquement désinscrite ;
    /// retourner `true` permet de rester abonné aux diffusions suivantes.
    ///
    /// Retourne un [`CacheSubscription`] à conserver pour se désabonner explicitement via
    /// [`Cache::unsubscribe_broadcast`].
    ///
    /// # Exemple
    ///
    /// ```rust,no_run
    /// use pmocache::{Cache, CacheConfig, CacheSubscription};
    /// use std::sync::Arc;
    ///
    /// struct MyConfig;
    /// impl CacheConfig for MyConfig {
    ///     fn file_extension() -> &'static str { "dat" }
    /// }
    ///
    /// # async fn demo() -> anyhow::Result<()> {
    /// let cache = Arc::new(Cache::<MyConfig>::new("/tmp/cache", 100)?);
    /// let token: CacheSubscription = cache
    ///     .subscribe_broadcast("abc123", |event| {
    ///         println!("{} served with param {}", event.pk, event.qualifier);
    ///         // Retourner true pour rester abonné
    ///         true
    ///     })
    ///     .await;
    ///
    /// // ... plus tard, pour se désabonner explicitement :
    /// cache.unsubscribe_broadcast(&token).await;
    /// # Ok(())
    /// # }
    /// ```
    pub async fn subscribe_broadcast<F>(
        &self,
        pk: impl Into<String>,
        callback: F,
    ) -> CacheSubscription
    where
        F: Fn(&CacheBroadcastEvent) -> bool + Send + Sync + 'static,
    {
        let pk = pk.into();
        let id = self.subscriber_counter.fetch_add(1, Ordering::Relaxed);
        let mut subscribers = self.serve_subscribers.write().await;
        subscribers
            .entry(pk.clone())
            .or_default()
            .push((id, Arc::new(callback)));

        CacheSubscription { pk, id }
    }

    /// Désabonne une callback précédemment enregistrée via [`Cache::subscribe_broadcast`].
    ///
    /// N'a aucun effet si le token est inconnu ou déjà désinscrit.
    pub async fn unsubscribe_broadcast(&self, token: &CacheSubscription) {
        let mut subscribers = self.serve_subscribers.write().await;
        if let Some(list) = subscribers.get_mut(&token.pk) {
            list.retain(|(id, _)| *id != token.id);
            if list.is_empty() {
                subscribers.remove(&token.pk);
            }
        }
    }

    /// Notifie les abonnés qu'un élément du cache a été diffusé via HTTP.
    ///
    /// Interne au crate : les routes Axum appellent cette méthode lorsqu'une réponse 2xx est
    /// renvoyée. Les callbacks qui retournent `false` sont retirées.
    pub(crate) async fn notify_broadcast(&self, pk: &str, qualifier: &str) {
        let mut subscribers = self.serve_subscribers.write().await;
        if let Some(callbacks) = subscribers.get_mut(pk) {
            let event = CacheBroadcastEvent {
                pk: pk.to_string(),
                qualifier: qualifier.to_string(),
                cache_name: C::cache_name(),
                cache_type: C::cache_type(),
            };

            let mut to_keep = Vec::new();
            for (id, callback) in callbacks.drain(..) {
                if callback(&event) {
                    to_keep.push((id, callback));
                }
            }

            if to_keep.is_empty() {
                subscribers.remove(pk);
            } else {
                callbacks.extend(to_keep);
            }
        }
    }

    /// Télécharge un fichier depuis une URL et l'ajoute au cache
    ///
    /// Cette méthode utilise un système d'identifiants basé sur le contenu plutôt que sur l'URL.
    /// Elle télécharge les 512 premiers octets du fichier pour calculer un identifiant unique (pk),
    /// puis vérifie si le fichier est déjà en cache. Si c'est le cas, elle met à jour le timestamp
    /// et retourne rapidement. Sinon, elle lance le téléchargement complet en arrière-plan.
    ///
    /// # Workflow
    ///
    /// 1. Télécharge les 512 premiers octets via une requête HTTP partielle
    /// 2. Calcule le pk en hashant (SHA256) ces premiers octets
    /// 3. Vérifie si le fichier existe déjà dans le cache
    /// 4. Si oui : update timestamp et retour rapide
    /// 5. Si non : lance le téléchargement complet en background
    ///
    /// # Arguments
    ///
    /// * `url` - URL du fichier à télécharger
    /// * `collection` - Collection optionnelle à laquelle appartient le fichier
    ///
    /// # Returns
    ///
    /// La clé primaire (pk) du fichier dans le cache, calculée à partir du contenu
    ///
    /// # Note
    ///
    /// Deux URLs différentes pointant vers le même contenu auront le même pk,
    /// permettant une déduplication automatique.
    pub async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
        // 0. Vérifier d'abord si cette URL est déjà en cache (optimisation réseau)
        if let Ok(Some(existing_pk)) = self.db.get_pk_by_origin_url(url) {
            // Vérifier que le fichier est toujours complet et valide
            if self.check_cached_and_complete(&existing_pk).await? {
                tracing::debug!(
                    "URL {} already in cache with pk {}, skipping download",
                    url,
                    existing_pk
                );
                self.db.update_hit(&existing_pk)?;
                return Ok(existing_pk);
            } else {
                tracing::debug!(
                    "URL {} found in DB with pk {} but file is incomplete, re-downloading",
                    url,
                    existing_pk
                );
            }
        }

        // 1. Télécharger les 2048 premiers octets pour calculer le pk
        let header = crate::download::peek_header(url, 2048)
            .await
            .map_err(|e| anyhow!("Failed to peek header: {}", e))?;

        // 2. Calculer le pk basé sur le contenu
        let pk = crate::cache_trait::pk_from_content_header(&header);
        tracing::debug!("Computed pk {} for URL {}", pk, url);

        // 3. Vérifier si le fichier est déjà en cache ET complet
        if self.check_cached_and_complete(&pk).await? {
            tracing::debug!("File with pk {} already in cache, updating timestamp", pk);
            self.db.update_hit(&pk)?;
            return Ok(pk);
        }

        // 4. Vérifier si un download est déjà en cours pour ce pk
        if let Some(pk) = self.check_ongoing_download(&pk).await? {
            return Ok(pk);
        }

        // 5. Lancer le téléchargement complet avec transformer
        tracing::debug!("Starting full download for pk {} from URL {}", pk, url);
        let file_path = self.get_file_path(&pk);
        let transformer = self.transformer_factory.as_ref().map(|f| f());
        let download = download_with_transformer(&file_path, url, transformer);

        // Stocker dans la map des downloads en cours
        {
            let mut downloads = self.downloads.write().await;
            downloads.insert(pk.clone(), download.clone());
        }

        // Finaliser avec prébuffering et nettoyage
        self.finalize_download(
            &pk,
            download,
            collection,
            Some(url),
            FinalizeMode::InsertNew,
        )
        .await
    }

    /// Télécharge un fichier lazy et commute l'entrée existante
    pub async fn download_lazy_from_url(
        &self,
        lazy_pk: &str,
        url: &str,
        collection: Option<&str>,
    ) -> Result<String> {
        // Si déjà converti, retourner directement
        if let Ok(Some(real_pk)) = self.db.get_pk_by_lazy_pk(lazy_pk) {
            return Ok(real_pk);
        }

        // Si l'URL pointe déjà vers un fichier complet, commuter sans re-télécharger
        if let Ok(Some(existing_pk)) = self.db.get_pk_by_origin_url(url) {
            if existing_pk != lazy_pk && self.check_cached_and_complete(&existing_pk).await? {
                self.db.update_lazy_to_downloaded(lazy_pk, &existing_pk)?;
                return Ok(existing_pk);
            }
        }

        // 1. Télécharger les 2048 premiers octets pour calculer le pk
        let header = crate::download::peek_header(url, 2048)
            .await
            .map_err(|e| anyhow!("Failed to peek header: {}", e))?;
        let pk = crate::cache_trait::pk_from_content_header(&header);

        // 2. Si déjà en cache (complet), commuter directement
        if self.check_cached_and_complete(&pk).await? {
            self.db.update_lazy_to_downloaded(lazy_pk, &pk)?;
            return Ok(pk);
        }

        // 3. Lancer le téléchargement complet
        tracing::debug!("Starting lazy download for pk {} (lazy {})", pk, lazy_pk);
        let file_path = self.get_file_path(&pk);
        let transformer = self.transformer_factory.as_ref().map(|f| f());
        let download = download_with_transformer(&file_path, url, transformer);

        {
            let mut downloads = self.downloads.write().await;
            downloads.insert(pk.clone(), download.clone());
        }

        self.finalize_download(
            &pk,
            download,
            collection,
            Some(url),
            FinalizeMode::ConvertLazy { lazy_pk },
        )
        .await
    }

    /// Ajoute un fichier à partir d'un flux asynchrone.
    ///
    /// Cette méthode utilise le même système d'identifiants basé sur le contenu que `add_from_url`.
    /// Elle lit les 512 premiers octets du flux pour calculer l'identifiant, puis reconstitue
    /// le flux complet pour l'ingestion.
    ///
    /// # Workflow
    ///
    /// 1. Lit les 512 premiers octets du reader
    /// 2. Calcule le pk en hashant (SHA256) ces premiers octets
    /// 3. Vérifie si le fichier existe déjà dans le cache
    /// 4. Si oui : update timestamp et retour rapide
    /// 5. Si non : reconstitue le reader (header + reste) et lance l'ingestion
    ///
    /// # Arguments
    ///
    /// * `source_uri` - Identifiant logique optionnel du flux (pour traçabilité dans la DB). Si None, l'origin_url ne sera pas sauvegardée.
    /// * `reader` - Flux asynchrone fournissant les données
    /// * `length` - Taille attendue (si connue)
    /// * `collection` - Collection optionnelle à laquelle appartient l'élément
    ///
    /// # Returns
    ///
    /// La clé primaire (pk) du fichier dans le cache, calculée à partir du contenu
    pub async fn add_from_reader<R>(
        &self,
        source_uri: Option<&str>,
        reader: R,
        length: Option<u64>,
        collection: Option<&str>,
    ) -> Result<String>
    where
        R: AsyncRead + Send + Unpin + 'static,
    {
        self.add_from_reader_with_pk(source_uri, reader, length, collection, None)
            .await
    }

    /// Ajoute un fichier à partir d'un flux avec un pk explicite optionnel.
    ///
    /// Si `explicit_pk` est fourni, utilise ce pk au lieu de le calculer à partir du contenu.
    /// Ceci est utile quand plusieurs fichiers ont le même header mais doivent être cachés séparément
    /// (par exemple, des fichiers FLAC avec le même format mais du contenu différent).
    pub async fn add_from_reader_with_pk<R>(
        &self,
        source_uri: Option<&str>,
        mut reader: R,
        length: Option<u64>,
        collection: Option<&str>,
        explicit_pk: Option<String>,
    ) -> Result<String>
    where
        R: AsyncRead + Send + Unpin + 'static,
    {
        // 1. Lire EXACTEMENT 1024 octets (ou EOF si fichier plus petit)
        // Utilise read_exact_or_eof qui boucle jusqu'à avoir tous les octets demandés
        let header = crate::download::read_exact_or_eof(&mut reader, 1024)
            .await
            .map_err(|e| anyhow!("Failed to read header bytes: {}", e))?;

        // 2. Calculer le pk selon la taille du fichier
        // - Fichiers >= 1024 octets (FLAC): skip header (512 premiers octets), utilise octets 512-1024
        // - Fichiers < 1024 octets (images, petits fichiers): utilise TOUT le contenu
        let pk = if let Some(explicit) = explicit_pk {
            explicit
        } else {
            let pk_bytes = if header.len() >= 1024 {
                // Gros fichier (>= 1024 octets): skip les 512 premiers (header FLAC)
                &header[512..]
            } else {
                // Petit fichier (< 1024 octets): utiliser TOUT le contenu
                &header[..]
            };
            crate::cache_trait::pk_from_content_header(pk_bytes)
        };
        if let Some(uri) = source_uri {
            tracing::debug!("Computed pk {} for source_uri {}", pk, uri);
        } else {
            tracing::debug!("Computed pk {} from reader", pk);
        }

        // 3. Vérifier si le fichier est déjà en cache ET complet
        if self.check_cached_and_complete(&pk).await? {
            tracing::debug!("File with pk {} already in cache, updating timestamp", pk);
            self.db.update_hit(&pk)?;
            return Ok(pk);
        }

        // 4. Vérifier si un download est déjà en cours pour ce pk
        if let Some(pk) = self.check_ongoing_download(&pk).await? {
            return Ok(pk);
        }

        // 5. Reconstituer le reader complet (header + reste)
        use std::io::Cursor;
        let header_reader = Cursor::new(header);
        let full_reader = header_reader.chain(reader);

        // 6. Lancer l'ingestion avec transformer
        tracing::debug!("Starting ingestion for pk {} from reader", pk);
        let file_path = self.get_file_path(&pk);
        let transformer = self.transformer_factory.as_ref().map(|factory| factory());
        let download = ingest_with_transformer(&file_path, full_reader, length, transformer);

        {
            let mut downloads = self.downloads.write().await;
            downloads.insert(pk.clone(), download.clone());
        }

        // Finaliser avec prébuffering et nettoyage
        self.finalize_download(
            &pk,
            download,
            collection,
            source_uri,
            FinalizeMode::InsertNew,
        )
        .await
    }

    /// Ajoute un fichier local au cache en copiant son contenu.
    pub async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String> {
        let canonical_path = std::fs::canonicalize(path)?;
        let file_url = format!("file://{}", canonical_path.display());
        let length = tokio::fs::metadata(&canonical_path)
            .await
            .ok()
            .map(|m| m.len());
        let reader = tokio::fs::File::open(&canonical_path).await?;

        self.add_from_reader(Some(&file_url), reader, length, collection)
            .await
    }

    /// Enregistre une référence vers un fichier déjà présent sur le disque sans duplication.
    pub async fn register_local_file_reference(
        &self,
        pk: &str,
        source_path: &Path,
        collection: Option<&str>,
        origin_url: Option<&str>,
        extra_metadata: Option<&[(String, Value)]>,
    ) -> Result<()> {
        let cache_path = self.get_file_path(pk);
        if cache_path.exists() {
            if let Err(err) = tokio::fs::remove_file(&cache_path).await {
                if err.kind() != std::io::ErrorKind::NotFound {
                    return Err(err.into());
                }
            }
        }

        link_file(source_path, &cache_path)
            .map_err(|e| anyhow!("Failed to link local file into cache: {}", e))?;

        let completion_marker = self.get_completion_marker_path(pk);
        if completion_marker.exists() {
            let _ = std::fs::remove_file(&completion_marker);
        }
        std::fs::write(&completion_marker, "")
            .map_err(|e| anyhow!("Failed to create completion marker for local file: {}", e))?;

        self.db.add(pk, None, collection)?;
        if let Some(url) = origin_url {
            self.db.set_origin_url(pk, url)?;
        }

        if let Some(entries) = extra_metadata {
            for (key, value) in entries {
                self.db.set_a_metadata(pk, key, value.clone())?;
            }
        }

        if let Err(e) = self.enforce_limit().await {
            tracing::warn!(
                "Error enforcing cache limit after local file registration (pk={}): {}",
                pk,
                e
            );
        }

        Ok(())
    }

    pub async fn delete_item(&self, pk: &str) -> Result<()> {
        // Vérifie l'existence pour signaler une erreur explicite si l'entrée est absente
        self.db.get(pk, false)?;

        // Oublie un téléchargement en cours pour cette clé
        self.downloads.write().await.remove(pk);

        // Supprime chaque fichier {pk}.{qualifier}.{ext} (ignorer si déjà absent)
        for path in self.get_file_paths(pk)? {
            if let Err(err) = tokio::fs::remove_file(&path).await {
                if err.kind() != std::io::ErrorKind::NotFound {
                    return Err(err.into());
                }
            }
        }

        // Efface l’entrée de la base (les métadonnées partent via ON DELETE CASCADE)
        self.db.delete(pk)?;

        Ok(())
    }

    pub async fn delete_collection(&self, collection: &str) -> Result<()> {
        let entries = self.db.get_by_collection(collection, false)?;

        {
            let mut downloads = self.downloads.write().await;
            for entry in &entries {
                downloads.remove(&entry.pk);
            }
        }

        for entry in &entries {
            for path in self.get_file_paths(&entry.pk)? {
                if let Err(err) = tokio::fs::remove_file(&path).await {
                    if err.kind() != std::io::ErrorKind::NotFound {
                        return Err(err.into());
                    }
                }
            }
        }

        self.db.delete_collection(collection)?;
        Ok(())
    }

    /// Récupère le chemin d'un fichier dans le cache
    ///
    /// # Arguments
    ///
    /// * `pk` - Clé primaire du fichier
    pub async fn get(&self, pk: &str) -> Result<PathBuf> {
        self.db.get(pk, false)?;
        self.db.update_hit(pk)?;

        let file_path = self.get_file_path(pk);
        if file_path.exists() {
            Ok(file_path)
        } else {
            Err(anyhow!("File not found"))
        }
    }

    /// Récupère une métadonnée précise pour une entrée du cache.
    pub async fn get_a_metadata(&self, pk: &str, key: &str) -> Result<Option<Value>> {
        Ok(self.db.get_metadata_value(pk, key)?)
    }

    /// Récupère une métadonnée en tant que chaîne, si disponible.
    pub async fn get_a_metadata_as_string(&self, pk: &str, key: &str) -> Result<Option<String>> {
        match self.get_a_metadata(pk, key).await? {
            Some(Value::String(s)) => Ok(Some(s)),
            Some(Value::Null) | None => Ok(None),
            Some(other) => bail!("metadata '{key}' for pk '{pk}' is not a string (found {other})"),
        }
    }

    /// Récupère une métadonnée en tant que nombre JSON (`serde_json::Number`).
    pub async fn get_a_metadata_as_number(&self, pk: &str, key: &str) -> Result<Option<Number>> {
        match self.get_a_metadata(pk, key).await? {
            Some(Value::Number(n)) => Ok(Some(n)),
            Some(Value::Null) | None => Ok(None),
            Some(other) => bail!("metadata '{key}' for pk '{pk}' is not a number (found {other})"),
        }
    }

    /// Récupère une métadonnée en tant que booléen.
    pub async fn get_a_metadata_as_bool(&self, pk: &str, key: &str) -> Result<Option<bool>> {
        match self.get_a_metadata(pk, key).await? {
            Some(Value::Bool(b)) => Ok(Some(b)),
            Some(Value::Null) | None => Ok(None),
            Some(other) => bail!("metadata '{key}' for pk '{pk}' is not a boolean (found {other})"),
        }
    }
    pub async fn touch(&self, pk: &str) -> Result<()> {
        self.db.update_hit(pk)?;
        Ok(())
    }

    /// Épingle un item pour le protéger de l'éviction LRU
    ///
    /// Un item épinglé ne peut pas être supprimé automatiquement par la politique LRU
    /// et ne compte pas dans la limite du cache.
    ///
    /// # Arguments
    ///
    /// * `pk` - Clé primaire de l'item à épingler
    ///
    /// # Errors
    ///
    /// Retourne une erreur si l'item a un TTL défini (incompatibilité métier)
    pub async fn pin(&self, pk: &str) -> Result<()> {
        self.db.pin(pk).map_err(|e| anyhow!(e))
    }

    /// Désépingle un item pour le rendre à nouveau éligible à l'éviction LRU
    ///
    /// # Arguments
    ///
    /// * `pk` - Clé primaire de l'item à désépingler
    pub async fn unpin(&self, pk: &str) -> Result<()> {
        self.db.unpin(pk).map_err(|e| anyhow!(e))
    }

    /// Vérifie si un item est épinglé
    ///
    /// # Arguments
    ///
    /// * `pk` - Clé primaire de l'item
    ///
    /// # Returns
    ///
    /// `true` si l'item est épinglé, `false` sinon
    pub async fn is_pinned(&self, pk: &str) -> Result<bool> {
        self.db.is_pinned(pk).map_err(|e| anyhow!(e))
    }

    /// Définit le TTL (Time To Live) d'un item
    ///
    /// # Arguments
    ///
    /// * `pk` - Clé primaire de l'item
    /// * `expires_at` - Date/heure d'expiration au format RFC3339
    ///
    /// # Errors
    ///
    /// Retourne une erreur si l'item est épinglé (incompatibilité métier)
    pub async fn set_ttl(&self, pk: &str, expires_at: &str) -> Result<()> {
        self.db.set_ttl(pk, expires_at).map_err(|e| anyhow!(e))
    }

    /// Supprime le TTL d'un item
    ///
    /// # Arguments
    ///
    /// * `pk` - Clé primaire de l'item
    pub async fn clear_ttl(&self, pk: &str) -> Result<()> {
        self.db.clear_ttl(pk).map_err(|e| anyhow!(e))
    }

    /// Récupère tous les fichiers d'une collection
    ///
    /// # Arguments
    ///
    /// * `collection` - Identifiant de la collection
    pub async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>> {
        let entries = self.db.get_by_collection(collection, false)?;
        let mut paths = Vec::new();

        for entry in entries {
            let path = self.get_file_path(&entry.pk);
            if path.exists() {
                paths.push(path);
            }
        }

        Ok(paths)
    }

    /// Supprime tous les fichiers et entrées du cache
    pub async fn purge(&self) -> Result<()> {
        let mut entries = tokio::fs::read_dir(&self.dir).await?;
        while let Some(entry) = entries.next_entry().await? {
            if entry.path().is_file() && entry.path() != self.dir.join("cache.db") {
                tokio::fs::remove_file(entry.path()).await?;
            }
        }

        self.db
            .purge()
            .map_err(|e| anyhow!("Database error: {}", e))
    }

    /// Consolide le cache en supprimant les orphelins et en re-téléchargeant les fichiers manquants
    ///
    /// Cette fonction :
    /// - Supprime les entrées DB sans fichiers (ou re-télécharge si URL disponible)
    /// - Supprime les fichiers sans marker de complétion et leurs entrées DB
    /// - Supprime les fichiers sans entrées DB correspondantes
    pub async fn consolidate(&self) -> Result<()> {
        // Récupérer la liste des entrées à traiter
        let entries = self.db.get_all(false)?;

        // Supprimer les entrées sans fichiers correspondants OU sans marker de complétion
        for entry in entries {
            let file_path = self.get_file_path(&entry.pk);
            let completion_marker = self.get_completion_marker_path(&entry.pk);

            if !file_path.exists() {
                // Fichier manquant, essayer de re-télécharger
                match self.db.get_origin_url(&entry.pk)? {
                    Some(url) => {
                        if let Err(err) = self.add_from_url(&url, entry.collection.as_deref()).await
                        {
                            tracing::warn!(
                                "Unable to redownload missing file for {}: {}",
                                entry.pk,
                                err
                            );
                            self.db.delete(&entry.pk)?;
                        }
                    }
                    None => {
                        self.db.delete(&entry.pk)?;
                    }
                }
            } else if !completion_marker.exists() {
                // Fichier existe mais pas de marker de complétion -> fichier incomplet
                tracing::warn!(
                    "Removing incomplete file {} (no completion marker)",
                    entry.pk
                );
                let _ = tokio::fs::remove_file(&file_path).await;
                self.db.delete(&entry.pk)?;
            }
        }

        // Supprimer les fichiers sans entrées DB correspondantes
        let mut dir_entries = tokio::fs::read_dir(&self.dir).await?;
        while let Some(entry) = dir_entries.next_entry().await? {
            let path = entry.path();
            if path.is_file() && path != self.dir.join("cache.db") {
                if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
                    // Ignorer les fichiers .complete
                    if file_name.ends_with(".complete") {
                        continue;
                    }

                    // Format attendu: {pk}.{qualifier}.{EXT}
                    // On extrait le pk (première partie avant le premier point)
                    if let Some(pk) = file_name.split('.').next() {
                        if self.db.get(pk, false).is_err() {
                            tracing::debug!("Removing orphan file: {}", file_name);
                            tokio::fs::remove_file(&path).await?;
                            // Supprimer aussi le marker de complétion s'il existe
                            let completion_marker = self.get_completion_marker_path(pk);
                            let _ = tokio::fs::remove_file(&completion_marker).await;
                        }
                    }
                }
            }
        }

        Ok(())
    }

    /// Récupère l'objet Download pour un pk donné (si en cours)
    ///
    /// # Arguments
    ///
    /// * `pk` - Clé primaire du fichier
    ///
    /// # Returns
    ///
    /// Some(Download) si le téléchargement est en cours, None sinon
    pub async fn get_download(&self, pk: &str) -> Option<Arc<Download>> {
        let downloads = self.downloads.read().await;
        downloads.get(pk).cloned()
    }

    /// Vérifie si le téléchargement/ingestion d'un fichier est complètement terminé
    ///
    /// Cette méthode vérifie l'existence du fichier marker de complétion (.complete)
    /// qui est créé uniquement quand le fichier est complètement écrit et fermé.
    ///
    /// Utile pour différencier:
    /// - EOF temporaire : fichier encore en cours d'écriture (retourne false)
    /// - EOF réel : fichier complètement écrit (retourne true)
    ///
    /// # Arguments
    ///
    /// * `pk` - Clé primaire du fichier
    ///
    /// # Returns
    ///
    /// `true` si le fichier est complètement écrit (marker existe), `false` sinon
    pub fn is_download_complete(&self, pk: &str) -> bool {
        let completion_marker = self.get_completion_marker_path(pk);
        completion_marker.exists()
    }

    /// Retourne la taille actuelle téléchargée (source)
    ///
    /// Si le download est en cours, retourne la taille téléchargée.
    /// Sinon, retourne la taille du fichier sur disque.
    ///
    /// # Arguments
    ///
    /// * `pk` - Clé primaire du fichier
    pub async fn current_size(&self, pk: &str) -> Option<u64> {
        if let Some(download) = self.get_download(pk).await {
            Some(download.current_size().await)
        } else {
            // Fichier terminé, lire la taille du fichier
            let file_path = self.get_file_path(pk);
            if file_path.exists() {
                std::fs::metadata(file_path).ok().map(|m| m.len())
            } else {
                None
            }
        }
    }

    /// Retourne la taille des données transformées
    ///
    /// Si le download est en cours, retourne la taille transformée.
    /// Sinon, retourne la taille du fichier sur disque.
    ///
    /// # Arguments
    ///
    /// * `pk` - Clé primaire du fichier
    pub async fn transformed_size(&self, pk: &str) -> Option<u64> {
        if let Some(download) = self.get_download(pk).await {
            Some(download.transformed_size().await)
        } else {
            // Fichier terminé, lire la taille du fichier
            let file_path = self.get_file_path(pk);
            if file_path.exists() {
                std::fs::metadata(file_path).ok().map(|m| m.len())
            } else {
                None
            }
        }
    }

    /// Retourne la taille attendue du fichier (si disponible)
    ///
    /// # Arguments
    ///
    /// * `pk` - Clé primaire du fichier
    pub async fn expected_size(&self, pk: &str) -> Option<u64> {
        if let Some(download) = self.get_download(pk).await {
            download.expected_size().await
        } else {
            // Fichier terminé, la taille finale est la taille du fichier
            self.transformed_size(pk).await
        }
    }

    /// Retourne les métadonnées de transformation (si disponibles)
    pub async fn transform_metadata(&self, pk: &str) -> Option<crate::download::TransformMetadata> {
        if let Some(download) = self.get_download(pk).await {
            download.transform_metadata().await
        } else {
            None
        }
    }

    /// Indique si le téléchargement est terminé
    ///
    /// # Arguments
    ///
    /// * `pk` - Clé primaire du fichier
    pub async fn is_finished(&self, pk: &str) -> bool {
        if let Some(download) = self.get_download(pk).await {
            download.finished().await
        } else {
            // Pas dans la map = terminé (ou n'existe pas)
            self.get_file_path(pk).exists()
        }
    }

    /// Attend qu'un fichier atteigne au moins une taille minimale
    ///
    /// # Arguments
    ///
    /// * `pk` - Clé primaire du fichier
    /// * `min_size` - Taille minimale attendue en bytes
    pub async fn wait_until_min_size(&self, pk: &str, min_size: u64) -> Result<()> {
        if let Some(download) = self.get_download(pk).await {
            download
                .wait_until_min_size(min_size)
                .await
                .map_err(|e| anyhow!("Download error: {}", e))
        } else {
            // Déjà terminé ou n'existe pas
            if self.get_file_path(pk).exists() {
                Ok(())
            } else {
                Err(anyhow!("File not found"))
            }
        }
    }

    /// Attend que le téléchargement soit complètement terminé
    ///
    /// # Arguments
    ///
    /// * `pk` - Clé primaire du fichier
    pub async fn wait_until_finished(&self, pk: &str) -> Result<()> {
        if let Some(download) = self.get_download(pk).await {
            download
                .wait_until_finished()
                .await
                .map_err(|e| anyhow!("Download error: {}", e))
        } else {
            // Déjà terminé ou n'existe pas
            if self.get_file_path(pk).exists() {
                Ok(())
            } else {
                Err(anyhow!("File not found"))
            }
        }
    }

    /// Retourne le répertoire du cache
    pub fn cache_dir(&self) -> &Path {
        &self.dir
    }

    /// Construit le chemin complet d'un fichier dans le cache avec le param par défaut
    ///
    /// Format: `{pk}.{default_param}.{extension}`
    pub fn get_file_path(&self, pk: &str) -> PathBuf {
        self.get_file_path_with_qualifier(pk, C::default_param())
    }

    /// Construit le chemin d'un fichier dans le cache avec un qualificatif
    ///
    /// Format: `{pk}.{qualifier}.{extension}`
    pub fn get_file_path_with_qualifier(&self, pk: &str, qualifier: &str) -> PathBuf {
        self.dir
            .join(format!("{}.{}.{}", pk, qualifier, C::file_extension()))
    }

    /// Retourne tous les chemins de fichiers stockés pour une clé donnée,
    /// quel que soit le qualifier.
    ///
    /// Format: `{pk}.*.{extension}`
    pub fn get_file_paths(&self, pk: &str) -> Result<Vec<PathBuf>> {
        let mut paths = Vec::new();
        let prefix = format!("{pk}.");
        let expected_ext = C::file_extension();

        for entry in std::fs::read_dir(&self.dir)? {
            let entry = entry?;
            let path = entry.path();

            if !path.is_file() {
                continue;
            }

            let file_name = match entry.file_name().into_string() {
                Ok(name) => name,
                Err(_) => continue, // nom de fichier non UTF-8 : on l’ignore
            };

            if !file_name.starts_with(&prefix) {
                continue;
            }

            if !file_name.ends_with(expected_ext) {
                continue;
            }

            paths.push(path);
        }

        Ok(paths)
    }

    /// Valide les données avant de les stocker
    /// Par défaut, accepte toutes les données
    pub fn validate_data(&self, data: &[u8]) -> Result<Vec<u8>> {
        Ok(data.to_vec())
    }

    /// Applique la politique d'éviction LRU (Least Recently Used)
    ///
    /// Si le nombre d'entrées non épinglées dépasse la limite configurée, supprime
    /// les entrées les plus anciennes (moins récemment utilisées).
    ///
    /// Les items épinglés sont exclus du comptage et ne peuvent pas être supprimés.
    ///
    /// Cette méthode :
    /// 1. Compte le nombre d'entrées non épinglées
    /// 2. Si > limit, récupère les N entrées les plus anciennes (non épinglées)
    /// 3. Supprime ces entrées de la DB et leurs fichiers du disque
    /// 4. Supprime également les items expirés (TTL dépassé)
    ///
    /// # Returns
    ///
    /// Le nombre d'entrées supprimées
    pub async fn enforce_limit(&self) -> Result<usize> {
        let mut total_removed = 0;

        // 1. Supprimer d'abord les items expirés (TTL dépassé)
        let expired_entries = self.db.get_expired()?;
        for entry in expired_entries {
            if let Ok(paths) = self.get_file_paths(&entry.pk) {
                for path in paths {
                    let _ = tokio::fs::remove_file(path).await;
                }
            }

            if let Err(e) = self.db.delete(&entry.pk) {
                tracing::warn!("Error deleting expired entry {} from DB: {}", entry.pk, e);
            } else {
                total_removed += 1;
                tracing::debug!(
                    "Removed expired item {} (TTL: {:?})",
                    entry.pk,
                    entry.ttl_expires_at
                );
            }
        }

        // 2. Compter seulement les items non épinglés
        let count = self.db.count_unpinned()?;

        if count <= self.limit {
            if total_removed > 0 {
                tracing::info!("Cache cleanup: removed {} expired entries", total_removed);
            }
            return Ok(total_removed);
        }

        // 3. Supprimer les plus vieux items non épinglés si nécessaire
        let to_remove = count - self.limit;
        let old_entries = self.db.get_oldest(to_remove)?;

        let mut lru_removed = 0;
        for entry in old_entries {
            // Utiliser get_file_paths() pour obtenir tous les fichiers de cette entrée
            if let Ok(paths) = self.get_file_paths(&entry.pk) {
                for path in paths {
                    let _ = tokio::fs::remove_file(path).await;
                }
            }

            // Supprimer de la base de données
            if let Err(e) = self.db.delete(&entry.pk) {
                tracing::warn!("Error deleting entry {} from DB: {}", entry.pk, e);
            } else {
                lru_removed += 1;
            }
        }

        total_removed += lru_removed;

        if total_removed > 0 {
            tracing::info!(
                "LRU eviction: removed {} old entries (unpinned cache size: {} -> {})",
                lru_removed,
                count,
                count - lru_removed
            );
        }

        Ok(total_removed)
    }

    // ============================================================================
    // LAZY PK SUPPORT - Methods
    // ============================================================================

    /// S'abonne aux events du cache (lazy downloads, etc.)
    ///
    /// Retourne un receiver pour écouter les events. Chaque abonné reçoit
    /// une copie indépendante des events.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// use pmocache::{Cache, CacheConfig, CacheEvent};
    ///
    /// struct AudioConfig;
    /// impl CacheConfig for AudioConfig {
    ///     fn file_extension() -> &'static str { "flac" }
    /// }
    ///
    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
    /// let cache = Cache::<AudioConfig>::new("./cache", 1000)?;
    /// let mut rx = cache.subscribe_events();
    ///
    /// tokio::spawn(async move {
    ///     while let Ok(event) = rx.recv().await {
    ///         match event {
    ///             CacheEvent::LazyDownloaded { lazy_pk, real_pk } => {
    ///                 println!("Lazy {} → Real {}", lazy_pk, real_pk);
    ///             }
    ///             _ => {}
    ///         }
    ///     }
    /// });
    /// # Ok(())
    /// # }
    /// ```
    pub fn subscribe_events(&self) -> broadcast::Receiver<CacheEvent> {
        self.served_tx
            .as_ref()
            .expect("Cache event channel not initialized")
            .subscribe()
    }

    /// Broadcast un event quand un lazy PK est téléchargé
    ///
    /// Cette méthode est appelée après qu'un fichier lazy a été téléchargé
    /// et son real pk calculé. Elle permet aux playlists de commuter leurs PK.
    pub async fn broadcast_lazy_downloaded(&self, lazy_pk: &str, real_pk: &str) {
        if let Some(tx) = &self.served_tx {
            let event = CacheEvent::LazyDownloaded {
                lazy_pk: lazy_pk.to_string(),
                real_pk: real_pk.to_string(),
            };
            // Ignorer l'erreur si pas d'abonnés
            let _ = tx.send(event);
        }
    }

    /// Garantit l'existence d'une entrée lazy spécifique.
    pub async fn ensure_lazy_entry(
        &self,
        lazy_pk: &str,
        collection: Option<&str>,
        origin_url: Option<&str>,
    ) -> Result<()> {
        if let Ok(true) = self.db.has_lazy_entry(lazy_pk) {
            self.db.update_hit_by_lazy_pk(lazy_pk)?;
        } else {
            self.db.add_lazy(lazy_pk, None, collection)?;
        }

        if let Some(url) = origin_url {
            self.db.set_origin_url_for_lazy(lazy_pk, url)?;
        }

        Ok(())
    }

    /// Récupère auprès du provider les métadonnées/couvertures associées.
    pub async fn fetch_lazy_provider_data(&self, lazy_pk: &str) -> Result<LazyEntryRemoteData> {
        if let Some(provider) = self.provider_for_lazy_pk(lazy_pk) {
            let metadata = provider.metadata(lazy_pk).await?;
            let cover_url = provider.cover_url(lazy_pk).await?;
            Ok(LazyEntryRemoteData {
                metadata,
                cover_url,
            })
        } else {
            Ok(LazyEntryRemoteData::default())
        }
    }

    /// Résout l'URL d'origine pour un lazy PK, via la DB ou un provider.
    pub async fn resolve_lazy_url(&self, lazy_pk: &str) -> Result<String> {
        if let Ok(Some(url)) = self.db.get_origin_url(lazy_pk) {
            return Ok(url);
        }

        if let Some(provider) = self.provider_for_lazy_pk(lazy_pk) {
            return provider.get_url(lazy_pk).await;
        }

        bail!("No origin URL or lazy provider registered for {}", lazy_pk);
    }

    /// Télécharge un lazy PK en résolvant automatiquement son URL.
    pub async fn download_lazy(&self, lazy_pk: &str, collection: Option<&str>) -> Result<String> {
        let (existing_pk, _lazy_sec, existing_collection) = self
            .db
            .get_entry_by_pk_or_lazy_pk(lazy_pk)?
            .ok_or_else(|| anyhow!("Lazy pk {} not found in DB", lazy_pk))?;

        let url = self.resolve_lazy_url(lazy_pk).await?;
        let collection = collection.or(existing_collection.as_deref());

        if let Some(real_pk) = existing_pk {
            if real_pk != lazy_pk && self.check_cached_and_complete(&real_pk).await? {
                return Ok(real_pk);
            }
        }

        self.download_lazy_from_url(lazy_pk, &url, collection).await
    }

    /// Ajoute une URL sans lancer immédiatement le téléchargement.
    pub async fn add_from_url_deferred(
        &self,
        url: &str,
        collection: Option<&str>,
    ) -> Result<String> {
        if let Ok(Some((pk_opt, lazy_pk_opt))) = self.db.get_entry_by_url(url) {
            if let Some(pk) = pk_opt {
                self.db.update_hit(&pk)?;
                if let Some(lpk) = lazy_pk_opt {
                    return Ok(lpk);
                }
                return Ok(pk);
            } else if let Some(lpk) = lazy_pk_opt {
                self.db.update_hit_by_lazy_pk(&lpk)?;
                return Ok(lpk);
            }
        }

        let lazy_pk = format!("L:{}", generate_lazy_pk(url));
        if let Ok(true) = self.db.has_lazy_entry(&lazy_pk) {
            bail!("Lazy PK collision for URL: {}", url);
        }

        self.ensure_lazy_entry(&lazy_pk, collection, Some(url))
            .await?;
        tracing::debug!("Created new lazy pk {} for URL {}", lazy_pk, url);
        Ok(lazy_pk)
    }
}

fn link_file(source: &Path, destination: &Path) -> std::io::Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::symlink;
        symlink(source, destination)
    }

    #[cfg(windows)]
    {
        use std::os::windows::fs::symlink_file;
        symlink_file(source, destination)
    }

    #[cfg(not(any(unix, windows)))]
    {
        std::fs::hard_link(source, destination)
    }
}

/// Implémentation du trait FileCache pour Cache
impl<C: CacheConfig + 'static> FileCache<C> for Cache<C> {
    fn get_cache_dir(&self) -> &Path {
        self.cache_dir()
    }

    fn get_database(&self) -> Arc<DB> {
        self.db.clone()
    }

    fn validate_data(&self, data: &[u8]) -> Result<Vec<u8>> {
        // Le cache générique accepte toutes les données
        Ok(data.to_vec())
    }

    async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String> {
        self.add_from_url(url, collection).await
    }

    async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String> {
        self.add_from_file(path, collection).await
    }

    async fn get(&self, pk: &str) -> Result<PathBuf> {
        self.get(pk).await
    }

    async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>> {
        self.get_collection(collection).await
    }

    async fn purge(&self) -> Result<()> {
        self.purge().await
    }

    async fn consolidate(&self) -> Result<()> {
        self.consolidate().await
    }
}
