//! Multimodal Embedding Pipeline — automatic embedding for MultimodalRecord
//!
//! Integrates `PerceptionEngine` (Whisper, CLIP, etc.) with `MultimodalRecord`
//! and `EmbeddingCache` for automatic embedding generation and caching.
//!
//! # Architecture
//!
//! ```text
//! MultimodalRecord (audio/image data)
//!        │
//!        ▼
//! embed_multimodal_record()
//!        │
//!        ├── check EmbeddingCache (SHA256 of binary data)
//!        │     ├── HIT  → use cached embedding
//!        │     └── MISS → call PerceptionEngine::align_modality()
//!        │                    └── store in cache
//!        ▼
//! record.embeddings["audio_embedding"] = embedding
//! ```
//!
//! # Usage
//!
//! ```ignore
//! use aifastdb_core::perception::multimodal_pipeline::*;
//! use aifastdb_core::perception::{PerceptionConfig, create_perception_engine};
//! use aifastdb_core::types::multimodal::{MultimodalRecord, MediaType};
//!
//! // Create a Whisper engine
//! let config = PerceptionConfig::whisper_small();
//! let engine = create_perception_engine(&config)?;
//!
//! // Create an audio record
//! let mut record = MultimodalRecord::new("audio-001", MediaType::Audio)
//!     .with_mime_type("audio/wav")
//!     .with_data(wav_bytes);
//!
//! // Auto-embed the audio data
//! embed_multimodal_record(&*engine, &mut record)?;
//! // record.embeddings now contains "audio_embedding" → Vec<f32>
//!
//! // With caching (avoids re-embedding identical audio):
//! let cache = EmbeddingCache::new(EmbeddingCacheConfig::default());
//! embed_multimodal_record_cached(&*engine, &mut record, &cache)?;
//! ```

use crate::types::multimodal::{MediaType, MultimodalRecord};
use crate::vector::embedding::EmbeddingCache;

use super::error::PerceptionError;
use super::traits::PerceptionEngine;

// ============================================================================
// Constants — Embedding Key Names
// ============================================================================

/// Default embedding key for audio content in `MultimodalRecord.embeddings`
pub const AUDIO_EMBEDDING_KEY: &str = "audio_embedding";

/// Default embedding key for image content (CLIP) in `MultimodalRecord.embeddings`
pub const IMAGE_EMBEDDING_KEY: &str = "clip_image";

/// Default embedding key for text content (CLIP text) in `MultimodalRecord.embeddings`
pub const TEXT_EMBEDDING_KEY: &str = "clip_text";

/// Cache modality identifier for Whisper audio embeddings
pub const WHISPER_CACHE_MODALITY: &str = "whisper_audio";

/// Cache modality identifier for CLIP image embeddings
pub const CLIP_IMAGE_CACHE_MODALITY: &str = "clip_image";

// ============================================================================
// Core API — embed_multimodal_record
// ============================================================================

/// Automatically generate and attach embeddings for a `MultimodalRecord`.
///
/// Given a `PerceptionEngine` that supports the record's media type,
/// this function:
/// 1. Extracts the raw binary data from the record
/// 2. Calls `engine.align_modality(data, mime_type)` to generate an embedding
/// 3. Stores the embedding in `record.embeddings` under the appropriate key
///
/// # Embedding Key Mapping
///
/// | MediaType | Embedding Key       |
/// |-----------|---------------------|
/// | Audio     | `audio_embedding`   |
/// | Image     | `clip_image`        |
/// | Video     | (unsupported)       |
///
/// # Arguments
/// * `engine` - The perception engine to use for embedding
/// * `record` - Mutable reference to the record to embed
///
/// # Returns
/// * `Ok(true)` - Embedding was generated and attached
/// * `Ok(false)` - Record has no data to embed (skipped)
/// * `Err(...)` - Engine error during embedding
///
/// # Example
///
/// ```ignore
/// let engine = create_perception_engine(&PerceptionConfig::whisper_small())?;
/// let mut record = MultimodalRecord::new("aud-001", MediaType::Audio)
///     .with_mime_type("audio/wav")
///     .with_data(wav_bytes);
///
/// let embedded = embed_multimodal_record(&*engine, &mut record)?;
/// assert!(embedded);
/// assert!(record.embeddings.contains_key("audio_embedding"));
/// ```
pub fn embed_multimodal_record(
    engine: &dyn PerceptionEngine,
    record: &mut MultimodalRecord,
) -> Result<bool, PerceptionError> {
    // Check if the record has data to embed
    let data = match &record.data {
        Some(d) if !d.is_empty() => d.clone(),
        _ => return Ok(false),
    };

    let mime_type = &record.mime_type;
    let embedding_key = embedding_key_for_media_type(record.media_type);

    // Skip if already embedded with this key
    if record.embeddings.contains_key(embedding_key) {
        tracing::debug!(
            id = %record.id,
            key = embedding_key,
            "Skipping embedding — already exists"
        );
        return Ok(false);
    }

    // Generate embedding via the engine
    tracing::info!(
        id = %record.id,
        mime = %mime_type,
        data_size = data.len(),
        "Generating multimodal embedding"
    );

    let embedding = engine.align_modality(&data, mime_type)?;

    // Attach the embedding to the record
    record
        .embeddings
        .insert(embedding_key.to_string(), embedding);

    tracing::info!(
        id = %record.id,
        key = embedding_key,
        dim = record.embeddings[embedding_key].len(),
        "Multimodal embedding attached"
    );

    Ok(true)
}

/// Same as [`embed_multimodal_record`] but with a custom embedding key.
///
/// Useful when you want to store multiple embeddings per record
/// (e.g., both "whisper_audio" and "clap_audio").
pub fn embed_multimodal_record_with_key(
    engine: &dyn PerceptionEngine,
    record: &mut MultimodalRecord,
    embedding_key: &str,
) -> Result<bool, PerceptionError> {
    let data = match &record.data {
        Some(d) if !d.is_empty() => d.clone(),
        _ => return Ok(false),
    };

    if record.embeddings.contains_key(embedding_key) {
        return Ok(false);
    }

    let embedding = engine.align_modality(&data, &record.mime_type)?;
    record
        .embeddings
        .insert(embedding_key.to_string(), embedding);

    Ok(true)
}

// ============================================================================
// Cached API — embed_multimodal_record_cached
// ============================================================================

/// Generate and attach embeddings with LRU cache support.
///
/// Before calling the engine, checks `EmbeddingCache` using the binary
/// SHA256 hash of the record's data. If a cached embedding is found,
/// it's used directly (avoiding expensive model inference).
///
/// On cache miss, the engine generates the embedding, which is then
/// stored in the cache for future reuse.
///
/// # Cache Key
///
/// The cache key is `{modality}:{SHA256(data)}`, e.g.:
/// - `whisper_audio:a1b2c3d4...`
/// - `clip_image:e5f6g7h8...`
///
/// # Arguments
/// * `engine` - The perception engine to use
/// * `record` - Mutable reference to the record
/// * `cache` - The embedding cache instance
///
/// # Returns
/// * `Ok(EmbedResult::Cached)` - Used cached embedding
/// * `Ok(EmbedResult::Generated)` - Generated new embedding + cached it
/// * `Ok(EmbedResult::Skipped)` - No data or already embedded
/// * `Err(...)` - Engine error
///
/// # Example
///
/// ```ignore
/// let cache = EmbeddingCache::new(EmbeddingCacheConfig::default());
/// let engine = create_perception_engine(&PerceptionConfig::whisper_small())?;
///
/// // First call: generates embedding (cache miss)
/// let result = embed_multimodal_record_cached(&*engine, &mut record1, &cache)?;
/// assert_eq!(result, EmbedResult::Generated);
///
/// // Second call with identical audio: uses cache
/// let result = embed_multimodal_record_cached(&*engine, &mut record2, &cache)?;
/// assert_eq!(result, EmbedResult::Cached);
/// ```
pub fn embed_multimodal_record_cached(
    engine: &dyn PerceptionEngine,
    record: &mut MultimodalRecord,
    cache: &EmbeddingCache,
) -> Result<EmbedResult, PerceptionError> {
    let data = match &record.data {
        Some(d) if !d.is_empty() => d.clone(),
        _ => return Ok(EmbedResult::Skipped),
    };

    let embedding_key = embedding_key_for_media_type(record.media_type);
    let cache_modality = cache_modality_for_media_type(record.media_type);

    // Skip if already embedded
    if record.embeddings.contains_key(embedding_key) {
        return Ok(EmbedResult::Skipped);
    }

    // Check cache first (SHA256 of binary data + modality)
    if let Some(cached_embedding) = cache.get_binary(&data, cache_modality) {
        tracing::debug!(
            id = %record.id,
            key = embedding_key,
            dim = cached_embedding.len(),
            "Using cached multimodal embedding"
        );
        record
            .embeddings
            .insert(embedding_key.to_string(), cached_embedding);
        return Ok(EmbedResult::Cached);
    }

    // Cache miss — generate embedding
    tracing::info!(
        id = %record.id,
        mime = %record.mime_type,
        data_size = data.len(),
        "Cache miss — generating multimodal embedding"
    );

    let embedding = engine.align_modality(&data, &record.mime_type)?;

    // Store in cache
    cache.put_binary(&data, cache_modality, embedding.clone());

    // Attach to record
    record
        .embeddings
        .insert(embedding_key.to_string(), embedding);

    tracing::info!(
        id = %record.id,
        key = embedding_key,
        dim = record.embeddings[embedding_key].len(),
        "Multimodal embedding generated and cached"
    );

    Ok(EmbedResult::Generated)
}

/// Same as [`embed_multimodal_record_cached`] but with custom keys.
pub fn embed_multimodal_record_cached_with_key(
    engine: &dyn PerceptionEngine,
    record: &mut MultimodalRecord,
    cache: &EmbeddingCache,
    embedding_key: &str,
    cache_modality: &str,
) -> Result<EmbedResult, PerceptionError> {
    let data = match &record.data {
        Some(d) if !d.is_empty() => d.clone(),
        _ => return Ok(EmbedResult::Skipped),
    };

    if record.embeddings.contains_key(embedding_key) {
        return Ok(EmbedResult::Skipped);
    }

    // Check cache
    if let Some(cached_embedding) = cache.get_binary(&data, cache_modality) {
        record
            .embeddings
            .insert(embedding_key.to_string(), cached_embedding);
        return Ok(EmbedResult::Cached);
    }

    // Generate
    let embedding = engine.align_modality(&data, &record.mime_type)?;

    // Cache + attach
    cache.put_binary(&data, cache_modality, embedding.clone());
    record
        .embeddings
        .insert(embedding_key.to_string(), embedding);

    Ok(EmbedResult::Generated)
}

// ============================================================================
// Batch API — embed_multimodal_batch
// ============================================================================

/// Embed a batch of `MultimodalRecord`s, optionally with caching.
///
/// Processes each record in the batch, skipping those that already
/// have embeddings or lack data. Returns a summary of results.
///
/// # Arguments
/// * `engine` - The perception engine
/// * `records` - Mutable slice of records to embed
/// * `cache` - Optional embedding cache
///
/// # Returns
/// `BatchEmbedResult` with counts of cached, generated, skipped, and failed records.
pub fn embed_multimodal_batch(
    engine: &dyn PerceptionEngine,
    records: &mut [MultimodalRecord],
    cache: Option<&EmbeddingCache>,
) -> BatchEmbedResult {
    let mut result = BatchEmbedResult::default();
    let total = records.len();

    for (i, record) in records.iter_mut().enumerate() {
        let outcome = if let Some(cache) = cache {
            match embed_multimodal_record_cached(engine, record, cache) {
                Ok(r) => r,
                Err(e) => {
                    tracing::warn!(
                        id = %record.id,
                        error = %e,
                        "Failed to embed multimodal record [{}/{}]",
                        i + 1, total
                    );
                    result.failed += 1;
                    result.errors.push((record.id.clone(), e.to_string()));
                    continue;
                }
            }
        } else {
            match embed_multimodal_record(engine, record) {
                Ok(true) => EmbedResult::Generated,
                Ok(false) => EmbedResult::Skipped,
                Err(e) => {
                    tracing::warn!(
                        id = %record.id,
                        error = %e,
                        "Failed to embed multimodal record [{}/{}]",
                        i + 1, total
                    );
                    result.failed += 1;
                    result.errors.push((record.id.clone(), e.to_string()));
                    continue;
                }
            }
        };

        match outcome {
            EmbedResult::Cached => result.cached += 1,
            EmbedResult::Generated => result.generated += 1,
            EmbedResult::Skipped => result.skipped += 1,
        }
    }

    result
}

// ============================================================================
// Result Types
// ============================================================================

/// Result of a single embedding operation
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmbedResult {
    /// Used a cached embedding (no inference needed)
    Cached,
    /// Generated a new embedding via the engine
    Generated,
    /// Skipped (no data, or already embedded)
    Skipped,
}

/// Result of a batch embedding operation
#[derive(Debug, Clone, Default)]
pub struct BatchEmbedResult {
    /// Number of records that used cached embeddings
    pub cached: usize,
    /// Number of records where new embeddings were generated
    pub generated: usize,
    /// Number of records skipped (no data or already embedded)
    pub skipped: usize,
    /// Number of records that failed
    pub failed: usize,
    /// Error details for failed records: (record_id, error_message)
    pub errors: Vec<(String, String)>,
}

impl BatchEmbedResult {
    /// Total number of records processed
    pub fn total(&self) -> usize {
        self.cached + self.generated + self.skipped + self.failed
    }

    /// Number of records successfully embedded (cached + generated)
    pub fn embedded(&self) -> usize {
        self.cached + self.generated
    }

    /// Whether all records were processed without errors
    pub fn all_succeeded(&self) -> bool {
        self.failed == 0
    }
}

impl std::fmt::Display for BatchEmbedResult {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "BatchEmbed: {} total, {} generated, {} cached, {} skipped, {} failed",
            self.total(),
            self.generated,
            self.cached,
            self.skipped,
            self.failed
        )
    }
}

// ============================================================================
// Helper Functions
// ============================================================================

/// Get the appropriate embedding key for a given media type.
fn embedding_key_for_media_type(media_type: MediaType) -> &'static str {
    match media_type {
        MediaType::Audio => AUDIO_EMBEDDING_KEY,
        MediaType::Image => IMAGE_EMBEDDING_KEY,
        MediaType::Video => AUDIO_EMBEDDING_KEY, // video could use audio track
    }
}

/// Get the cache modality identifier for a given media type.
fn cache_modality_for_media_type(media_type: MediaType) -> &'static str {
    match media_type {
        MediaType::Audio => WHISPER_CACHE_MODALITY,
        MediaType::Image => CLIP_IMAGE_CACHE_MODALITY,
        MediaType::Video => WHISPER_CACHE_MODALITY,
    }
}

// ============================================================================
// Tests
// ============================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crate::perception::traits::DummyPerceptionEngine;
    use crate::perception::Modality;
    use crate::types::multimodal::MediaMetadata;
    use crate::vector::embedding::EmbeddingCacheConfig;

    /// A mock multimodal engine that supports audio
    struct MockAudioEngine {
        dim: usize,
    }

    impl MockAudioEngine {
        fn new(dim: usize) -> Self {
            Self { dim }
        }
    }

    impl PerceptionEngine for MockAudioEngine {
        fn conceptualize(&self, input: &str) -> Result<Vec<f32>, PerceptionError> {
            let mut embedding = vec![0.0f32; self.dim];
            for (i, byte) in input.bytes().enumerate() {
                embedding[i % self.dim] += byte as f32 / 255.0;
            }
            Ok(embedding)
        }

        fn align_modality(&self, data: &[u8], mime_type: &str) -> Result<Vec<f32>, PerceptionError> {
            if !mime_type.starts_with("audio/") {
                return Err(PerceptionError::UnsupportedModality(mime_type.to_string()));
            }
            // Generate deterministic embedding from data bytes
            let mut embedding = vec![0.0f32; self.dim];
            for (i, &byte) in data.iter().enumerate() {
                embedding[i % self.dim] += byte as f32 / 255.0;
            }
            // L2 normalize
            let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
            if norm > 0.0 {
                for x in &mut embedding {
                    *x /= norm;
                }
            }
            Ok(embedding)
        }

        fn dimension(&self) -> usize {
            self.dim
        }

        fn supported_modalities(&self) -> Vec<Modality> {
            vec![Modality::Audio]
        }

        fn model_name(&self) -> &str {
            "mock-audio"
        }
    }

    fn make_audio_record(id: &str, data: Vec<u8>) -> MultimodalRecord {
        let file_size = data.len() as u64;
        MultimodalRecord::new(id, MediaType::Audio)
            .with_mime_type("audio/wav")
            .with_data(data)
            .with_metadata(MediaMetadata::audio("audio/wav", file_size, 5.0))
    }

    // ── embed_multimodal_record tests ────────────────────────────────

    #[test]
    fn test_embed_audio_record() {
        let engine = MockAudioEngine::new(128);
        let mut record = make_audio_record("aud-001", vec![0x52, 0x49, 0x46, 0x46]);

        let result = embed_multimodal_record(&engine, &mut record).unwrap();
        assert!(result);
        assert!(record.embeddings.contains_key(AUDIO_EMBEDDING_KEY));
        assert_eq!(record.embeddings[AUDIO_EMBEDDING_KEY].len(), 128);
    }

    #[test]
    fn test_embed_skips_no_data() {
        let engine = MockAudioEngine::new(128);
        let mut record = MultimodalRecord::new("aud-002", MediaType::Audio)
            .with_mime_type("audio/wav");
        // No data attached

        let result = embed_multimodal_record(&engine, &mut record).unwrap();
        assert!(!result);
        assert!(record.embeddings.is_empty());
    }

    #[test]
    fn test_embed_skips_empty_data() {
        let engine = MockAudioEngine::new(128);
        let mut record = make_audio_record("aud-003", vec![]);

        let result = embed_multimodal_record(&engine, &mut record).unwrap();
        assert!(!result);
    }

    #[test]
    fn test_embed_skips_already_embedded() {
        let engine = MockAudioEngine::new(128);
        let mut record = make_audio_record("aud-004", vec![1, 2, 3, 4]);
        record
            .embeddings
            .insert(AUDIO_EMBEDDING_KEY.to_string(), vec![0.5; 128]);

        let result = embed_multimodal_record(&engine, &mut record).unwrap();
        assert!(!result);
        // Original embedding should be untouched
        assert_eq!(record.embeddings[AUDIO_EMBEDDING_KEY], vec![0.5; 128]);
    }

    #[test]
    fn test_embed_with_custom_key() {
        let engine = MockAudioEngine::new(64);
        let mut record = make_audio_record("aud-005", vec![10, 20, 30]);

        let result =
            embed_multimodal_record_with_key(&engine, &mut record, "whisper_small").unwrap();
        assert!(result);
        assert!(record.embeddings.contains_key("whisper_small"));
        assert_eq!(record.embeddings["whisper_small"].len(), 64);
    }

    #[test]
    fn test_embed_unsupported_modality() {
        let engine = MockAudioEngine::new(128); // Only supports audio
        let mut record = MultimodalRecord::new("img-001", MediaType::Image)
            .with_mime_type("image/jpeg")
            .with_data(vec![0xFF, 0xD8, 0xFF, 0xE0]);

        let result = embed_multimodal_record(&engine, &mut record);
        assert!(result.is_err());
    }

    // ── embed_multimodal_record_cached tests ─────────────────────────

    #[test]
    fn test_cached_embed_generates_on_miss() {
        let engine = MockAudioEngine::new(128);
        let cache = EmbeddingCache::new(EmbeddingCacheConfig {
            max_entries: 100,
            enabled: true,
        });
        let mut record = make_audio_record("aud-010", vec![1, 2, 3, 4, 5]);

        let result = embed_multimodal_record_cached(&engine, &mut record, &cache).unwrap();
        assert_eq!(result, EmbedResult::Generated);
        assert!(record.embeddings.contains_key(AUDIO_EMBEDDING_KEY));

        // Cache should now have the embedding
        let stats = cache.stats();
        assert_eq!(stats.misses, 1);
        assert_eq!(stats.entries, 1);
    }

    #[test]
    fn test_cached_embed_uses_cache_on_hit() {
        let engine = MockAudioEngine::new(128);
        let cache = EmbeddingCache::new(EmbeddingCacheConfig {
            max_entries: 100,
            enabled: true,
        });
        let audio_data = vec![10, 20, 30, 40, 50];

        // First record: generates and caches
        let mut record1 = make_audio_record("aud-011", audio_data.clone());
        let r1 = embed_multimodal_record_cached(&engine, &mut record1, &cache).unwrap();
        assert_eq!(r1, EmbedResult::Generated);

        // Second record with same audio data: should use cache
        let mut record2 = make_audio_record("aud-012", audio_data);
        let r2 = embed_multimodal_record_cached(&engine, &mut record2, &cache).unwrap();
        assert_eq!(r2, EmbedResult::Cached);

        // Both should have the same embedding
        assert_eq!(
            record1.embeddings[AUDIO_EMBEDDING_KEY],
            record2.embeddings[AUDIO_EMBEDDING_KEY]
        );

        let stats = cache.stats();
        assert_eq!(stats.hits, 1);
        assert_eq!(stats.misses, 1);
    }

    #[test]
    fn test_cached_embed_skips_already_embedded() {
        let engine = MockAudioEngine::new(128);
        let cache = EmbeddingCache::new(EmbeddingCacheConfig {
            max_entries: 100,
            enabled: true,
        });

        let mut record = make_audio_record("aud-013", vec![1, 2, 3]);
        record
            .embeddings
            .insert(AUDIO_EMBEDDING_KEY.to_string(), vec![0.1; 128]);

        let result = embed_multimodal_record_cached(&engine, &mut record, &cache).unwrap();
        assert_eq!(result, EmbedResult::Skipped);

        // Cache should not have been touched
        let stats = cache.stats();
        assert_eq!(stats.hits, 0);
        assert_eq!(stats.misses, 0);
    }

    #[test]
    fn test_cached_embed_different_data_different_embeddings() {
        let engine = MockAudioEngine::new(128);
        let cache = EmbeddingCache::new(EmbeddingCacheConfig {
            max_entries: 100,
            enabled: true,
        });

        // Use data with different byte patterns across enough dimensions
        let mut record_a = make_audio_record("aud-a", vec![100, 0, 0, 0, 0, 0, 0, 0]);
        let mut record_b = make_audio_record("aud-b", vec![0, 0, 0, 0, 0, 0, 0, 200]);

        embed_multimodal_record_cached(&engine, &mut record_a, &cache).unwrap();
        embed_multimodal_record_cached(&engine, &mut record_b, &cache).unwrap();

        // Different data should produce different embeddings
        assert_ne!(
            record_a.embeddings[AUDIO_EMBEDDING_KEY],
            record_b.embeddings[AUDIO_EMBEDDING_KEY]
        );
        assert_eq!(cache.len(), 2);
    }

    #[test]
    fn test_cached_embed_with_custom_key() {
        let engine = MockAudioEngine::new(64);
        let cache = EmbeddingCache::new(EmbeddingCacheConfig {
            max_entries: 100,
            enabled: true,
        });
        let audio_data = vec![5, 10, 15, 20];

        let mut record = make_audio_record("aud-020", audio_data.clone());
        let result = embed_multimodal_record_cached_with_key(
            &engine,
            &mut record,
            &cache,
            "whisper_v2",
            "whisper_v2_audio",
        )
        .unwrap();
        assert_eq!(result, EmbedResult::Generated);
        assert!(record.embeddings.contains_key("whisper_v2"));

        // Another record with same data, same custom key → cached
        let mut record2 = make_audio_record("aud-021", audio_data);
        let result2 = embed_multimodal_record_cached_with_key(
            &engine,
            &mut record2,
            &cache,
            "whisper_v2",
            "whisper_v2_audio",
        )
        .unwrap();
        assert_eq!(result2, EmbedResult::Cached);
    }

    // ── batch embedding tests ────────────────────────────────────────

    #[test]
    fn test_batch_embed_without_cache() {
        let engine = MockAudioEngine::new(128);
        let mut records = vec![
            make_audio_record("batch-1", vec![1, 2, 3]),
            make_audio_record("batch-2", vec![4, 5, 6]),
            MultimodalRecord::new("batch-3", MediaType::Audio).with_mime_type("audio/wav"),
            // no data
        ];

        let result = embed_multimodal_batch(&engine, &mut records, None);
        assert_eq!(result.generated, 2);
        assert_eq!(result.skipped, 1); // batch-3 has no data
        assert_eq!(result.failed, 0);
        assert!(result.all_succeeded());
    }

    #[test]
    fn test_batch_embed_with_cache() {
        let engine = MockAudioEngine::new(128);
        let cache = EmbeddingCache::new(EmbeddingCacheConfig {
            max_entries: 100,
            enabled: true,
        });

        let shared_data = vec![7, 8, 9, 10];

        let mut records = vec![
            make_audio_record("batch-a", shared_data.clone()),
            make_audio_record("batch-b", shared_data.clone()), // same data
            make_audio_record("batch-c", vec![11, 12, 13]),    // different data
        ];

        let result = embed_multimodal_batch(&engine, &mut records, Some(&cache));
        assert_eq!(result.generated, 2); // batch-a (miss) + batch-c (miss)
        assert_eq!(result.cached, 1); // batch-b (hit from batch-a's data)
        assert_eq!(result.skipped, 0);
        assert_eq!(result.failed, 0);
        assert_eq!(result.total(), 3);
        assert_eq!(result.embedded(), 3);
    }

    #[test]
    fn test_batch_embed_result_display() {
        let result = BatchEmbedResult {
            cached: 2,
            generated: 5,
            skipped: 1,
            failed: 0,
            errors: vec![],
        };
        let display = format!("{}", result);
        assert!(display.contains("8 total"));
        assert!(display.contains("5 generated"));
        assert!(display.contains("2 cached"));
    }

    // ── helper function tests ────────────────────────────────────────

    #[test]
    fn test_embedding_key_for_media_type() {
        assert_eq!(
            embedding_key_for_media_type(MediaType::Audio),
            AUDIO_EMBEDDING_KEY
        );
        assert_eq!(
            embedding_key_for_media_type(MediaType::Image),
            IMAGE_EMBEDDING_KEY
        );
    }

    #[test]
    fn test_cache_modality_for_media_type() {
        assert_eq!(
            cache_modality_for_media_type(MediaType::Audio),
            WHISPER_CACHE_MODALITY
        );
        assert_eq!(
            cache_modality_for_media_type(MediaType::Image),
            CLIP_IMAGE_CACHE_MODALITY
        );
    }
}
