//! Python bindings for Perception & Reasoning engines (Phase-28).
//!
//! Provides:
//! - `EmbeddingEngine` class wrapping `PerceptionEngine` (T28.1, T28.2)
//! - `ReasoningEngine` class wrapping `ReasoningEngine` (T28.5)
//! - `PerceptionPipeline` class wrapping `CompositeEngine` (T28.3, T28.6)
//! - numpy integration for embedding vectors (T28.4)

use std::sync::Arc;

use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList};
use pyo3::exceptions::{PyRuntimeError, PyValueError};

use numpy::{IntoPyArray, PyArray1};

use aifastdb_core::perception::{
    self,
    PerceptionConfig, ReasoningConfig,
    PerceptionEngine, ReasoningEngine as CoreReasoningEngine,
    DummyPerceptionEngine,
    Message, MessageRole,
};

// ============================================================================
// T28.6: PerceptionConfig mapped to Python
// ============================================================================

/// Configuration for the perception (embedding) engine.
///
/// Presets:
///     EmbeddingEngine.config_dummy(dim)   — deterministic hash-based (no model)
///     EmbeddingEngine.config_minilm()     — MiniLM-L6-v2 (384d, fast)
///     EmbeddingEngine.config_bge_small()  — BGE small (384d)
///     EmbeddingEngine.config_gemma()      — EmbeddingGemma (768d, multilingual)
///     EmbeddingEngine.whisper_small()     — Whisper-small audio (768d)
///     EmbeddingEngine.whisper_tiny()      — Whisper-tiny audio (384d, lightest)

// ============================================================================
// T28.1 / T28.2: EmbeddingEngine
// ============================================================================

/// High-level embedding engine for generating vector representations.
///
/// Wraps the Rust `PerceptionEngine` trait. Supports text embedding,
/// batch embedding, and (when the model supports it) multimodal embedding.
///
/// Example:
///     >>> engine = EmbeddingEngine()           # dummy engine, 384d
///     >>> vec = engine.embed("Hello world")     # list[float]
///     >>> arr = engine.embed_numpy("Hello")     # numpy.ndarray
///     >>> vecs = engine.embed_batch(["a", "b"]) # list[list[float]]
#[pyclass(name = "EmbeddingEngine")]
struct PyEmbeddingEngine {
    inner: Arc<dyn PerceptionEngine>,
}

#[pymethods]
impl PyEmbeddingEngine {
    // ---- Constructors ----

    /// Create an EmbeddingEngine.
    ///
    /// Args:
    ///     engine_type: "dummy", "candle", or "embedding_gemma" (default: "dummy").
    ///     model_id: HuggingFace model ID (default depends on engine_type).
    ///     dimension: Output dimension (only for dummy engine).
    ///     device: "cpu", "cuda", or "metal" (default: "cpu").
    ///     cache_dir: Local model cache directory.
    #[new]
    #[pyo3(signature = (*, engine_type="dummy", model_id=None, dimension=None, device="cpu", cache_dir=None))]
    fn new(
        engine_type: &str,
        model_id: Option<&str>,
        dimension: Option<usize>,
        device: &str,
        cache_dir: Option<&str>,
    ) -> PyResult<Self> {
        let config = match engine_type {
            "dummy" | "none" => {
                let dim = dimension.unwrap_or(384);
                PerceptionConfig {
                    engine_type: "none".to_string(),
                    dimension: Some(dim),
                    device: device.to_string(),
                    ..Default::default()
                }
            }
            "candle" => {
                let mid = model_id
                    .unwrap_or("sentence-transformers/all-MiniLM-L6-v2")
                    .to_string();
                PerceptionConfig {
                    engine_type: "candle".to_string(),
                    model_id: mid,
                    dimension,
                    device: device.to_string(),
                    cache_dir: cache_dir.map(|s| s.to_string()),
                    ..Default::default()
                }
            }
            "embedding_gemma" => {
                let mid = model_id
                    .unwrap_or("google/embeddinggemma-300m")
                    .to_string();
                PerceptionConfig {
                    engine_type: "embedding_gemma".to_string(),
                    model_id: mid,
                    dimension: dimension.or(Some(768)),
                    device: device.to_string(),
                    cache_dir: cache_dir.map(|s| s.to_string()),
                    max_length: Some(2048),
                    ..Default::default()
                }
            }
            "whisper" => {
                let mid = model_id
                    .unwrap_or("openai/whisper-small")
                    .to_string();
                PerceptionConfig {
                    engine_type: "whisper".to_string(),
                    model_id: mid,
                    dimension: dimension.or(Some(768)),
                    device: device.to_string(),
                    cache_dir: cache_dir.map(|s| s.to_string()),
                    ..Default::default()
                }
            }
            other => {
                return Err(PyValueError::new_err(format!(
                    "Unknown engine_type '{}'. Use 'dummy', 'candle', 'embedding_gemma', or 'whisper'.",
                    other
                )));
            }
        };

        let engine = perception::create_perception_engine(&config)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to create engine: {}", e)))?;

        Ok(Self {
            inner: Arc::from(engine),
        })
    }

    // ---- Preset constructors ----

    /// Create a dummy (hash-based) engine for testing.
    #[staticmethod]
    #[pyo3(signature = (dimension=384))]
    fn dummy(dimension: usize) -> PyResult<Self> {
        let engine = DummyPerceptionEngine::new(dimension);
        Ok(Self {
            inner: Arc::new(engine),
        })
    }

    /// Create a MiniLM engine (requires candle feature).
    #[staticmethod]
    fn minilm() -> PyResult<Self> {
        let config = PerceptionConfig::minilm();
        let engine = perception::create_perception_engine(&config)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))?;
        Ok(Self {
            inner: Arc::from(engine),
        })
    }

    /// Create a BGE-small engine (requires candle feature).
    #[staticmethod]
    fn bge_small() -> PyResult<Self> {
        let config = PerceptionConfig::bge_small();
        let engine = perception::create_perception_engine(&config)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))?;
        Ok(Self {
            inner: Arc::from(engine),
        })
    }

    /// Create an EmbeddingGemma engine (requires candle feature).
    #[staticmethod]
    #[pyo3(signature = (dimension=768))]
    fn gemma(dimension: usize) -> PyResult<Self> {
        let config = PerceptionConfig::embedding_gemma_matryoshka(dimension);
        let engine = perception::create_perception_engine(&config)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))?;
        Ok(Self {
            inner: Arc::from(engine),
        })
    }

    /// Create a Whisper-small audio embedding engine (768d, requires audio feature).
    ///
    /// Generates 768-dimensional audio embeddings using OpenAI Whisper-small encoder.
    /// Supports WAV, MP3, FLAC, OGG formats.
    ///
    /// Example:
    ///     >>> engine = EmbeddingEngine.whisper_small()
    ///     >>> audio_data = open("speech.wav", "rb").read()
    ///     >>> embedding = engine.embed_multimodal(audio_data, "audio/wav")
    ///     >>> len(embedding)  # 768
    #[staticmethod]
    fn whisper_small() -> PyResult<Self> {
        let config = PerceptionConfig::whisper_small();
        let engine = perception::create_perception_engine(&config)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))?;
        Ok(Self {
            inner: Arc::from(engine),
        })
    }

    /// Create a Whisper-tiny audio embedding engine (384d, lightest).
    #[staticmethod]
    fn whisper_tiny() -> PyResult<Self> {
        let config = PerceptionConfig::whisper_tiny();
        let engine = perception::create_perception_engine(&config)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))?;
        Ok(Self {
            inner: Arc::from(engine),
        })
    }

    // ---- T28.1: embed / embed_batch ----

    /// Generate an embedding vector for the given text.
    ///
    /// Args:
    ///     text: Input text to embed.
    ///
    /// Returns:
    ///     list[float]: The embedding vector.
    fn embed(&self, text: &str) -> PyResult<Vec<f32>> {
        self.inner
            .conceptualize(text)
            .map_err(|e| PyRuntimeError::new_err(format!("Embedding failed: {}", e)))
    }

    /// Generate embedding vectors for multiple texts.
    ///
    /// More efficient than calling embed() in a loop.
    ///
    /// Args:
    ///     texts: List of input texts.
    ///
    /// Returns:
    ///     list[list[float]]: A list of embedding vectors.
    fn embed_batch(&self, texts: Vec<String>) -> PyResult<Vec<Vec<f32>>> {
        let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
        self.inner
            .conceptualize_batch(&refs)
            .map_err(|e| PyRuntimeError::new_err(format!("Batch embedding failed: {}", e)))
    }

    // ---- T28.4: numpy integration ----

    /// Generate an embedding and return it as a numpy ndarray (float32).
    ///
    /// Args:
    ///     text: Input text to embed.
    ///
    /// Returns:
    ///     numpy.ndarray: 1-D float32 array of shape (dimension,).
    fn embed_numpy<'py>(&self, py: Python<'py>, text: &str) -> PyResult<Bound<'py, PyArray1<f32>>> {
        let vec = self
            .inner
            .conceptualize(text)
            .map_err(|e| PyRuntimeError::new_err(format!("Embedding failed: {}", e)))?;
        Ok(vec.into_pyarray_bound(py))
    }

    /// Generate embeddings for multiple texts and return as a list of numpy ndarrays.
    ///
    /// Args:
    ///     texts: List of input texts.
    ///
    /// Returns:
    ///     list[numpy.ndarray]: Each element is a 1-D float32 array.
    fn embed_batch_numpy<'py>(
        &self,
        py: Python<'py>,
        texts: Vec<String>,
    ) -> PyResult<Vec<Bound<'py, PyArray1<f32>>>> {
        let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
        let vecs = self
            .inner
            .conceptualize_batch(&refs)
            .map_err(|e| PyRuntimeError::new_err(format!("Batch embedding failed: {}", e)))?;
        Ok(vecs
            .into_iter()
            .map(|v| v.into_pyarray_bound(py))
            .collect())
    }

    /// Compute cosine similarity between two vectors.
    ///
    /// Accepts list[float] or numpy.ndarray inputs.
    ///
    /// Args:
    ///     a: First vector (list or ndarray).
    ///     b: Second vector (list or ndarray).
    ///
    /// Returns:
    ///     float: Cosine similarity in [-1, 1].
    #[staticmethod]
    fn cosine_similarity(a: Vec<f32>, b: Vec<f32>) -> PyResult<f32> {
        if a.len() != b.len() {
            return Err(PyValueError::new_err(format!(
                "Dimension mismatch: {} vs {}",
                a.len(),
                b.len()
            )));
        }
        Ok(perception::cosine_similarity(&a, &b))
    }

    // ---- T28.2: Multimodal embedding ----

    /// Generate an embedding from multimodal data (image, audio, etc.).
    ///
    /// Args:
    ///     data: Raw bytes of the media file.
    ///     mime_type: MIME type (e.g. "image/jpeg", "audio/mpeg").
    ///
    /// Returns:
    ///     list[float]: The embedding vector.
    fn embed_multimodal(&self, data: &[u8], mime_type: &str) -> PyResult<Vec<f32>> {
        self.inner
            .align_modality(data, mime_type)
            .map_err(|e| PyRuntimeError::new_err(format!("Multimodal embedding failed: {}", e)))
    }

    /// Generate a multimodal embedding and return as numpy ndarray.
    fn embed_multimodal_numpy<'py>(
        &self,
        py: Python<'py>,
        data: &[u8],
        mime_type: &str,
    ) -> PyResult<Bound<'py, PyArray1<f32>>> {
        let vec = self
            .inner
            .align_modality(data, mime_type)
            .map_err(|e| PyRuntimeError::new_err(format!("Multimodal embedding failed: {}", e)))?;
        Ok(vec.into_pyarray_bound(py))
    }

    // ---- Properties ----

    /// Get the output embedding dimension.
    #[getter]
    fn dimension(&self) -> usize {
        self.inner.dimension()
    }

    /// Get the model name/identifier.
    #[getter]
    fn model_name(&self) -> String {
        self.inner.model_name().to_string()
    }

    /// Get the maximum input length (tokens), or None if unlimited.
    #[getter]
    fn max_input_length(&self) -> Option<usize> {
        self.inner.max_input_length()
    }

    /// Get the list of supported modalities.
    #[getter]
    fn supported_modalities(&self) -> Vec<String> {
        self.inner
            .supported_modalities()
            .iter()
            .map(|m| m.name().to_string())
            .collect()
    }

    fn __repr__(&self) -> String {
        format!(
            "EmbeddingEngine(model='{}', dim={}, modalities={:?})",
            self.inner.model_name(),
            self.inner.dimension(),
            self.supported_modalities(),
        )
    }
}

// ============================================================================
// T28.5: ReasoningEngine
// ============================================================================

/// Reasoning engine for LLM-based cognitive tasks.
///
/// Connects to external LLM APIs (Ollama, OpenAI, etc.) for:
/// - Logical consistency checking
/// - Intent understanding
/// - Summarization
/// - Chat completion
///
/// Example:
///     >>> engine = ReasoningEngine.ollama("llama3.2")
///     >>> result = engine.understand_intent("What's the weather?")
///     >>> is_ok = engine.check_logic("Sky is blue", "Sky is red")
#[pyclass(name = "ReasoningEngine")]
struct PyReasoningEngine {
    inner: Box<dyn CoreReasoningEngine>,
}

#[pymethods]
impl PyReasoningEngine {
    /// Create a ReasoningEngine with explicit configuration.
    ///
    /// Args:
    ///     provider: "ollama", "openai", or "azure".
    ///     model: Model name/identifier.
    ///     endpoint: API endpoint URL (default depends on provider).
    ///     api_key: API key (required for openai/azure).
    ///     timeout: Request timeout in seconds (default: 60).
    ///     max_tokens: Maximum generation tokens.
    ///     temperature: Sampling temperature (0.0-2.0).
    #[new]
    #[pyo3(signature = (provider, model, *, endpoint=None, api_key=None, timeout=60, max_tokens=None, temperature=None))]
    fn new(
        provider: &str,
        model: &str,
        endpoint: Option<&str>,
        api_key: Option<&str>,
        timeout: u64,
        max_tokens: Option<usize>,
        temperature: Option<f32>,
    ) -> PyResult<Self> {
        let mut config = match provider {
            "ollama" => ReasoningConfig::ollama(model),
            "openai" => {
                let key = api_key
                    .ok_or_else(|| PyValueError::new_err("api_key is required for OpenAI"))?;
                ReasoningConfig::openai(key, model)
            }
            "azure" => {
                let ep = endpoint
                    .ok_or_else(|| PyValueError::new_err("endpoint is required for Azure"))?;
                let key = api_key
                    .ok_or_else(|| PyValueError::new_err("api_key is required for Azure"))?;
                ReasoningConfig::azure(ep, key, model)
            }
            other => {
                return Err(PyValueError::new_err(format!(
                    "Unknown provider '{}'. Use 'ollama', 'openai', or 'azure'.",
                    other
                )));
            }
        };

        if let Some(ep) = endpoint {
            config = config.with_endpoint(ep);
        }
        config = config.with_timeout(timeout);
        if let Some(mt) = max_tokens {
            config = config.with_max_tokens(mt);
        }
        if let Some(t) = temperature {
            config = config.with_temperature(t);
        }

        let engine = perception::create_reasoning_engine(&config)
            .map_err(|e| PyRuntimeError::new_err(format!("Failed to create reasoning engine: {}", e)))?;

        Ok(Self { inner: engine })
    }

    // ---- Preset constructors ----

    /// Create an Ollama-backed reasoning engine.
    #[staticmethod]
    #[pyo3(signature = (model="llama3.2", *, endpoint=None, timeout=60))]
    fn ollama(model: &str, endpoint: Option<&str>, timeout: u64) -> PyResult<Self> {
        let mut config = ReasoningConfig::ollama(model);
        if let Some(ep) = endpoint {
            config = config.with_endpoint(ep);
        }
        config = config.with_timeout(timeout);
        let engine = perception::create_reasoning_engine(&config)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))?;
        Ok(Self { inner: engine })
    }

    /// Create an OpenAI-backed reasoning engine.
    #[staticmethod]
    #[pyo3(signature = (api_key, model="gpt-4o-mini", *, timeout=60, max_tokens=None))]
    fn openai(api_key: &str, model: &str, timeout: u64, max_tokens: Option<usize>) -> PyResult<Self> {
        let mut config = ReasoningConfig::openai(api_key, model).with_timeout(timeout);
        if let Some(mt) = max_tokens {
            config = config.with_max_tokens(mt);
        }
        let engine = perception::create_reasoning_engine(&config)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))?;
        Ok(Self { inner: engine })
    }

    // ---- T28.5: Reasoning methods ----

    /// Check logical consistency between two facts/statements.
    ///
    /// Args:
    ///     fact_a: First statement.
    ///     fact_b: Second statement.
    ///
    /// Returns:
    ///     bool: True if the facts are logically consistent.
    fn check_logic(&self, fact_a: &str, fact_b: &str) -> PyResult<bool> {
        self.inner
            .check_logic_consistency(fact_a, fact_b)
            .map_err(|e| PyRuntimeError::new_err(format!("Logic check failed: {}", e)))
    }

    /// Understand the intent behind a user query.
    ///
    /// Args:
    ///     query: The user's query or request.
    ///
    /// Returns:
    ///     str: Structured understanding of the intent.
    fn understand_intent(&self, query: &str) -> PyResult<String> {
        self.inner
            .understand_intent(query)
            .map_err(|e| PyRuntimeError::new_err(format!("Intent understanding failed: {}", e)))
    }

    /// Generate a summary of the given content.
    ///
    /// Args:
    ///     content: The text to summarize.
    ///     max_tokens: Maximum length of the summary (default: 200).
    ///
    /// Returns:
    ///     str: The generated summary.
    #[pyo3(signature = (content, *, max_tokens=200))]
    fn summarize(&self, content: &str, max_tokens: usize) -> PyResult<String> {
        self.inner
            .summarize(content, max_tokens)
            .map_err(|e| PyRuntimeError::new_err(format!("Summarization failed: {}", e)))
    }

    /// Chat completion with conversation history.
    ///
    /// Args:
    ///     messages: List of message dicts with "role" and "content" keys.
    ///         role: "system", "user", or "assistant"
    ///
    /// Returns:
    ///     dict: {"content": str, "usage": dict|None, "model": str|None}
    fn chat(&self, py: Python<'_>, messages: &Bound<'_, PyList>) -> PyResult<PyObject> {
        let msgs: Vec<Message> = messages
            .iter()
            .map(|item| {
                let dict = item
                    .downcast::<PyDict>()
                    .map_err(|_| PyValueError::new_err("Each message must be a dict"))?;
                let role_str: String = dict
                    .get_item("role")?
                    .ok_or_else(|| PyValueError::new_err("Message must have 'role' key"))?
                    .extract()?;
                let content: String = dict
                    .get_item("content")?
                    .ok_or_else(|| PyValueError::new_err("Message must have 'content' key"))?
                    .extract()?;

                let role = match role_str.as_str() {
                    "system" => MessageRole::System,
                    "user" => MessageRole::User,
                    "assistant" => MessageRole::Assistant,
                    other => {
                        return Err(PyValueError::new_err(format!(
                            "Unknown role '{}'. Use 'system', 'user', or 'assistant'.",
                            other
                        )))
                    }
                };

                Ok(Message { role, content })
            })
            .collect::<PyResult<Vec<_>>>()?;

        let result = self
            .inner
            .chat(&msgs)
            .map_err(|e| PyRuntimeError::new_err(format!("Chat failed: {}", e)))?;

        let dict = PyDict::new_bound(py);
        dict.set_item("content", &result.content)?;
        dict.set_item("model", result.model.as_deref())?;
        if let Some(usage) = &result.usage {
            let usage_dict = PyDict::new_bound(py);
            usage_dict.set_item("prompt_tokens", usage.prompt_tokens)?;
            usage_dict.set_item("completion_tokens", usage.completion_tokens)?;
            usage_dict.set_item("total_tokens", usage.total_tokens)?;
            dict.set_item("usage", usage_dict)?;
        } else {
            dict.set_item("usage", py.None())?;
        }
        Ok(dict.into())
    }

    /// Get the model name.
    #[getter]
    fn model_name(&self) -> String {
        self.inner.model_name().to_string()
    }

    /// Check if the engine is available/connected.
    #[getter]
    fn is_available(&self) -> bool {
        self.inner.is_available()
    }

    fn __repr__(&self) -> String {
        format!(
            "ReasoningEngine(model='{}', available={})",
            self.inner.model_name(),
            self.inner.is_available(),
        )
    }
}

// ============================================================================
// T28.3 / T28.6: PerceptionPipeline (CompositeEngine)
// ============================================================================

/// Combined perception + reasoning pipeline.
///
/// Wraps both an EmbeddingEngine (for vector generation) and an optional
/// ReasoningEngine (for LLM-based tasks) in a single interface.
///
/// This is useful for workflows that need both embedding and reasoning,
/// such as ``remember_with_embedding()`` which auto-generates embeddings
/// before storing documents.
///
/// Example:
///     >>> pipe = PerceptionPipeline()   # dummy perception, no reasoning
///     >>> vec = pipe.embed("Hello")
///     >>> pipe = PerceptionPipeline(
///     ...     engine_type="dummy",
///     ...     reasoning_provider="ollama",
///     ...     reasoning_model="llama3.2",
///     ... )
#[pyclass(name = "PerceptionPipeline")]
struct PyPerceptionPipeline {
    perception: Arc<dyn PerceptionEngine>,
    reasoning: Option<Box<dyn CoreReasoningEngine>>,
}

#[pymethods]
impl PyPerceptionPipeline {
    /// Create a PerceptionPipeline.
    ///
    /// Args:
    ///     engine_type: Perception engine type (default: "dummy").
    ///     dimension: Output dimension for dummy engine (default: 384).
    ///     device: Device for perception engine (default: "cpu").
    ///     reasoning_provider: Optional reasoning provider ("ollama", "openai", etc.).
    ///     reasoning_model: Model for reasoning engine.
    ///     reasoning_endpoint: Custom API endpoint.
    ///     reasoning_api_key: API key for reasoning.
    #[new]
    #[pyo3(signature = (*, engine_type="dummy", dimension=384, device="cpu",
                        reasoning_provider=None, reasoning_model=None,
                        reasoning_endpoint=None, reasoning_api_key=None))]
    fn new(
        engine_type: &str,
        dimension: usize,
        device: &str,
        reasoning_provider: Option<&str>,
        reasoning_model: Option<&str>,
        reasoning_endpoint: Option<&str>,
        reasoning_api_key: Option<&str>,
    ) -> PyResult<Self> {
        // Build perception config
        let p_config = match engine_type {
            "dummy" | "none" => PerceptionConfig {
                engine_type: "none".to_string(),
                dimension: Some(dimension),
                device: device.to_string(),
                ..Default::default()
            },
            "candle" => PerceptionConfig {
                engine_type: "candle".to_string(),
                device: device.to_string(),
                ..Default::default()
            },
            "embedding_gemma" => PerceptionConfig {
                engine_type: "embedding_gemma".to_string(),
                dimension: Some(dimension),
                device: device.to_string(),
                ..Default::default()
            },
            other => {
                return Err(PyValueError::new_err(format!(
                    "Unknown engine_type '{}'",
                    other
                )));
            }
        };

        let perception = perception::create_perception_engine(&p_config)
            .map_err(|e| PyRuntimeError::new_err(format!("Perception engine error: {}", e)))?;

        // Build optional reasoning
        let reasoning = if let Some(provider) = reasoning_provider {
            let model = reasoning_model.unwrap_or("llama3.2");
            let r_config = match provider {
                "ollama" => {
                    let mut c = ReasoningConfig::ollama(model);
                    if let Some(ep) = reasoning_endpoint {
                        c = c.with_endpoint(ep);
                    }
                    c
                }
                "openai" => {
                    let key = reasoning_api_key.ok_or_else(|| {
                        PyValueError::new_err("reasoning_api_key required for openai")
                    })?;
                    ReasoningConfig::openai(key, model)
                }
                other => {
                    return Err(PyValueError::new_err(format!(
                        "Unknown reasoning_provider '{}'",
                        other
                    )));
                }
            };
            match perception::create_reasoning_engine(&r_config) {
                Ok(engine) => Some(engine),
                Err(e) => {
                    // Non-fatal: reasoning is optional
                    eprintln!("[PerceptionPipeline] Warning: reasoning engine not available: {}", e);
                    None
                }
            }
        } else {
            None
        };

        Ok(Self {
            perception: Arc::from(perception),
            reasoning,
        })
    }

    // ---- Embedding methods (delegate) ----

    /// Generate an embedding for text. Returns list[float].
    fn embed(&self, text: &str) -> PyResult<Vec<f32>> {
        self.perception
            .conceptualize(text)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))
    }

    /// Generate embeddings for multiple texts. Returns list[list[float]].
    fn embed_batch(&self, texts: Vec<String>) -> PyResult<Vec<Vec<f32>>> {
        let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
        self.perception
            .conceptualize_batch(&refs)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))
    }

    /// Embed text and return numpy ndarray.
    fn embed_numpy<'py>(&self, py: Python<'py>, text: &str) -> PyResult<Bound<'py, PyArray1<f32>>> {
        let vec = self
            .perception
            .conceptualize(text)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))?;
        Ok(vec.into_pyarray_bound(py))
    }

    /// Multimodal embedding. Returns list[float].
    fn embed_multimodal(&self, data: &[u8], mime_type: &str) -> PyResult<Vec<f32>> {
        self.perception
            .align_modality(data, mime_type)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))
    }

    // ---- T28.3: remember_with_embedding ----

    /// Generate an embedding for content and return it alongside the content.
    ///
    /// This is a convenience method for workflows that auto-embed content
    /// before storing it. It returns a dict ready for DocumentStore.put()
    /// or Federation.write().
    ///
    /// Args:
    ///     content: Text content to embed and remember.
    ///     id: Document ID.
    ///     tags: Optional list of tags.
    ///     doc_type: Document type (default: "text").
    ///
    /// Returns:
    ///     dict: {"id": str, "content": str, "embedding": list[float],
    ///            "tags": list[str], "doc_type": str}
    #[pyo3(signature = (content, id, *, tags=None, doc_type="text"))]
    fn remember_with_embedding(
        &self,
        py: Python<'_>,
        content: &str,
        id: &str,
        tags: Option<Vec<String>>,
        doc_type: &str,
    ) -> PyResult<PyObject> {
        let embedding = self
            .perception
            .conceptualize(content)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))?;

        let dict = PyDict::new_bound(py);
        dict.set_item("id", id)?;
        dict.set_item("content", content)?;
        dict.set_item("embedding", &embedding)?;
        dict.set_item("tags", tags.unwrap_or_default())?;
        dict.set_item("doc_type", doc_type)?;
        dict.set_item("dimension", embedding.len())?;
        Ok(dict.into())
    }

    /// Generate an embedding and return as numpy ndarray alongside content.
    ///
    /// Same as remember_with_embedding but the embedding is a numpy ndarray.
    #[pyo3(signature = (content, id, *, tags=None, doc_type="text"))]
    fn remember_with_embedding_numpy(
        &self,
        py: Python<'_>,
        content: &str,
        id: &str,
        tags: Option<Vec<String>>,
        doc_type: &str,
    ) -> PyResult<PyObject> {
        let embedding = self
            .perception
            .conceptualize(content)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))?;

        let dim = embedding.len();
        let arr = embedding.into_pyarray_bound(py);
        let dict = PyDict::new_bound(py);
        dict.set_item("id", id)?;
        dict.set_item("content", content)?;
        dict.set_item("embedding", &arr)?;
        dict.set_item("tags", tags.unwrap_or_default())?;
        dict.set_item("doc_type", doc_type)?;
        dict.set_item("dimension", dim)?;
        Ok(dict.into())
    }

    // ---- Reasoning methods (delegate) ----

    /// Check logical consistency. Requires reasoning engine.
    fn check_logic(&self, fact_a: &str, fact_b: &str) -> PyResult<bool> {
        let r = self
            .reasoning
            .as_ref()
            .ok_or_else(|| PyRuntimeError::new_err("No reasoning engine configured"))?;
        r.check_logic_consistency(fact_a, fact_b)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))
    }

    /// Understand intent. Requires reasoning engine.
    fn understand_intent(&self, query: &str) -> PyResult<String> {
        let r = self
            .reasoning
            .as_ref()
            .ok_or_else(|| PyRuntimeError::new_err("No reasoning engine configured"))?;
        r.understand_intent(query)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))
    }

    /// Summarize content. Requires reasoning engine.
    #[pyo3(signature = (content, *, max_tokens=200))]
    fn summarize(&self, content: &str, max_tokens: usize) -> PyResult<String> {
        let r = self
            .reasoning
            .as_ref()
            .ok_or_else(|| PyRuntimeError::new_err("No reasoning engine configured"))?;
        r.summarize(content, max_tokens)
            .map_err(|e| PyRuntimeError::new_err(format!("{}", e)))
    }

    // ---- Properties ----

    /// Embedding dimension.
    #[getter]
    fn dimension(&self) -> usize {
        self.perception.dimension()
    }

    /// Whether reasoning engine is available.
    #[getter]
    fn has_reasoning(&self) -> bool {
        self.reasoning.is_some()
    }

    /// Perception model name.
    #[getter]
    fn model_name(&self) -> String {
        self.perception.model_name().to_string()
    }

    fn __repr__(&self) -> String {
        format!(
            "PerceptionPipeline(model='{}', dim={}, reasoning={})",
            self.perception.model_name(),
            self.perception.dimension(),
            self.reasoning.is_some(),
        )
    }
}

// ============================================================================
// Module Registration
// ============================================================================

/// Register perception classes in the Python module.
pub fn register_perception(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_class::<PyEmbeddingEngine>()?;
    m.add_class::<PyReasoningEngine>()?;
    m.add_class::<PyPerceptionPipeline>()?;
    Ok(())
}
