//! CLIP (Contrastive Language-Image Pre-training) Multimodal Embedding Engine
//!
//! Provides image and text embedding in a shared semantic space using OpenAI's
//! CLIP model architecture. Enables cross-modal search (text-to-image, image-to-text).
//!
//! # Architecture
//!
//! ```text
//! ┌──────────────┐     ┌──────────────────────┐
//! │ Text Input   │────▶│ CLIP Text Encoder     │──▶ 512d / 768d vector
//! └──────────────┘     │ (Transformer)         │
//!                      └──────────────────────┘
//!
//! ┌──────────────┐     ┌──────────────────────┐
//! │ Image Input  │────▶│ CLIP Vision Encoder   │──▶ 512d / 768d vector
//! │ (raw bytes)  │     │ (ViT-B/32 or ViT-L/14)│      (same space!)
//! └──────────────┘     └──────────────────────┘
//! ```
//!
//! # Supported Models
//!
//! | Model           | Dimension | Speed  | Quality  |
//! |-----------------|-----------|--------|----------|
//! | clip-vit-b32    | 512       | Fast   | Good     |
//! | clip-vit-b16    | 512       | Medium | Better   |
//! | clip-vit-l14    | 768       | Slow   | Best     |
//!
//! # Operation Modes
//!
//! 1. **API Mode** (recommended for production): Calls an external CLIP inference
//!    service (e.g. running on GPU). Configure with `with_api_endpoint()`.
//! 2. **Local Mode** (placeholder): Uses a hash-based pseudo-embedding for
//!    development/testing. Will be replaced by Candle ViT inference in the future.
//!
//! # Usage
//!
//! ```ignore
//! use aifastdb_core::perception::clip::ClipEmbeddingEngine;
//!
//! // Local mode (pseudo-embedding for development)
//! let engine = ClipEmbeddingEngine::new("clip-vit-b32")?;
//! let text_vec = engine.conceptualize("a photo of a cat")?;
//!
//! // API mode (production, connects to external CLIP service)
//! let engine = ClipEmbeddingEngine::new("clip-vit-b32")?
//!     .with_api_endpoint("http://localhost:8080/clip");
//! let image_bytes = std::fs::read("cat.jpg")?;
//! let image_vec = engine.align_modality(&image_bytes, "image/jpeg")?;
//! ```

use std::sync::Mutex;

use super::error::PerceptionError;
use super::traits::{Modality, PerceptionEngine};

// Candle-based local CLIP inference (feature-gated)
#[cfg(feature = "candle")]
use candle_core::{DType, Device, Tensor};
#[cfg(feature = "candle")]
use candle_nn::VarBuilder;
#[cfg(feature = "candle")]
use candle_transformers::models::clip as candle_clip;
#[cfg(feature = "candle")]
use hf_hub::{api::sync::ApiBuilder, Repo, RepoType};
#[cfg(feature = "candle")]
use tokenizers::Tokenizer;

// ============================================================================
// CLIP Model Configuration
// ============================================================================

/// CLIP model variant configuration.
#[derive(Debug, Clone)]
pub struct ClipModelConfig {
    /// Model identifier
    pub model_id: String,
    /// Output embedding dimension
    pub dimension: usize,
    /// Vision input resolution (square, e.g., 224 for ViT-B/32)
    pub image_resolution: u32,
    /// Patch size for ViT (e.g., 32, 16, 14)
    pub patch_size: u32,
    /// Number of vision transformer layers
    pub vision_layers: usize,
    /// Number of text transformer layers
    pub text_layers: usize,
    /// Hidden dimension of the model
    pub hidden_dim: usize,
    /// HuggingFace model repository ID
    pub hf_repo_id: String,
}

impl ClipModelConfig {
    /// CLIP ViT-B/32 — fastest, good quality (512d)
    pub fn vit_b32() -> Self {
        Self {
            model_id: "clip-vit-b32".to_string(),
            dimension: 512,
            image_resolution: 224,
            patch_size: 32,
            vision_layers: 12,
            text_layers: 12,
            hidden_dim: 768,
            hf_repo_id: "openai/clip-vit-base-patch32".to_string(),
        }
    }

    /// CLIP ViT-B/16 — balanced speed and quality (512d)
    pub fn vit_b16() -> Self {
        Self {
            model_id: "clip-vit-b16".to_string(),
            dimension: 512,
            image_resolution: 224,
            patch_size: 16,
            vision_layers: 12,
            text_layers: 12,
            hidden_dim: 768,
            hf_repo_id: "openai/clip-vit-base-patch16".to_string(),
        }
    }

    /// CLIP ViT-L/14 — best quality, slower (768d)
    pub fn vit_l14() -> Self {
        Self {
            model_id: "clip-vit-l14".to_string(),
            dimension: 768,
            image_resolution: 224,
            patch_size: 14,
            vision_layers: 24,
            text_layers: 12,
            hidden_dim: 1024,
            hf_repo_id: "openai/clip-vit-large-patch14".to_string(),
        }
    }

    /// Get config by model name
    pub fn from_name(name: &str) -> Option<Self> {
        match name {
            "clip-vit-b32" | "clip-vit-base-patch32" => Some(Self::vit_b32()),
            "clip-vit-b16" | "clip-vit-base-patch16" => Some(Self::vit_b16()),
            "clip-vit-l14" | "clip-vit-large-patch14" => Some(Self::vit_l14()),
            _ => None,
        }
    }

    /// List all available model names.
    pub fn available_models() -> Vec<&'static str> {
        vec!["clip-vit-b32", "clip-vit-b16", "clip-vit-l14"]
    }
}

// ============================================================================
// Local CLIP Inference via Candle (feature-gated)
// ============================================================================

/// CLIP image preprocessing constants (ImageNet normalization used by OpenAI CLIP)
#[cfg(feature = "candle")]
const CLIP_IMAGE_MEAN: [f32; 3] = [0.48145466, 0.4578275, 0.40821073];
#[cfg(feature = "candle")]
const CLIP_IMAGE_STD: [f32; 3] = [0.26862954, 0.26130258, 0.27577711];

/// Local CLIP model loaded via Candle for on-device inference.
///
/// Supports both Vision (image → embedding) and Text (text → embedding) encoders.
/// Uses `DeviceSelector` for automatic CPU/GPU device selection.
#[cfg(feature = "candle")]
pub struct ClipLocalModel {
    model: candle_clip::ClipModel,
    tokenizer: Tokenizer,
    device: Device,
    config: ClipModelConfig,
}

#[cfg(feature = "candle")]
impl std::fmt::Debug for ClipLocalModel {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ClipLocalModel")
            .field("model_id", &self.config.model_id)
            .field("dimension", &self.config.dimension)
            .field("device", &format!("{:?}", self.device))
            .finish()
    }
}

#[cfg(feature = "candle")]
impl ClipLocalModel {
    /// Load a CLIP model from HuggingFace Hub.
    ///
    /// Downloads model weights, tokenizer, and config on first call.
    /// Subsequent calls use the cached files.
    pub fn from_pretrained(
        clip_config: &ClipModelConfig,
        device: Device,
    ) -> Result<Self, PerceptionError> {
        tracing::info!(
            "Loading CLIP model from HuggingFace Hub: {}",
            clip_config.hf_repo_id
        );

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

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

        // Download tokenizer
        let tokenizer_path = repo
            .get("tokenizer.json")
            .map_err(|e| PerceptionError::ModelDownloadError(
                format!("Failed to download CLIP tokenizer: {}", e),
            ))?;

        Self::load_from_files(clip_config, &weights_path, &tokenizer_path, device)
    }

    /// Load CLIP model from local file paths.
    pub fn load_from_files(
        clip_config: &ClipModelConfig,
        weights_path: &std::path::Path,
        tokenizer_path: &std::path::Path,
        device: Device,
    ) -> Result<Self, PerceptionError> {
        // Build candle CLIP config
        let candle_config = candle_clip::ClipConfig::vit_base_patch32();

        // 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 CLIP safetensors: {}", e),
                    ))?
            }
        } else {
            VarBuilder::from_pth(weights_path, DType::F32, &device)
                .map_err(|e| PerceptionError::InferenceError(
                    format!("Failed to load CLIP weights: {}", e),
                ))?
        };

        // Create model
        let model = candle_clip::ClipModel::new(vb, &candle_config)
            .map_err(|e| PerceptionError::InferenceError(
                format!("Failed to initialize CLIP model: {}", e),
            ))?;

        // Load tokenizer
        let tokenizer = Tokenizer::from_file(tokenizer_path)
            .map_err(|e| PerceptionError::TokenizationError(
                format!("Failed to load CLIP tokenizer: {}", e),
            ))?;

        tracing::info!(
            model = clip_config.model_id.as_str(),
            dim = clip_config.dimension,
            device = ?device,
            "CLIP local model loaded successfully"
        );

        Ok(Self {
            model,
            tokenizer,
            device,
            config: clip_config.clone(),
        })
    }

    /// Encode text into a CLIP embedding vector.
    pub fn encode_text(&self, text: &str) -> Result<Vec<f32>, PerceptionError> {
        if text.is_empty() {
            return Err(PerceptionError::InferenceError("Empty text input".into()));
        }

        // Tokenize
        let encoding = self.tokenizer.encode(text, true)
            .map_err(|e| PerceptionError::TokenizationError(e.to_string()))?;

        // Pad/truncate to 77 tokens (CLIP max)
        let max_len = 77;
        let mut ids: Vec<u32> = encoding.get_ids().to_vec();
        ids.truncate(max_len);
        while ids.len() < max_len {
            ids.push(0); // pad token
        }

        let input_ids = Tensor::new(&ids[..], &self.device)
            .map_err(|e| PerceptionError::InferenceError(e.to_string()))?
            .unsqueeze(0) // [1, 77]
            .map_err(|e| PerceptionError::InferenceError(e.to_string()))?;

        let features = self.model.get_text_features(&input_ids)
            .map_err(|e| PerceptionError::InferenceError(
                format!("CLIP text encoding failed: {}", e),
            ))?;

        // L2 normalize
        let features = candle_clip::div_l2_norm(&features)
            .map_err(|e| PerceptionError::InferenceError(e.to_string()))?;

        // To CPU vec
        let features = features
            .to_device(&Device::Cpu)
            .map_err(|e| PerceptionError::InferenceError(e.to_string()))?;
        let vec: Vec<f32> = features.squeeze(0)
            .map_err(|e| PerceptionError::InferenceError(e.to_string()))?
            .to_vec1()
            .map_err(|e| PerceptionError::InferenceError(e.to_string()))?;

        Ok(vec)
    }

    /// Encode raw image bytes (JPEG/PNG/etc.) into a CLIP embedding vector.
    pub fn encode_image(&self, data: &[u8]) -> Result<Vec<f32>, PerceptionError> {
        if data.is_empty() {
            return Err(PerceptionError::InferenceError("Empty image data".into()));
        }

        let pixel_values = self.preprocess_image(data)?;

        let features = self.model.get_image_features(&pixel_values)
            .map_err(|e| PerceptionError::InferenceError(
                format!("CLIP image encoding failed: {}", e),
            ))?;

        // L2 normalize
        let features = candle_clip::div_l2_norm(&features)
            .map_err(|e| PerceptionError::InferenceError(e.to_string()))?;

        // To CPU vec
        let features = features
            .to_device(&Device::Cpu)
            .map_err(|e| PerceptionError::InferenceError(e.to_string()))?;
        let vec: Vec<f32> = features.squeeze(0)
            .map_err(|e| PerceptionError::InferenceError(e.to_string()))?
            .to_vec1()
            .map_err(|e| PerceptionError::InferenceError(e.to_string()))?;

        Ok(vec)
    }

    /// Preprocess image bytes to CLIP input tensor [1, 3, H, W].
    ///
    /// Pipeline: Decode → Resize → CenterCrop → Normalize
    fn preprocess_image(&self, data: &[u8]) -> Result<Tensor, PerceptionError> {
        let target_size = self.config.image_resolution as u32;

        // Decode image
        let img = image::load_from_memory(data)
            .map_err(|e| PerceptionError::InferenceError(
                format!("Failed to decode image: {}", e),
            ))?;

        // Resize — maintain aspect ratio, shortest side = target_size
        let (w, h) = (img.width(), img.height());
        let (new_w, new_h) = if w < h {
            (target_size, (target_size as f32 * h as f32 / w as f32) as u32)
        } else {
            ((target_size as f32 * w as f32 / h as f32) as u32, target_size)
        };
        let img = img.resize_exact(new_w, new_h, image::imageops::FilterType::Lanczos3);

        // Center crop to target_size x target_size
        let crop_x = (new_w.saturating_sub(target_size)) / 2;
        let crop_y = (new_h.saturating_sub(target_size)) / 2;
        let img = img.crop_imm(crop_x, crop_y, target_size, target_size);

        // Convert to RGB f32 tensor [3, H, W] with CLIP normalization
        let rgb = img.to_rgb8();
        let (tw, th) = (target_size as usize, target_size as usize);
        let mut pixel_data = vec![0.0f32; 3 * th * tw];

        for y in 0..th {
            for x in 0..tw {
                let pixel = rgb.get_pixel(x as u32, y as u32);
                for c in 0..3 {
                    let val = pixel[c] as f32 / 255.0;
                    let normalized = (val - CLIP_IMAGE_MEAN[c]) / CLIP_IMAGE_STD[c];
                    pixel_data[c * th * tw + y * tw + x] = normalized;
                }
            }
        }

        // Create tensor [1, 3, H, W]
        Tensor::from_vec(pixel_data, (1, 3, th, tw), &self.device)
            .map_err(|e| PerceptionError::InferenceError(
                format!("Failed to create image tensor: {}", e),
            ))
    }

    /// Encode a batch of texts.
    pub fn encode_texts_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, PerceptionError> {
        texts.iter().map(|t| self.encode_text(t)).collect()
    }

    /// Encode a batch of images.
    pub fn encode_images_batch(&self, images: &[&[u8]]) -> Result<Vec<Vec<f32>>, PerceptionError> {
        images.iter().map(|d| self.encode_image(d)).collect()
    }

    /// Get the output embedding dimension.
    pub fn dimension(&self) -> usize {
        self.config.dimension
    }

    /// Get the device being used.
    pub fn device(&self) -> &Device {
        &self.device
    }
}

// ============================================================================
// Image Preprocessing — Fallback (pure Rust, no external image crate)
// ============================================================================

/// Compute a content-hash embedding from raw image bytes.
///
/// This is a lightweight, deterministic pseudo-embedding that captures
/// some structural information about the image data. It is NOT a real
/// CLIP embedding — use the API mode for production quality.
///
/// The algorithm:
/// 1. Compute a sliding hash over the image bytes
/// 2. Use frequency analysis of byte patterns
/// 3. L2-normalize the output
fn content_hash_embedding(data: &[u8], dim: usize) -> Vec<f32> {
    let mut embedding = vec![0.0f32; dim];
    if data.is_empty() {
        return embedding;
    }

    // Phase 1: byte frequency distribution (captures color/intensity distribution)
    let freq_bins = dim.min(256);
    for &byte in data {
        let idx = (byte as usize * freq_bins / 256) % dim;
        embedding[idx] += 1.0;
    }

    // Phase 2: sliding window hash (captures local structure)
    let window_size = 8;
    if data.len() >= window_size {
        for window in data.windows(window_size) {
            let mut hash: u64 = 0;
            for &b in window {
                hash = hash.wrapping_mul(31).wrapping_add(b as u64);
            }
            let idx = (hash as usize) % dim;
            embedding[idx] += 0.1;
        }
    }

    // Phase 3: positional features (captures spatial layout)
    let quarter = data.len() / 4;
    if quarter > 0 {
        for (q, start) in [0, quarter, quarter * 2, quarter * 3].iter().enumerate() {
            let end = (*start + quarter).min(data.len());
            let slice = &data[*start..end];
            let mean: f32 = slice.iter().map(|&b| b as f32).sum::<f32>() / slice.len() as f32;
            let variance: f32 = slice.iter().map(|&b| {
                let diff = b as f32 - mean;
                diff * diff
            }).sum::<f32>() / slice.len() as f32;

            let base_idx = (q * dim / 4) % dim;
            embedding[base_idx] += mean / 255.0;
            if base_idx + 1 < dim {
                embedding[base_idx + 1] += variance.sqrt() / 255.0;
            }
        }
    }

    l2_normalize(&mut embedding);
    embedding
}

/// Compute a text content embedding (deterministic pseudo-embedding).
///
/// Uses character n-gram frequency analysis to create a fixed-dimension
/// vector from text content. NOT a real CLIP text embedding.
fn text_hash_embedding(text: &str, dim: usize) -> Vec<f32> {
    let mut embedding = vec![0.0f32; dim];
    if text.is_empty() {
        return embedding;
    }

    let bytes = text.as_bytes();

    // Unigram frequency
    for &b in bytes {
        let idx = (b as usize) % dim;
        embedding[idx] += 1.0;
    }

    // Bigram frequency
    if bytes.len() >= 2 {
        for pair in bytes.windows(2) {
            let hash = (pair[0] as usize).wrapping_mul(257).wrapping_add(pair[1] as usize);
            let idx = hash % dim;
            embedding[idx] += 0.5;
        }
    }

    // Trigram frequency
    if bytes.len() >= 3 {
        for triple in bytes.windows(3) {
            let hash = (triple[0] as usize)
                .wrapping_mul(65537)
                .wrapping_add((triple[1] as usize).wrapping_mul(257))
                .wrapping_add(triple[2] as usize);
            let idx = hash % dim;
            embedding[idx] += 0.25;
        }
    }

    // Length feature
    let len_idx = (bytes.len() * 7) % dim;
    embedding[len_idx] += (bytes.len() as f32).ln();

    l2_normalize(&mut embedding);
    embedding
}

// ============================================================================
// CLIP Embedding Engine
// ============================================================================

/// CLIP operation mode.
#[derive(Debug)]
enum ClipMode {
    /// Hash-based pseudo-embedding (development/testing, no model download)
    Hash,
    /// External API endpoint
    Api {
        endpoint: String,
        api_key: Option<String>,
    },
    /// Local Candle inference (real CLIP model, CPU or GPU)
    #[cfg(feature = "candle")]
    Local(ClipLocalModel),
}

/// CLIP multimodal embedding engine.
///
/// Provides text and image embeddings in a shared semantic space.
/// Supports three operation modes:
///
/// - **Local mode** (recommended): Real CLIP inference via Candle, auto CPU/GPU.
///   Activate with `ClipEmbeddingEngine::local("clip-vit-b32")`.
/// - **API mode**: Calls external CLIP inference endpoint.
///   Activate with `.with_api_endpoint()`.
/// - **Hash mode** (fallback): Deterministic pseudo-embedding for testing.
///   Default when `candle` feature is not enabled.
pub struct ClipEmbeddingEngine {
    /// Model configuration
    config: ClipModelConfig,
    /// Operation mode
    mode: ClipMode,
}

impl ClipEmbeddingEngine {
    /// Create a new CLIP engine in hash/pseudo-embedding mode.
    ///
    /// This mode does NOT require downloading model weights.
    /// Use `.local()` or `.with_local_model()` for real CLIP inference.
    ///
    /// # Arguments
    /// * `model_name` - One of: "clip-vit-b32", "clip-vit-b16", "clip-vit-l14"
    pub fn new(model_name: &str) -> Result<Self, PerceptionError> {
        let config = ClipModelConfig::from_name(model_name)
            .ok_or_else(|| PerceptionError::ModelNotLoaded(
                format!(
                    "Unknown CLIP model: {}. Available: {:?}",
                    model_name,
                    ClipModelConfig::available_models()
                ),
            ))?;

        Ok(Self {
            config,
            mode: ClipMode::Hash,
        })
    }

    /// Create a CLIP engine with local Candle inference (real CLIP model).
    ///
    /// Downloads the model from HuggingFace Hub on first call.
    /// Automatically selects CPU/GPU via `DeviceSelector`.
    ///
    /// # Arguments
    /// * `model_name` - One of: "clip-vit-b32", "clip-vit-b16", "clip-vit-l14"
    #[cfg(feature = "candle")]
    pub fn local(model_name: &str) -> Result<Self, PerceptionError> {
        let config = ClipModelConfig::from_name(model_name)
            .ok_or_else(|| PerceptionError::ModelNotLoaded(
                format!(
                    "Unknown CLIP model: {}. Available: {:?}",
                    model_name,
                    ClipModelConfig::available_models()
                ),
            ))?;

        // Use DeviceSelector for automatic CPU/GPU selection
        let selector = crate::device::DeviceSelector::auto();
        let (device, is_gpu) = selector.candle_device_with_fallback("clip_local_inference");

        tracing::info!(
            model = config.model_id.as_str(),
            gpu = is_gpu,
            device = ?device,
            "Initializing CLIP local inference engine"
        );

        let local_model = ClipLocalModel::from_pretrained(&config, device)?;

        Ok(Self {
            config,
            mode: ClipMode::Local(local_model),
        })
    }

    /// Create a CLIP engine with local inference on a specific device.
    #[cfg(feature = "candle")]
    pub fn local_on_device(model_name: &str, device: Device) -> Result<Self, PerceptionError> {
        let config = ClipModelConfig::from_name(model_name)
            .ok_or_else(|| PerceptionError::ModelNotLoaded(
                format!("Unknown CLIP model: {}", model_name),
            ))?;

        let local_model = ClipLocalModel::from_pretrained(&config, device)?;

        Ok(Self {
            config,
            mode: ClipMode::Local(local_model),
        })
    }

    /// Attach a pre-loaded local model to this engine.
    #[cfg(feature = "candle")]
    pub fn with_local_model(mut self, model: ClipLocalModel) -> Self {
        self.mode = ClipMode::Local(model);
        self
    }

    /// Configure an external HTTP API endpoint for CLIP inference.
    ///
    /// When set, embeddings are generated by calling the external API
    /// instead of local inference. Useful for remote GPU-based servers.
    ///
    /// Expected API contract:
    /// - `POST {endpoint}/encode/image` with JSON `{"image": "<base64>", "model": "..."}`
    /// - `POST {endpoint}/encode/text` with JSON `{"text": "...", "model": "..."}`
    /// - Response: `{"embedding": [f32, ...]}`
    pub fn with_api_endpoint(mut self, url: impl Into<String>) -> Self {
        self.mode = ClipMode::Api {
            endpoint: url.into(),
            api_key: None,
        };
        self
    }

    /// Set API key for external endpoint authentication.
    pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
        if let ClipMode::Api { ref mut api_key, .. } = self.mode {
            *api_key = Some(key.into());
        }
        self
    }

    /// Check if the engine is running in API mode.
    pub fn is_api_mode(&self) -> bool {
        matches!(self.mode, ClipMode::Api { .. })
    }

    /// Check if the engine is running in local Candle inference mode.
    pub fn is_local_mode(&self) -> bool {
        #[cfg(feature = "candle")]
        {
            matches!(self.mode, ClipMode::Local(_))
        }
        #[cfg(not(feature = "candle"))]
        {
            false
        }
    }

    /// Check if the engine is running in hash/pseudo-embedding mode.
    pub fn is_hash_mode(&self) -> bool {
        matches!(self.mode, ClipMode::Hash)
    }

    /// Encode an image into a CLIP embedding vector.
    ///
    /// - **Local mode**: Real CLIP ViT inference (JPEG/PNG/BMP/GIF supported).
    /// - **API mode**: Sends base64-encoded image to external service.
    /// - **Hash mode**: Generates a content-hash pseudo-embedding.
    pub fn encode_image(&self, data: &[u8]) -> Result<Vec<f32>, PerceptionError> {
        if data.is_empty() {
            return Err(PerceptionError::InferenceError("Empty image data".into()));
        }

        match &self.mode {
            #[cfg(feature = "candle")]
            ClipMode::Local(model) => model.encode_image(data),
            ClipMode::Api { endpoint, .. } => self.encode_image_via_api(data, endpoint),
            ClipMode::Hash => Ok(content_hash_embedding(data, self.config.dimension)),
        }
    }

    /// Encode text into a CLIP embedding vector.
    ///
    /// The resulting vector is in the same semantic space as image embeddings.
    pub fn encode_text(&self, text: &str) -> Result<Vec<f32>, PerceptionError> {
        if text.is_empty() {
            return Err(PerceptionError::InferenceError("Empty text input".into()));
        }

        match &self.mode {
            #[cfg(feature = "candle")]
            ClipMode::Local(model) => model.encode_text(text),
            ClipMode::Api { endpoint, .. } => self.encode_text_via_api(text, endpoint),
            ClipMode::Hash => Ok(text_hash_embedding(text, self.config.dimension)),
        }
    }

    /// Encode multiple images in batch.
    pub fn encode_images_batch(&self, images: &[&[u8]]) -> Result<Vec<Vec<f32>>, PerceptionError> {
        images.iter().map(|data| self.encode_image(data)).collect()
    }

    /// Encode multiple texts in batch.
    pub fn encode_texts_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, PerceptionError> {
        texts.iter().map(|text| self.encode_text(text)).collect()
    }

    /// Call external API for image embedding (blocking HTTP call).
    fn encode_image_via_api(
        &self,
        data: &[u8],
        endpoint: &str,
    ) -> Result<Vec<f32>, PerceptionError> {
        let url = format!("{}/encode/image", endpoint);
        let encoded = base64_encode(data);

        let body = serde_json::json!({
            "image": encoded,
            "model": self.config.model_id,
        });

        self.post_json_extract_embedding(&url, &body)
    }

    /// Call external API for text embedding (blocking HTTP call).
    fn encode_text_via_api(
        &self,
        text: &str,
        endpoint: &str,
    ) -> Result<Vec<f32>, PerceptionError> {
        let url = format!("{}/encode/text", endpoint);
        let body = serde_json::json!({
            "text": text,
            "model": self.config.model_id,
        });

        self.post_json_extract_embedding(&url, &body)
    }

    /// POST JSON to a URL and extract the "embedding" field from the response.
    fn post_json_extract_embedding(
        &self,
        url: &str,
        body: &serde_json::Value,
    ) -> Result<Vec<f32>, PerceptionError> {
        let body_str = body.to_string();

        // Extract api_key from mode
        let api_key = match &self.mode {
            ClipMode::Api { api_key, .. } => api_key.as_ref(),
            _ => None,
        };

        // Parse URL
        let parsed = SimpleUrl::parse(url)
            .ok_or_else(|| PerceptionError::InferenceError(format!("Invalid URL: {}", url)))?;

        let request = format!(
            "POST {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{}\r\n{}",
            parsed.path,
            parsed.host,
            body_str.len(),
            api_key.map(|k| format!("Authorization: Bearer {}\r\n", k)).unwrap_or_default(),
            body_str,
        );

        let addr = format!("{}:{}", parsed.host, parsed.port);
        let socket_addr: std::net::SocketAddr = addr.parse()
            .map_err(|e| PerceptionError::InferenceError(format!("Invalid address {}: {}", addr, e)))?;
        let mut stream = std::net::TcpStream::connect_timeout(
            &socket_addr,
            std::time::Duration::from_secs(30),
        ).map_err(|e| PerceptionError::InferenceError(format!("Connection failed: {}", e)))?;

        stream.set_read_timeout(Some(std::time::Duration::from_secs(60)))
            .map_err(|e| PerceptionError::InferenceError(format!("Set timeout failed: {}", e)))?;

        use std::io::Write;
        stream.write_all(request.as_bytes())
            .map_err(|e| PerceptionError::InferenceError(format!("Write failed: {}", e)))?;

        use std::io::Read;
        let mut response = String::new();
        stream.read_to_string(&mut response)
            .map_err(|e| PerceptionError::InferenceError(format!("Read failed: {}", e)))?;

        // Extract body after \r\n\r\n
        let body_start = response.find("\r\n\r\n")
            .map(|i| i + 4)
            .unwrap_or(0);
        let response_body = &response[body_start..];

        let json: serde_json::Value = serde_json::from_str(response_body)
            .map_err(|e| PerceptionError::InferenceError(format!("Parse response failed: {}", e)))?;

        json.get("embedding")
            .and_then(|v| v.as_array())
            .map(|arr| arr.iter().filter_map(|v| v.as_f64().map(|f| f as f32)).collect::<Vec<f32>>())
            .ok_or_else(|| PerceptionError::InferenceError("No 'embedding' field in response".into()))
    }

    /// Get the model configuration.
    pub fn model_config(&self) -> &ClipModelConfig {
        &self.config
    }
}

// ============================================================================
// PerceptionEngine Implementation
// ============================================================================

impl PerceptionEngine for ClipEmbeddingEngine {
    fn conceptualize(&self, input: &str) -> Result<Vec<f32>, PerceptionError> {
        self.encode_text(input)
    }

    fn align_modality(&self, data: &[u8], mime_type: &str) -> Result<Vec<f32>, PerceptionError> {
        match mime_type {
            s if s.starts_with("image/") => self.encode_image(data),
            s if s.starts_with("text/") => {
                let text = std::str::from_utf8(data)
                    .map_err(|e| PerceptionError::InferenceError(format!("Invalid UTF-8: {}", e)))?;
                self.encode_text(text)
            }
            _ => Err(PerceptionError::UnsupportedModality(
                format!("CLIP does not support modality: {}. Supported: image/*, text/*", mime_type),
            )),
        }
    }

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

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

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

    fn max_input_length(&self) -> Option<usize> {
        Some(77) // CLIP text encoder max tokens
    }
}

// ============================================================================
// Helpers
// ============================================================================

/// L2-normalize a vector in-place.
fn l2_normalize(v: &mut [f32]) {
    let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
    if norm > 1e-12 {
        for x in v.iter_mut() {
            *x /= norm;
        }
    }
}

/// Base64 encode bytes (minimal implementation, no extra deps).
fn base64_encode(data: &[u8]) -> String {
    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut result = String::with_capacity((data.len() + 2) / 3 * 4);
    for chunk in data.chunks(3) {
        let b0 = chunk[0] as u32;
        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
        let n = (b0 << 16) | (b1 << 8) | b2;
        result.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
        result.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
        if chunk.len() > 1 {
            result.push(CHARS[((n >> 6) & 0x3F) as usize] as char);
        } else {
            result.push('=');
        }
        if chunk.len() > 2 {
            result.push(CHARS[(n & 0x3F) as usize] as char);
        } else {
            result.push('=');
        }
    }
    result
}

/// Simple URL parser (avoids `url` crate dependency).
struct SimpleUrl {
    host: String,
    port: u16,
    path: String,
}

impl SimpleUrl {
    fn parse(url: &str) -> Option<Self> {
        let (scheme, rest) = if url.starts_with("https://") {
            ("https", &url[8..])
        } else if url.starts_with("http://") {
            ("http", &url[7..])
        } else {
            return None;
        };

        let (host_port, path) = match rest.find('/') {
            Some(i) => (&rest[..i], &rest[i..]),
            None => (rest, "/"),
        };

        let (host, port) = match host_port.find(':') {
            Some(i) => {
                let port: u16 = host_port[i + 1..].parse().ok()?;
                (&host_port[..i], port)
            }
            None => {
                let default_port = if scheme == "https" { 443 } else { 80 };
                (host_port, default_port)
            }
        };

        Some(Self {
            host: host.to_string(),
            port,
            path: path.to_string(),
        })
    }
}

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

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

    #[test]
    fn test_clip_config_from_name() {
        let config = ClipModelConfig::from_name("clip-vit-b32").unwrap();
        assert_eq!(config.dimension, 512);
        assert_eq!(config.image_resolution, 224);

        let config = ClipModelConfig::from_name("clip-vit-l14").unwrap();
        assert_eq!(config.dimension, 768);

        assert!(ClipModelConfig::from_name("unknown-model").is_none());
    }

    #[test]
    fn test_available_models() {
        let models = ClipModelConfig::available_models();
        assert_eq!(models.len(), 3);
        assert!(models.contains(&"clip-vit-b32"));
    }

    #[test]
    fn test_clip_engine_creation() {
        let engine = ClipEmbeddingEngine::new("clip-vit-b32").unwrap();
        assert_eq!(engine.dimension(), 512);
        assert_eq!(engine.model_name(), "clip-vit-b32");
        assert!(engine.supports_modality(Modality::Text));
        assert!(engine.supports_modality(Modality::Image));
        assert!(!engine.supports_modality(Modality::Audio));
        assert!(!engine.is_api_mode());
    }

    #[test]
    fn test_clip_engine_invalid_model() {
        let result = ClipEmbeddingEngine::new("nonexistent-model");
        assert!(result.is_err());
    }

    #[test]
    fn test_clip_text_embedding() {
        let engine = ClipEmbeddingEngine::new("clip-vit-b32").unwrap();
        let vec = engine.encode_text("a photo of a cat").unwrap();
        assert_eq!(vec.len(), 512);

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

    #[test]
    fn test_clip_text_empty_error() {
        let engine = ClipEmbeddingEngine::new("clip-vit-b32").unwrap();
        assert!(engine.encode_text("").is_err());
    }

    #[test]
    fn test_clip_image_empty_error() {
        let engine = ClipEmbeddingEngine::new("clip-vit-b32").unwrap();
        assert!(engine.encode_image(&[]).is_err());
    }

    #[test]
    fn test_clip_text_deterministic() {
        let engine = ClipEmbeddingEngine::new("clip-vit-b32").unwrap();
        let v1 = engine.encode_text("hello world").unwrap();
        let v2 = engine.encode_text("hello world").unwrap();
        assert_eq!(v1, v2, "Same input should produce same output");
    }

    #[test]
    fn test_clip_different_texts() {
        let engine = ClipEmbeddingEngine::new("clip-vit-b32").unwrap();
        let v1 = engine.encode_text("a photo of a cat").unwrap();
        let v2 = engine.encode_text("a photo of a dog").unwrap();
        assert_ne!(v1, v2, "Different inputs should give different embeddings");
    }

    #[test]
    fn test_clip_image_embedding() {
        let engine = ClipEmbeddingEngine::new("clip-vit-b32").unwrap();
        // Synthetic "image" data
        let data: Vec<u8> = (0..1024).map(|i| (i % 256) as u8).collect();
        let vec = engine.encode_image(&data).unwrap();
        assert_eq!(vec.len(), 512);

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

    #[test]
    fn test_clip_image_deterministic() {
        let engine = ClipEmbeddingEngine::new("clip-vit-b32").unwrap();
        let data: Vec<u8> = (0..512).map(|i| (i * 7 % 256) as u8).collect();
        let v1 = engine.encode_image(&data).unwrap();
        let v2 = engine.encode_image(&data).unwrap();
        assert_eq!(v1, v2);
    }

    #[test]
    fn test_clip_perception_engine_trait() {
        let engine = ClipEmbeddingEngine::new("clip-vit-b32").unwrap();

        // Text via conceptualize
        let vec = engine.conceptualize("test text").unwrap();
        assert_eq!(vec.len(), 512);

        // Image via align_modality
        let image_data: Vec<u8> = vec![0xFF, 0xD8, 0xFF, 0xE0]; // JPEG header bytes
        let vec = engine.align_modality(&image_data, "image/jpeg").unwrap();
        assert_eq!(vec.len(), 512);

        // Unsupported modality
        let result = engine.align_modality(b"audio data", "audio/wav");
        assert!(result.is_err());
    }

    #[test]
    fn test_clip_768d_model() {
        let engine = ClipEmbeddingEngine::new("clip-vit-l14").unwrap();
        assert_eq!(engine.dimension(), 768);

        let vec = engine.encode_text("high quality test").unwrap();
        assert_eq!(vec.len(), 768);

        let data: Vec<u8> = (0..256).map(|i| (i % 256) as u8).collect();
        let vec = engine.encode_image(&data).unwrap();
        assert_eq!(vec.len(), 768);
    }

    #[test]
    fn test_content_hash_embedding_properties() {
        // Different data should produce different embeddings
        let e1 = content_hash_embedding(&[1, 2, 3, 4], 32);
        let e2 = content_hash_embedding(&[5, 6, 7, 8], 32);
        assert_ne!(e1, e2);

        // Same data should produce same embeddings
        let e3 = content_hash_embedding(&[1, 2, 3, 4], 32);
        assert_eq!(e1, e3);

        // All should be unit vectors
        let norm: f32 = e1.iter().map(|x| x * x).sum::<f32>().sqrt();
        assert!((norm - 1.0).abs() < 0.01);
    }

    #[test]
    fn test_text_hash_embedding_properties() {
        let e1 = text_hash_embedding("hello", 32);
        let e2 = text_hash_embedding("world", 32);
        assert_ne!(e1, e2);

        let e3 = text_hash_embedding("hello", 32);
        assert_eq!(e1, e3);

        let norm: f32 = e1.iter().map(|x| x * x).sum::<f32>().sqrt();
        assert!((norm - 1.0).abs() < 0.01);
    }

    #[test]
    fn test_l2_normalize() {
        let mut v = vec![3.0, 4.0];
        l2_normalize(&mut v);
        assert!((v[0] - 0.6).abs() < 0.01);
        assert!((v[1] - 0.8).abs() < 0.01);

        // Zero vector should stay zero
        let mut z = vec![0.0, 0.0];
        l2_normalize(&mut z);
        assert_eq!(z, vec![0.0, 0.0]);
    }

    #[test]
    fn test_base64_encode() {
        assert_eq!(base64_encode(b""), "");
        assert_eq!(base64_encode(b"f"), "Zg==");
        assert_eq!(base64_encode(b"fo"), "Zm8=");
        assert_eq!(base64_encode(b"foo"), "Zm9v");
        assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
    }

    #[test]
    fn test_simple_url_parse() {
        let url = SimpleUrl::parse("http://localhost:8080/encode/text").unwrap();
        assert_eq!(url.host, "localhost");
        assert_eq!(url.port, 8080);
        assert_eq!(url.path, "/encode/text");

        let url = SimpleUrl::parse("https://api.example.com/clip").unwrap();
        assert_eq!(url.host, "api.example.com");
        assert_eq!(url.port, 443);
        assert_eq!(url.path, "/clip");

        assert!(SimpleUrl::parse("ftp://invalid").is_none());
    }

    #[test]
    fn test_api_mode_flag() {
        let engine = ClipEmbeddingEngine::new("clip-vit-b32").unwrap();
        assert!(!engine.is_api_mode());
        assert!(engine.is_hash_mode());

        let engine = engine.with_api_endpoint("http://localhost:8080");
        assert!(engine.is_api_mode());
        assert!(!engine.is_hash_mode());
    }

    #[test]
    fn test_mode_transitions() {
        // Default is Hash mode
        let engine = ClipEmbeddingEngine::new("clip-vit-b32").unwrap();
        assert!(engine.is_hash_mode());
        assert!(!engine.is_api_mode());
        assert!(!engine.is_local_mode());

        // Transition to API mode
        let engine = engine.with_api_endpoint("http://localhost:8080");
        assert!(engine.is_api_mode());
        assert!(!engine.is_hash_mode());
        assert!(!engine.is_local_mode());

        // API key should work
        let engine = engine.with_api_key("test-key");
        assert!(engine.is_api_mode());
    }

    // =========================================================================
    // Phase-64: CLIP Local inference tests (require model download, #[ignore])
    // =========================================================================

    /// Test that ClipLocalModel can be constructed (requires model download).
    /// Run with: cargo test --features candle -- --ignored test_clip_local
    #[cfg(feature = "candle")]
    #[test]
    #[ignore]
    fn test_clip_local_text_encoding() {
        let engine = ClipEmbeddingEngine::local("clip-vit-b32").unwrap();
        assert!(engine.is_local_mode());
        assert_eq!(engine.dimension(), 512);

        let vec = engine.encode_text("a photo of a cat").unwrap();
        assert_eq!(vec.len(), 512);

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

    #[cfg(feature = "candle")]
    #[test]
    #[ignore]
    fn test_clip_local_image_encoding() {
        let engine = ClipEmbeddingEngine::local("clip-vit-b32").unwrap();

        // Create a minimal valid PNG image (1x1 red pixel)
        let png_data = create_test_png_1x1();
        let vec = engine.encode_image(&png_data).unwrap();
        assert_eq!(vec.len(), 512);

        let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
        assert!((norm - 1.0).abs() < 0.05, "L2 norm should be ~1.0, got {}", norm);
    }

    #[cfg(feature = "candle")]
    #[test]
    #[ignore]
    fn test_clip_local_cross_modal_similarity() {
        let engine = ClipEmbeddingEngine::local("clip-vit-b32").unwrap();

        let text_vec = engine.encode_text("a red square").unwrap();
        let text_vec2 = engine.encode_text("random noise").unwrap();

        // Same text should produce same embedding
        let text_vec_dup = engine.encode_text("a red square").unwrap();
        assert_eq!(text_vec, text_vec_dup, "Deterministic encoding");

        // Different texts should produce different embeddings
        assert_ne!(text_vec, text_vec2);
    }

    #[cfg(feature = "candle")]
    #[test]
    #[ignore]
    fn test_clip_local_device_selector_integration() {
        // Test that DeviceSelector properly selects device
        let selector = crate::device::DeviceSelector::auto();
        let (device, is_gpu) = selector.candle_device_with_fallback("test_clip");

        let config = ClipModelConfig::from_name("clip-vit-b32").unwrap();
        let model = ClipLocalModel::from_pretrained(&config, device);
        // Should succeed regardless of GPU availability
        assert!(model.is_ok(), "Model loading should succeed on any device");

        if is_gpu {
            println!("CLIP loaded on GPU");
        } else {
            println!("CLIP loaded on CPU");
        }
    }

    /// Test image preprocessing with the image crate
    #[cfg(feature = "candle")]
    #[test]
    fn test_clip_image_preprocessing_constants() {
        // Verify CLIP normalization constants are correct
        assert!((CLIP_IMAGE_MEAN[0] - 0.48145466).abs() < 1e-6);
        assert!((CLIP_IMAGE_STD[0] - 0.26862954).abs() < 1e-6);
    }

    /// Helper: create a minimal valid PNG (1x1 red pixel)
    #[cfg(feature = "candle")]
    fn create_test_png_1x1() -> Vec<u8> {
        use image::{ImageBuffer, Rgb};
        let img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(1, 1, Rgb([255, 0, 0]));
        let mut buf = std::io::Cursor::new(Vec::new());
        img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
        buf.into_inner()
    }

    #[cfg(feature = "candle")]
    #[test]
    fn test_clip_preprocess_image_valid_png() {
        // This test just ensures the preprocessing pipeline doesn't crash
        // (does NOT require model weights)
        let png_data = create_test_png_1x1();

        // Verify image crate can decode it
        let img = image::load_from_memory(&png_data);
        assert!(img.is_ok(), "Should decode valid PNG");

        let img = img.unwrap();
        assert_eq!(img.width(), 1);
        assert_eq!(img.height(), 1);
    }
}
