//! Whisper Audio Embedding Engine — Phase-76
//!
//! Uses OpenAI Whisper-small encoder to generate 768-dimensional audio embeddings.
//! Built on `candle-transformers` Whisper model + `symphonia`/`hound` audio decoding.
//!
//! # Architecture
//!
//! ```text
//! Audio Input (WAV/MP3/FLAC/OGG bytes)
//!     │
//!     ▼
//! Audio Decoder → PCM f32 (16kHz mono)
//!     │
//!     ▼
//! Mel Spectrogram (80 bins, 3000 frames max)
//!     │
//!     ▼
//! Whisper Encoder (12 layers, 768d)
//!     │
//!     ▼
//! Mean Pooling → L2 Normalize
//!     │
//!     ▼
//! Audio Embedding (768d float32)
//! ```
//!
//! # GPU Support
//!
//! Uses the unified `DeviceSelector` (Phase-61) for automatic CUDA/Metal/CPU selection.
//! Falls back to CPU gracefully when GPU is unavailable.
//!
//! # Example
//!
//! ```ignore
//! use aifastdb_core::perception::whisper::{WhisperEmbeddingEngine, WhisperModelConfig};
//!
//! let config = WhisperModelConfig::small();
//! let engine = WhisperEmbeddingEngine::from_pretrained(&config)?;
//! let audio_bytes = std::fs::read("speech.wav")?;
//! let embedding = engine.encode_audio(&audio_bytes, "audio/wav")?;
//! assert_eq!(embedding.len(), 768);
//! ```

use std::cell::UnsafeCell;
use std::path::Path;

use candle_core::{Device, IndexOp, Tensor, DType};
use candle_nn::VarBuilder;
use candle_transformers::models::whisper::{
    self as whisper_model,
    model::Whisper,
    Config as WhisperConfig,
};
use hf_hub::{api::sync::ApiBuilder, Repo, RepoType};

use super::error::PerceptionError;
use super::traits::{Modality, PerceptionEngine};
use crate::device::{DevicePreference, DeviceSelector, GpuAccelConfig};

// ============================================================================
// Whisper Model Configuration
// ============================================================================

/// Whisper model variant configuration
#[derive(Debug, Clone)]
pub struct WhisperModelConfig {
    /// Display model ID
    pub model_id: String,
    /// HuggingFace repository ID
    pub hf_repo_id: String,
    /// Output embedding dimension (768 for small)
    pub dimension: usize,
    /// Whisper Config parameters
    pub num_mel_bins: usize,
    pub max_source_positions: usize,
    pub d_model: usize,
    pub encoder_attention_heads: usize,
    pub encoder_layers: usize,
    pub vocab_size: usize,
    pub max_target_positions: usize,
    pub decoder_attention_heads: usize,
    pub decoder_layers: usize,
    /// Max audio duration in seconds (will chunk longer audio)
    pub max_duration_secs: f32,
    /// Pooling strategy
    pub pooling: PoolingStrategy,
}

/// How to pool encoder hidden states into a single embedding vector
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PoolingStrategy {
    /// Average over the time dimension (recommended)
    MeanPooling,
    /// Take the first frame output
    FirstToken,
    /// Take the last frame output
    LastToken,
}

impl WhisperModelConfig {
    /// Whisper-tiny (39M params, 384d) — lightest
    pub fn tiny() -> Self {
        Self {
            model_id: "whisper-tiny".to_string(),
            hf_repo_id: "openai/whisper-tiny".to_string(),
            dimension: 384,
            num_mel_bins: 80,
            max_source_positions: 1500,
            d_model: 384,
            encoder_attention_heads: 6,
            encoder_layers: 4,
            vocab_size: 51865,
            max_target_positions: 448,
            decoder_attention_heads: 6,
            decoder_layers: 4,
            max_duration_secs: 30.0,
            pooling: PoolingStrategy::MeanPooling,
        }
    }

    /// Whisper-small (244M params, 768d) — recommended balance
    pub fn small() -> Self {
        Self {
            model_id: "whisper-small".to_string(),
            hf_repo_id: "openai/whisper-small".to_string(),
            dimension: 768,
            num_mel_bins: 80,
            max_source_positions: 1500,
            d_model: 768,
            encoder_attention_heads: 12,
            encoder_layers: 12,
            vocab_size: 51865,
            max_target_positions: 448,
            decoder_attention_heads: 12,
            decoder_layers: 12,
            max_duration_secs: 30.0,
            pooling: PoolingStrategy::MeanPooling,
        }
    }

    /// Whisper-medium (769M params, 1024d) — high quality
    pub fn medium() -> Self {
        Self {
            model_id: "whisper-medium".to_string(),
            hf_repo_id: "openai/whisper-medium".to_string(),
            dimension: 1024,
            num_mel_bins: 80,
            max_source_positions: 1500,
            d_model: 1024,
            encoder_attention_heads: 16,
            encoder_layers: 24,
            vocab_size: 51865,
            max_target_positions: 448,
            decoder_attention_heads: 16,
            decoder_layers: 24,
            max_duration_secs: 30.0,
            pooling: PoolingStrategy::MeanPooling,
        }
    }

    /// Get config by name
    pub fn from_name(name: &str) -> Option<Self> {
        match name {
            "whisper-tiny" | "openai/whisper-tiny" => Some(Self::tiny()),
            "whisper-small" | "openai/whisper-small" => Some(Self::small()),
            "whisper-medium" | "openai/whisper-medium" => Some(Self::medium()),
            _ => None,
        }
    }

    /// Available model names
    pub fn available_models() -> Vec<&'static str> {
        vec!["whisper-tiny", "whisper-small", "whisper-medium"]
    }

    /// Convert to candle WhisperConfig
    fn to_whisper_config(&self) -> WhisperConfig {
        WhisperConfig {
            num_mel_bins: self.num_mel_bins,
            max_source_positions: self.max_source_positions,
            d_model: self.d_model,
            encoder_attention_heads: self.encoder_attention_heads,
            encoder_layers: self.encoder_layers,
            vocab_size: self.vocab_size,
            max_target_positions: self.max_target_positions,
            decoder_attention_heads: self.decoder_attention_heads,
            decoder_layers: self.decoder_layers,
            suppress_tokens: vec![],
        }
    }
}

// ============================================================================
// Audio Decoding (T76.2)
// ============================================================================

/// Decoded PCM audio data
struct PcmAudio {
    /// Mono samples normalized to [-1.0, 1.0]
    samples: Vec<f32>,
    /// Sample rate in Hz
    sample_rate: u32,
}

/// Decode audio bytes to PCM f32 mono.
///
/// Supports WAV (via hound), MP3/FLAC/OGG (via symphonia).
/// Automatically converts to mono and resamples to 16kHz if needed.
fn decode_audio(data: &[u8], mime_type: &str) -> Result<PcmAudio, PerceptionError> {
    // Try WAV first (fastest path)
    if mime_type == "audio/wav" || mime_type == "audio/x-wav" || mime_type == "audio/wave" {
        match decode_wav(data) {
            Ok(pcm) => return Ok(pcm),
            Err(e) => {
                tracing::warn!("WAV decode via hound failed, trying symphonia: {}", e);
            }
        }
    }

    // General decode path via symphonia
    decode_symphonia(data)
}

/// Decode WAV using hound (fast path)
fn decode_wav(data: &[u8]) -> Result<PcmAudio, PerceptionError> {
    let cursor = std::io::Cursor::new(data);
    let mut reader = hound::WavReader::new(cursor)
        .map_err(|e| PerceptionError::InvalidInput(format!("WAV decode error: {}", e)))?;

    let spec = reader.spec();
    let sample_rate = spec.sample_rate;
    let channels = spec.channels as usize;

    let samples: Vec<f32> = match spec.sample_format {
        hound::SampleFormat::Float => {
            reader.samples::<f32>()
                .map(|s| s.unwrap_or(0.0))
                .collect()
        }
        hound::SampleFormat::Int => {
            let bits = spec.bits_per_sample;
            let max_val = (1i64 << (bits - 1)) as f32;
            reader.samples::<i32>()
                .map(|s| s.unwrap_or(0) as f32 / max_val)
                .collect()
        }
    };

    // Mix to mono if stereo/multi-channel
    let mono = if channels > 1 {
        mix_to_mono(&samples, channels)
    } else {
        samples
    };

    Ok(PcmAudio {
        samples: mono,
        sample_rate,
    })
}

/// Decode audio using symphonia (supports MP3, FLAC, OGG, WAV, AAC)
fn decode_symphonia(data: &[u8]) -> Result<PcmAudio, PerceptionError> {
    use symphonia::core::audio::SampleBuffer;
    use symphonia::core::codecs::DecoderOptions;
    use symphonia::core::formats::FormatOptions;
    use symphonia::core::io::MediaSourceStream;
    use symphonia::core::meta::MetadataOptions;
    use symphonia::core::probe::Hint;

    let cursor = std::io::Cursor::new(data.to_vec());
    let mss = MediaSourceStream::new(Box::new(cursor), Default::default());

    let hint = Hint::new();
    let format_opts = FormatOptions::default();
    let metadata_opts = MetadataOptions::default();

    let probed = symphonia::default::get_probe()
        .format(&hint, mss, &format_opts, &metadata_opts)
        .map_err(|e| PerceptionError::InvalidInput(format!("Audio format probe error: {}", e)))?;

    let mut format = probed.format;

    // Find the first audio track
    let track = format
        .tracks()
        .iter()
        .find(|t| {
            t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL
        })
        .ok_or_else(|| PerceptionError::InvalidInput("No audio track found".to_string()))?;

    let track_id = track.id;
    let sample_rate = track.codec_params.sample_rate
        .ok_or_else(|| PerceptionError::InvalidInput("Unknown sample rate".to_string()))?;
    let channels = track.codec_params.channels
        .map(|c| c.count())
        .unwrap_or(1);

    let decoder_opts = DecoderOptions::default();
    let mut decoder = symphonia::default::get_codecs()
        .make(&track.codec_params, &decoder_opts)
        .map_err(|e| PerceptionError::InvalidInput(format!("Codec init error: {}", e)))?;

    let mut all_samples: Vec<f32> = Vec::new();

    loop {
        let packet = match format.next_packet() {
            Ok(p) => p,
            Err(symphonia::core::errors::Error::IoError(ref e))
                if e.kind() == std::io::ErrorKind::UnexpectedEof =>
            {
                break;
            }
            Err(_) => break,
        };

        if packet.track_id() != track_id {
            continue;
        }

        let decoded = match decoder.decode(&packet) {
            Ok(d) => d,
            Err(_) => continue,
        };

        let spec = *decoded.spec();
        let n_frames = decoded.frames();
        if n_frames == 0 {
            continue;
        }

        let mut sample_buf = SampleBuffer::<f32>::new(n_frames as u64, spec);
        sample_buf.copy_interleaved_ref(decoded);
        all_samples.extend_from_slice(sample_buf.samples());
    }

    if all_samples.is_empty() {
        return Err(PerceptionError::InvalidInput("No audio samples decoded".to_string()));
    }

    // Mix to mono
    let mono = if channels > 1 {
        mix_to_mono(&all_samples, channels)
    } else {
        all_samples
    };

    Ok(PcmAudio {
        samples: mono,
        sample_rate,
    })
}

/// Mix multi-channel audio to mono by averaging channels
fn mix_to_mono(samples: &[f32], channels: usize) -> Vec<f32> {
    let n_frames = samples.len() / channels;
    let mut mono = Vec::with_capacity(n_frames);
    let inv_channels = 1.0 / channels as f32;
    for i in 0..n_frames {
        let mut sum = 0.0f32;
        for ch in 0..channels {
            sum += samples[i * channels + ch];
        }
        mono.push(sum * inv_channels);
    }
    mono
}

/// Resample PCM audio to target sample rate using linear interpolation.
/// For production quality, considers using `rubato` crate.
fn resample_to_16khz(pcm: &PcmAudio) -> Vec<f32> {
    let target_rate = whisper_model::SAMPLE_RATE as u32;
    if pcm.sample_rate == target_rate {
        return pcm.samples.clone();
    }

    // Use rubato for high-quality resampling
    use rubato::{Resampler, SincFixedIn, SincInterpolationParameters, SincInterpolationType, WindowFunction};

    let params = SincInterpolationParameters {
        sinc_len: 256,
        f_cutoff: 0.95,
        interpolation: SincInterpolationType::Linear,
        oversampling_factor: 256,
        window: WindowFunction::BlackmanHarris2,
    };

    let ratio = target_rate as f64 / pcm.sample_rate as f64;
    let chunk_size = 1024;

    match SincFixedIn::<f32>::new(ratio, 2.0, params, chunk_size, 1) {
        Ok(mut resampler) => {
            let mut output = Vec::new();
            let samples = &pcm.samples;
            let mut pos = 0;

            while pos < samples.len() {
                let end = (pos + chunk_size).min(samples.len());
                let mut chunk = samples[pos..end].to_vec();

                // Pad last chunk if needed
                if chunk.len() < chunk_size {
                    chunk.resize(chunk_size, 0.0);
                }

                let input = vec![chunk];
                match resampler.process(&input, None) {
                    Ok(resampled) => {
                        if !resampled.is_empty() {
                            output.extend_from_slice(&resampled[0]);
                        }
                    }
                    Err(e) => {
                        tracing::warn!("Rubato resample error, falling back to linear: {}", e);
                        return resample_linear(&pcm.samples, pcm.sample_rate, target_rate);
                    }
                }
                pos += chunk_size;
            }

            // Trim to expected length
            let expected_len = (pcm.samples.len() as f64 * ratio) as usize;
            output.truncate(expected_len);
            output
        }
        Err(e) => {
            tracing::warn!("Rubato init error, falling back to linear: {}", e);
            resample_linear(&pcm.samples, pcm.sample_rate, target_rate)
        }
    }
}

/// Simple linear interpolation resampling (fallback)
fn resample_linear(samples: &[f32], src_rate: u32, dst_rate: u32) -> Vec<f32> {
    let ratio = src_rate as f64 / dst_rate as f64;
    let n_out = (samples.len() as f64 / ratio) as usize;
    let mut output = Vec::with_capacity(n_out);

    for i in 0..n_out {
        let src_pos = i as f64 * ratio;
        let idx = src_pos as usize;
        let frac = src_pos - idx as f64;

        let sample = if idx + 1 < samples.len() {
            samples[idx] * (1.0 - frac as f32) + samples[idx + 1] * frac as f32
        } else if idx < samples.len() {
            samples[idx]
        } else {
            0.0
        };
        output.push(sample);
    }
    output
}

// ============================================================================
// Mel Filters Loading
// ============================================================================

/// Load mel filter banks from binary data.
///
/// The binary file contains f32 values in little-endian format.
/// Whisper uses 80 mel bins with (N_FFT/2 + 1) = 201 frequency bins.
fn load_mel_filters_from_bytes(data: &[u8], n_mel: usize) -> Result<Vec<f32>, PerceptionError> {
    if data.len() % 4 != 0 {
        return Err(PerceptionError::InvalidInput(
            "Mel filter binary data length must be a multiple of 4 (f32)".to_string(),
        ));
    }

    let n_floats = data.len() / 4;
    let mut filters = Vec::with_capacity(n_floats);
    for i in 0..n_floats {
        let bytes = [data[i * 4], data[i * 4 + 1], data[i * 4 + 2], data[i * 4 + 3]];
        filters.push(f32::from_le_bytes(bytes));
    }

    tracing::debug!(
        n_mel = n_mel,
        n_fft_bins = n_floats / n_mel,
        total_floats = n_floats,
        "Mel filters loaded"
    );

    Ok(filters)
}

/// Generate mel filter banks programmatically.
///
/// Uses the HTK-style mel scale matching OpenAI Whisper's implementation.
fn generate_mel_filters(n_mel: usize, n_fft: usize, sample_rate: usize) -> Vec<f32> {
    let n_freqs = n_fft / 2 + 1;

    // Mel scale conversion functions (HTK style)
    let hz_to_mel = |hz: f64| -> f64 { 2595.0 * (1.0 + hz / 700.0).log10() };
    let mel_to_hz = |mel: f64| -> f64 { 700.0 * (10.0_f64.powf(mel / 2595.0) - 1.0) };

    let mel_min = hz_to_mel(0.0);
    let mel_max = hz_to_mel(sample_rate as f64 / 2.0);

    // Create n_mel + 2 equally spaced mel points
    let mel_points: Vec<f64> = (0..=(n_mel + 1))
        .map(|i| mel_min + (mel_max - mel_min) * i as f64 / (n_mel + 1) as f64)
        .collect();

    // Convert back to Hz
    let hz_points: Vec<f64> = mel_points.iter().map(|&m| mel_to_hz(m)).collect();

    // Convert to FFT bin indices
    let bin_points: Vec<f64> = hz_points
        .iter()
        .map(|&hz| hz * n_fft as f64 / sample_rate as f64)
        .collect();

    // Create triangular filters
    let mut filters = vec![0.0f32; n_mel * n_freqs];
    for i in 0..n_mel {
        let left = bin_points[i];
        let center = bin_points[i + 1];
        let right = bin_points[i + 2];

        for j in 0..n_freqs {
            let freq = j as f64;
            if freq >= left && freq <= center {
                let val = (freq - left) / (center - left).max(1e-10);
                filters[i * n_freqs + j] = val as f32;
            } else if freq > center && freq <= right {
                let val = (right - freq) / (right - center).max(1e-10);
                filters[i * n_freqs + j] = val as f32;
            }
        }
    }

    // Normalize each filter (slaney style)
    for i in 0..n_mel {
        let norm: f32 = (0..n_freqs).map(|j| filters[i * n_freqs + j]).sum();
        if norm > 0.0 {
            for j in 0..n_freqs {
                filters[i * n_freqs + j] /= norm;
            }
        }
    }

    filters
}

// ============================================================================
// WhisperEmbeddingEngine (T76.1 + T76.4 + T76.5)
// ============================================================================

/// Whisper-based audio embedding engine.
///
/// Uses the Whisper encoder to produce fixed-dimension audio embeddings
/// from raw audio data. Only the encoder portion is loaded (decoder is skipped).
///
/// # Supported Formats
///
/// - WAV (via `hound`)
/// - MP3, FLAC, OGG, AAC (via `symphonia`)
///
/// # GPU Support
///
/// Automatically selects the best device via `DeviceSelector` (Phase-61).
/// Falls back to CPU if GPU is unavailable.
pub struct WhisperEmbeddingEngine {
    /// Full Whisper model (only encoder is used; decoder is loaded but idle)
    /// UnsafeCell is needed because AudioEncoder::forward() requires &mut self
    /// (for KV cache), but PerceptionEngine::align_modality() takes &self.
    /// Safety: We always call forward() with flush_kv_cache=true, so there's
    /// no shared mutable state concern. Access is single-threaded per engine.
    model: UnsafeCell<Whisper>,
    /// Compute device
    device: Device,
    /// Mel filter banks (80 × n_fft_bins)
    mel_filters: Vec<f32>,
    /// Model configuration
    config: WhisperModelConfig,
    /// Whisper config (for mel computation)
    whisper_config: WhisperConfig,
    /// Whether GPU is being used
    is_gpu: bool,
}

// SAFETY: WhisperEmbeddingEngine is only accessed from a single thread at a time.
// The UnsafeCell is needed to work around the &self/&mut self mismatch in trait impl.
unsafe impl Send for WhisperEmbeddingEngine {}
unsafe impl Sync for WhisperEmbeddingEngine {}

impl std::fmt::Debug for WhisperEmbeddingEngine {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("WhisperEmbeddingEngine")
            .field("model_id", &self.config.model_id)
            .field("dimension", &self.config.dimension)
            .field("device_is_gpu", &self.is_gpu)
            .finish()
    }
}

impl WhisperEmbeddingEngine {
    /// Create engine from a PerceptionConfig
    pub fn from_perception_config(
        config: &super::config::PerceptionConfig,
    ) -> Result<Self, PerceptionError> {
        let model_config = WhisperModelConfig::from_name(&config.model_id)
            .unwrap_or_else(|| WhisperModelConfig::small());

        let device = Self::select_device(&config.device)?;

        if config.auto_download {
            Self::from_pretrained(&model_config, device)
        } else if let Some(ref cache_dir) = config.cache_dir {
            Self::from_local(&model_config, Path::new(cache_dir), device)
        } else {
            Err(PerceptionError::ConfigError(
                "Either auto_download must be true or cache_dir must be set".to_string(),
            ))
        }
    }

    /// Load from HuggingFace Hub (auto-download)
    pub fn from_pretrained(
        config: &WhisperModelConfig,
        device: Device,
    ) -> Result<Self, PerceptionError> {
        tracing::info!(
            "Loading Whisper model from HuggingFace: {} (dim={})",
            config.hf_repo_id,
            config.dimension
        );

        let api = ApiBuilder::from_env()
            .with_progress(true)
            .build()
            .map_err(|e| PerceptionError::ModelDownloadError(e.to_string()))?;
        let repo = api.repo(Repo::new(config.hf_repo_id.clone(), RepoType::Model));

        // Download model weights
        let weights_path = repo
            .get("model.safetensors")
            .or_else(|_| repo.get("pytorch_model.bin"))
            .map_err(|e| PerceptionError::ModelDownloadError(
                format!("Failed to download Whisper weights: {}", e),
            ))?;

        // Try to download mel filters; generate if not available
        let mel_filters = match repo.get("melfilters.bytes") {
            Ok(mel_path) => {
                let mel_bytes = std::fs::read(&mel_path)
                    .map_err(|e| PerceptionError::IoError(e))?;
                load_mel_filters_from_bytes(&mel_bytes, config.num_mel_bins)?
            }
            Err(_) => {
                tracing::info!("melfilters.bytes not found in repo, generating programmatically");
                generate_mel_filters(
                    config.num_mel_bins,
                    whisper_model::N_FFT,
                    whisper_model::SAMPLE_RATE,
                )
            }
        };

        Self::load_model(&weights_path, config, mel_filters, device)
    }

    /// Load from local directory
    pub fn from_local(
        config: &WhisperModelConfig,
        model_dir: &Path,
        device: Device,
    ) -> Result<Self, PerceptionError> {
        tracing::info!("Loading Whisper model from local: {:?}", model_dir);

        let weights_path = if model_dir.join("model.safetensors").exists() {
            model_dir.join("model.safetensors")
        } else {
            model_dir.join("pytorch_model.bin")
        };

        let mel_filters = if model_dir.join("melfilters.bytes").exists() {
            let mel_bytes = std::fs::read(model_dir.join("melfilters.bytes"))
                .map_err(|e| PerceptionError::IoError(e))?;
            load_mel_filters_from_bytes(&mel_bytes, config.num_mel_bins)?
        } else {
            tracing::info!("melfilters.bytes not found locally, generating programmatically");
            generate_mel_filters(
                config.num_mel_bins,
                whisper_model::N_FFT,
                whisper_model::SAMPLE_RATE,
            )
        };

        Self::load_model(&weights_path, config, mel_filters, device)
    }

    /// Load the Whisper model from weights file.
    ///
    /// Loads the full model (encoder + decoder). Only the encoder is used
    /// for embedding generation; the decoder is kept idle.
    fn load_model(
        weights_path: &Path,
        config: &WhisperModelConfig,
        mel_filters: Vec<f32>,
        device: Device,
    ) -> Result<Self, PerceptionError> {
        let is_gpu = !matches!(device, Device::Cpu);

        // Load weights
        let vb = if weights_path.extension().map_or(false, |e| e == "safetensors") {
            unsafe {
                VarBuilder::from_mmaped_safetensors(&[weights_path], DType::F32, &device)
                    .map_err(|e| PerceptionError::InferenceError(
                        format!("Failed to load Whisper safetensors: {}", e),
                    ))?
            }
        } else {
            VarBuilder::from_pth(weights_path, DType::F32, &device)
                .map_err(|e| PerceptionError::InferenceError(
                    format!("Failed to load Whisper weights: {}", e),
                ))?
        };

        let whisper_config = config.to_whisper_config();

        // Load full Whisper model (encoder + decoder)
        let model = Whisper::load(&vb, whisper_config.clone())
            .map_err(|e| PerceptionError::InferenceError(
                format!("Failed to initialize Whisper model: {}", e),
            ))?;

        tracing::info!(
            model = config.model_id.as_str(),
            dim = config.dimension,
            gpu = is_gpu,
            "Whisper model loaded successfully (encoder will be used for embedding)"
        );

        Ok(Self {
            model: UnsafeCell::new(model),
            device,
            mel_filters,
            whisper_config,
            config: config.clone(),
            is_gpu,
        })
    }

    /// Select compute device using DeviceSelector (Phase-61)
    fn select_device(device_str: &str) -> Result<Device, PerceptionError> {
        let pref = DevicePreference::from_str_flexible(device_str);
        let gpu_config = GpuAccelConfig {
            device_preference: pref,
            ..Default::default()
        };
        let selector = DeviceSelector::new(gpu_config);
        let (device, is_gpu) = selector.candle_device_with_fallback("whisper_embedding");

        if is_gpu {
            tracing::info!(
                engine = "WhisperEmbeddingEngine",
                device = ?device,
                config_device = device_str,
                "Using GPU for Whisper inference"
            );
        } else {
            tracing::info!(
                engine = "WhisperEmbeddingEngine",
                config_device = device_str,
                "Using CPU for Whisper inference (GPU not available or not requested)"
            );
        }

        Ok(device)
    }

    // ========================================================================
    // Core Pipeline: audio bytes → embedding
    // ========================================================================

    /// Encode audio bytes to a fixed-dimension embedding vector.
    ///
    /// Pipeline: decode → resample to 16kHz → mel spectrogram → encoder → pooling → normalize
    pub fn encode_audio(
        &self,
        data: &[u8],
        mime_type: &str,
    ) -> Result<Vec<f32>, PerceptionError> {
        // Step 1: Decode audio to PCM
        let pcm = decode_audio(data, mime_type)?;
        tracing::debug!(
            sample_rate = pcm.sample_rate,
            samples = pcm.samples.len(),
            duration_secs = pcm.samples.len() as f32 / pcm.sample_rate as f32,
            "Audio decoded"
        );

        // Step 2: Resample to 16kHz if needed
        let samples_16k = resample_to_16khz(&pcm);
        tracing::debug!(
            samples_16k = samples_16k.len(),
            duration_secs = samples_16k.len() as f32 / whisper_model::SAMPLE_RATE as f32,
            "Resampled to 16kHz"
        );

        // Step 3: Handle long audio by chunking (30s per chunk)
        let max_samples = whisper_model::N_SAMPLES; // 480000 (30s)
        if samples_16k.len() > max_samples * 2 {
            // For very long audio, process chunks and average embeddings
            return self.encode_long_audio(&samples_16k);
        }

        // Step 4: Compute mel spectrogram
        let mel = self.compute_mel(&samples_16k)?;

        // Step 5: Run encoder
        let hidden = self.run_encoder(&mel)?;

        // Step 6: Pool + normalize
        let embedding = self.pool_and_normalize(&hidden)?;

        Ok(embedding)
    }

    /// Process long audio (>60s) by chunking into 30s segments and averaging embeddings.
    fn encode_long_audio(&self, samples: &[f32]) -> Result<Vec<f32>, PerceptionError> {
        let chunk_size = whisper_model::N_SAMPLES;
        let mut all_embeddings: Vec<Vec<f32>> = Vec::new();

        let mut pos = 0;
        while pos < samples.len() {
            let end = (pos + chunk_size).min(samples.len());
            let chunk = &samples[pos..end];

            let mel = self.compute_mel(chunk)?;
            let hidden = self.run_encoder(&mel)?;
            let emb = self.pool_and_normalize(&hidden)?;
            all_embeddings.push(emb);

            pos += chunk_size;
        }

        if all_embeddings.is_empty() {
            return Err(PerceptionError::InvalidInput("No audio chunks processed".to_string()));
        }

        // Average all chunk embeddings
        let dim = self.config.dimension;
        let mut avg = vec![0.0f32; dim];
        for emb in &all_embeddings {
            for (i, &v) in emb.iter().enumerate() {
                avg[i] += v;
            }
        }
        let n = all_embeddings.len() as f32;
        for v in &mut avg {
            *v /= n;
        }

        // Re-normalize
        let norm: f32 = avg.iter().map(|x| x * x).sum::<f32>().sqrt();
        if norm > 0.0 {
            for v in &mut avg {
                *v /= norm;
            }
        }

        tracing::debug!(
            chunks = all_embeddings.len(),
            dim = dim,
            "Long audio: averaged {} chunk embeddings",
            all_embeddings.len()
        );

        Ok(avg)
    }

    /// Compute mel spectrogram from PCM samples.
    fn compute_mel(&self, samples: &[f32]) -> Result<Tensor, PerceptionError> {
        // Use candle-transformers' built-in mel computation
        let mel_data = whisper_model::audio::pcm_to_mel(
            &self.whisper_config,
            samples,
            &self.mel_filters,
        );

        let n_mel = self.config.num_mel_bins;
        let n_frames = mel_data.len() / n_mel;

        // Create tensor [1, n_mel, n_frames] (batch=1)
        let mel_tensor = Tensor::from_vec(mel_data, (n_mel, n_frames), &self.device)
            .map_err(|e| PerceptionError::InferenceError(format!("Mel tensor creation error: {}", e)))?
            .unsqueeze(0)
            .map_err(|e| PerceptionError::InferenceError(format!("Mel unsqueeze error: {}", e)))?;

        Ok(mel_tensor)
    }

    /// Run the Whisper encoder on a mel spectrogram tensor.
    ///
    /// SAFETY: Uses UnsafeCell to get &mut access to the encoder.
    /// This is safe because we always flush KV cache (flush_kv_cache=true),
    /// ensuring no stale mutable state persists between calls.
    fn run_encoder(&self, mel: &Tensor) -> Result<Tensor, PerceptionError> {
        // SAFETY: Single-threaded access, KV cache is always flushed
        let model = unsafe { &mut *self.model.get() };
        model.encoder
            .forward(mel, true)
            .map_err(|e| {
                // Check if this is a GPU OOM error
                let err_str = format!("{}", e);
                if err_str.contains("out of memory") || err_str.contains("CUDA") {
                    tracing::error!(
                        "GPU OOM during Whisper inference. Consider using CPU or reducing input length."
                    );
                }
                PerceptionError::InferenceError(format!("Whisper encoder forward error: {}", e))
            })
    }

    /// Pool encoder hidden states into a single embedding and L2-normalize.
    fn pool_and_normalize(&self, hidden: &Tensor) -> Result<Vec<f32>, PerceptionError> {
        // hidden shape: [batch=1, seq_len, d_model]
        let embedding = match self.config.pooling {
            PoolingStrategy::MeanPooling => {
                // Mean over time dimension (dim 1)
                hidden.mean(1)
                    .map_err(|e| PerceptionError::InferenceError(
                        format!("Mean pooling error: {}", e),
                    ))?
            }
            PoolingStrategy::FirstToken => {
                hidden.i((.., 0..1, ..))
                    .map_err(|e| PerceptionError::InferenceError(
                        format!("First token selection error: {}", e),
                    ))?
                    .squeeze(1)
                    .map_err(|e| PerceptionError::InferenceError(
                        format!("Squeeze error: {}", e),
                    ))?
            }
            PoolingStrategy::LastToken => {
                let seq_len = hidden.dim(1)
                    .map_err(|e| PerceptionError::InferenceError(format!("Dim error: {}", e)))?;
                hidden.i((.., (seq_len - 1)..seq_len, ..))
                    .map_err(|e| PerceptionError::InferenceError(
                        format!("Last token selection error: {}", e),
                    ))?
                    .squeeze(1)
                    .map_err(|e| PerceptionError::InferenceError(
                        format!("Squeeze error: {}", e),
                    ))?
            }
        };

        // Squeeze batch dimension and move to CPU
        let embedding = embedding
            .squeeze(0)
            .map_err(|e| PerceptionError::InferenceError(format!("Squeeze batch error: {}", e)))?
            .to_device(&Device::Cpu)
            .map_err(|e| PerceptionError::InferenceError(format!("Device transfer error: {}", e)))?;

        // Convert to Vec<f32>
        let mut emb: Vec<f32> = embedding
            .to_vec1()
            .map_err(|e| PerceptionError::InferenceError(format!("Tensor to vec error: {}", e)))?;

        // L2 normalize
        let norm: f32 = emb.iter().map(|x| x * x).sum::<f32>().sqrt();
        if norm > 0.0 {
            for v in &mut emb {
                *v /= norm;
            }
        }

        Ok(emb)
    }
}

// ============================================================================
// PerceptionEngine Trait Implementation (T76.5)
// ============================================================================

impl PerceptionEngine for WhisperEmbeddingEngine {
    /// Whisper doesn't support pure text embedding.
    /// Returns an error suggesting to use align_modality for audio.
    fn conceptualize(&self, _input: &str) -> Result<Vec<f32>, PerceptionError> {
        Err(PerceptionError::UnsupportedModality(
            "WhisperEmbeddingEngine does not support text embedding. \
             Use align_modality() with audio data instead."
                .to_string(),
        ))
    }

    /// Generate an embedding from audio data.
    ///
    /// Accepts `audio/*` MIME types (WAV, MP3, FLAC, OGG, AAC).
    fn align_modality(&self, data: &[u8], mime_type: &str) -> Result<Vec<f32>, PerceptionError> {
        // Check modality
        if !mime_type.starts_with("audio/") {
            return Err(PerceptionError::UnsupportedModality(format!(
                "WhisperEmbeddingEngine only supports audio/* modality, got: {}",
                mime_type
            )));
        }

        self.encode_audio(data, mime_type)
    }

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

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

    fn model_name(&self) -> &str {
        &self.config.model_id
    }
}

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

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

    #[test]
    fn test_whisper_model_configs() {
        let tiny = WhisperModelConfig::tiny();
        assert_eq!(tiny.dimension, 384);
        assert_eq!(tiny.encoder_layers, 4);

        let small = WhisperModelConfig::small();
        assert_eq!(small.dimension, 768);
        assert_eq!(small.encoder_layers, 12);

        let medium = WhisperModelConfig::medium();
        assert_eq!(medium.dimension, 1024);
        assert_eq!(medium.encoder_layers, 24);
    }

    #[test]
    fn test_from_name() {
        assert!(WhisperModelConfig::from_name("whisper-small").is_some());
        assert!(WhisperModelConfig::from_name("openai/whisper-small").is_some());
        assert!(WhisperModelConfig::from_name("unknown-model").is_none());
    }

    #[test]
    fn test_generate_mel_filters() {
        let filters = generate_mel_filters(80, 400, 16000);
        assert_eq!(filters.len(), 80 * 201); // 80 mel bins × 201 frequency bins
        // All values should be non-negative
        assert!(filters.iter().all(|&v| v >= 0.0));
    }

    #[test]
    fn test_mix_to_mono() {
        // Stereo: L=1.0, R=0.0, L=0.5, R=0.5
        let stereo = vec![1.0f32, 0.0, 0.5, 0.5];
        let mono = mix_to_mono(&stereo, 2);
        assert_eq!(mono.len(), 2);
        assert!((mono[0] - 0.5).abs() < 1e-5);
        assert!((mono[1] - 0.5).abs() < 1e-5);
    }

    #[test]
    fn test_resample_linear() {
        // 1 second of silence at 44100 Hz
        let samples = vec![0.0f32; 44100];
        let resampled = resample_linear(&samples, 44100, 16000);
        // Should produce ~16000 samples
        assert!((resampled.len() as i32 - 16000).abs() < 10);
    }

    #[test]
    fn test_wav_decode_synthetic() {
        // Create a minimal WAV file in memory
        let spec = hound::WavSpec {
            channels: 1,
            sample_rate: 16000,
            bits_per_sample: 16,
            sample_format: hound::SampleFormat::Int,
        };

        let mut buffer = Vec::new();
        {
            let cursor = std::io::Cursor::new(&mut buffer);
            let mut writer = hound::WavWriter::new(cursor, spec).unwrap();
            // Write 0.1s of silence
            for _ in 0..1600 {
                writer.write_sample(0i16).unwrap();
            }
            writer.finalize().unwrap();
        }

        let pcm = decode_wav(&buffer).unwrap();
        assert_eq!(pcm.sample_rate, 16000);
        assert_eq!(pcm.samples.len(), 1600);
    }

    // Integration tests that require model download
    #[test]
    #[ignore]
    fn test_whisper_embedding_e2e() {
        let config = WhisperModelConfig::small();
        let device = Device::Cpu;
        let engine = WhisperEmbeddingEngine::from_pretrained(&config, device).unwrap();

        // Create a synthetic WAV file (1 second of silence)
        let spec = hound::WavSpec {
            channels: 1,
            sample_rate: 16000,
            bits_per_sample: 16,
            sample_format: hound::SampleFormat::Int,
        };
        let mut buffer = Vec::new();
        {
            let cursor = std::io::Cursor::new(&mut buffer);
            let mut writer = hound::WavWriter::new(cursor, spec).unwrap();
            for _ in 0..16000 {
                writer.write_sample(0i16).unwrap();
            }
            writer.finalize().unwrap();
        }

        let embedding = engine.encode_audio(&buffer, "audio/wav").unwrap();
        assert_eq!(embedding.len(), 768);

        // Should be L2 normalized
        let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
        assert!((norm - 1.0).abs() < 0.01, "norm = {}", norm);
    }
}
