//! OpenAI-compatible provider (Ollama, vLLM, LocalAI, etc.)
//!
//! This provider works with any service that follows the OpenAI API spec:
//! - Ollama (local models)
//! - vLLM (local inference server)
//! - LocalAI
//! - Any OpenAI-compatible endpoint
//!
//! ## Usage
//!
//! Use config-driven creation via `from_model_config()` with config.yml:
//!
//! ```rust,ignore
//! use enact_providers::{config::load_config, OpenAICompatible};
//!
//! let config = load_config("config.yml").unwrap();
//! let model_config = config.get_model("openai-compatible:text:gpt-4").unwrap();
//! let api_key = enact_providers::config::resolve_secret(&model_config.secrets.name).unwrap();
//! let provider = OpenAICompatible::from_model_config("openai-compatible:text:gpt-4", model_config, &api_key).unwrap();
//! ```

use crate::config::{AuthType, Modality, ModelConfig};
use crate::http::{
    post_binary, post_json, post_multipart, BinaryRequestSpec, HttpRequestSpec,
    MultipartRequestSpec,
};
use async_trait::async_trait;
use enact_core::providers::{
    AudioTranscriptionRequest, AudioTranscriptionResponse, ChatRequest, ChatResponse,
    EmbeddingRequest, EmbeddingResponse, ImageData, ImageGenerationRequest,
    ImageGenerationResponse, ModelCapabilities, ModelProvider, SpeechRequest, SpeechResponse,
};
use reqwest::Client;
use serde_json::Value;
use std::time::Duration;

/// OpenAI-compatible provider
///
/// Works with any service that follows the OpenAI API specification:
/// - Ollama (local models)
/// - vLLM (local inference server)
/// - LocalAI
/// - OpenAI API
/// - Any other OpenAI-compatible endpoint
pub struct OpenAICompatible {
    client: Client,
    /// Model identifier
    model_id: String,
    /// The actual model name to pass in requests
    model_name: String,
    /// API key for authentication (optional for local services)
    api_key: Option<String>,
    /// Authentication type
    auth_type: AuthType,
    /// Chat completions URL
    chat_url: String,
    /// Embeddings URL
    embeddings_url: Option<String>,
    /// Images URL
    images_url: Option<String>,
    /// Audio transcriptions URL (STT)
    transcriptions_url: Option<String>,
    /// Audio speech URL (TTS)
    speech_url: Option<String>,
    /// Output modalities supported by this model
    output_modalities: Vec<Modality>,
    /// Input modalities supported by this model
    input_modalities: Vec<Modality>,
    /// Whether this is a blackforestlabs endpoint (uses width/height instead of size)
    is_blackforestlabs: bool,
    /// Request timeout
    timeout: Duration,
    /// Whether temperature is supported
    has_temperature_supported: bool,
    /// Whether to use max_completion_tokens instead of max_tokens
    use_max_completion_tokens: bool,
    /// Whether tool calls are supported
    supports_tool_calls: bool,
    /// Whether embeddings are supported
    supports_embeddings: bool,
    /// Default max tokens (from config runtime.max_tokens_default)
    max_tokens_default: u32,
    /// Cost per 1K input tokens (from config cost.cost_per_1m_input)
    cost_per_1m_input: Option<f64>,
    /// Cost per 1K output tokens (from config cost.cost_per_1m_output)
    cost_per_1m_output: Option<f64>,
    /// Cost per 1K pixels for image (from config cost.cost_per_1m_pixels)
    cost_per_1m_pixels: Option<f64>,
}

impl OpenAICompatible {
    /// Create a new OpenAI-compatible provider from model configuration.
    ///
    /// # Arguments
    ///
    /// * `model_id` - The model identifier
    /// * `config` - The model configuration from config.yml
    /// * `api_key` - The resolved API key (can be empty string for local services)
    pub fn from_model_config(
        model_id: &str,
        config: &ModelConfig,
        api_key: &str,
    ) -> anyhow::Result<Self> {
        // Extract the actual model name from cost.provider_key or use model_id
        let model_name = config
            .cost
            .provider_key
            .clone()
            .unwrap_or_else(|| model_id.to_string());

        // Determine endpoint type from URL
        let is_images = config.runtime.full_url.contains("/images/generations");
        let is_embeddings = config.runtime.full_url.contains("/embeddings");
        let is_transcriptions = config.runtime.full_url.contains("/audio/transcriptions");
        let is_speech = config.runtime.full_url.contains("/audio/speech");

        // Determine URLs based on endpoint type
        let (chat_url, embeddings_url, images_url, transcriptions_url, speech_url) = if is_images {
            (
                config
                    .runtime
                    .full_url
                    .replace("/images/generations", "/chat/completions"),
                None,
                Some(config.runtime.full_url.clone()),
                None,
                None,
            )
        } else if is_embeddings {
            (
                config
                    .runtime
                    .full_url
                    .replace("/embeddings", "/chat/completions"),
                Some(config.runtime.full_url.clone()),
                None,
                None,
                None,
            )
        } else if is_transcriptions {
            // Audio transcription endpoint
            (
                config
                    .runtime
                    .full_url
                    .replace("/audio/transcriptions", "/chat/completions"),
                None,
                None,
                Some(config.runtime.full_url.clone()),
                None,
            )
        } else if is_speech {
            // Audio speech endpoint
            (
                config
                    .runtime
                    .full_url
                    .replace("/audio/speech", "/chat/completions"),
                None,
                None,
                None,
                Some(config.runtime.full_url.clone()),
            )
        } else {
            // Check if embeddings modality is supported
            let embeddings_url = if config
                .input_modality
                .iter()
                .any(|m| m == &Modality::Embedding)
                || config
                    .output_modality
                    .iter()
                    .any(|m| m == &Modality::Embedding)
            {
                Some(
                    config
                        .runtime
                        .full_url
                        .replace("/chat/completions", "/embeddings"),
                )
            } else {
                None
            };
            // Check if image modality is supported
            let images_url = if config.output_modality.iter().any(|m| m == &Modality::Image) {
                Some(config.runtime.full_url.clone())
            } else {
                None
            };
            // Check if audio input modality is supported (STT)
            let transcriptions_url = if config.input_modality.iter().any(|m| m == &Modality::Audio)
            {
                Some(
                    config
                        .runtime
                        .full_url
                        .replace("/chat/completions", "/audio/transcriptions"),
                )
            } else {
                None
            };
            // Check if audio output modality is supported (TTS)
            let speech_url = if config.output_modality.iter().any(|m| m == &Modality::Audio) {
                Some(
                    config
                        .runtime
                        .full_url
                        .replace("/chat/completions", "/audio/speech"),
                )
            } else {
                None
            };
            (
                config.runtime.full_url.clone(),
                embeddings_url,
                images_url,
                transcriptions_url,
                speech_url,
            )
        };

        // Handle empty API key for local services
        let api_key = if api_key.is_empty() {
            None
        } else {
            Some(api_key.to_string())
        };

        let supports_embeddings = is_embeddings || embeddings_url.is_some();
        let is_blackforestlabs = config.runtime.full_url.contains("blackforestlabs");
        Ok(Self {
            client: Client::new(),
            model_id: model_id.to_string(),
            model_name,
            api_key,
            auth_type: config.auth.auth_type.clone(),
            chat_url,
            embeddings_url,
            images_url,
            transcriptions_url,
            speech_url,
            output_modalities: config.output_modality.clone(),
            input_modalities: config.input_modality.clone(),
            is_blackforestlabs,
            timeout: Duration::from_secs(config.runtime.timeout_seconds),
            has_temperature_supported: config.runtime.has_temperature_supported,
            use_max_completion_tokens: config.runtime.use_max_completion_tokens,
            supports_tool_calls: config.capabilities.tool_calls,
            supports_embeddings,
            max_tokens_default: config.runtime.max_tokens_default,
            cost_per_1m_input: config.cost.cost_per_1m_input,
            cost_per_1m_output: config.cost.cost_per_1m_output,
            cost_per_1m_pixels: config.cost.cost_per_1m_pixels,
        })
    }

    /// Create provider from URL, model name, and optional API key (e.g. when not using config.yml).
    pub fn new(
        base_url: impl Into<String>,
        model: impl Into<String>,
        api_key: Option<String>,
    ) -> Self {
        let base_url = base_url.into().trim_end_matches('/').to_string();
        let model = model.into();
        let chat_url = if base_url.contains("/v1") {
            format!("{}/chat/completions", base_url)
        } else {
            format!("{}/v1/chat/completions", base_url)
        };
        Self {
            client: Client::new(),
            model_id: model.clone(),
            model_name: model,
            api_key,
            auth_type: AuthType::Bearer,
            chat_url,
            embeddings_url: None,
            images_url: None,
            transcriptions_url: None,
            speech_url: None,
            output_modalities: vec![Modality::Text],
            input_modalities: vec![Modality::Text],
            is_blackforestlabs: false,
            timeout: Duration::from_secs(60),
            has_temperature_supported: true,
            use_max_completion_tokens: false,
            supports_tool_calls: true,
            supports_embeddings: false,
            max_tokens_default: 4096,
            cost_per_1m_input: None,
            cost_per_1m_output: None,
            cost_per_1m_pixels: None,
        }
    }

    fn build_headers(&self) -> Vec<(String, String)> {
        let mut headers = vec![("Content-Type".to_string(), "application/json".to_string())];

        if let Some(api_key) = &self.api_key {
            match &self.auth_type {
                AuthType::Bearer => {
                    headers.push(("Authorization".to_string(), format!("Bearer {}", api_key)));
                }
                AuthType::Header => {
                    headers.push(("api-key".to_string(), api_key.clone()));
                }
            }
        }

        headers
    }

    fn transform_request(&self, mut request: ChatRequest) -> anyhow::Result<Value> {
        // Handle temperature support
        if !self.has_temperature_supported {
            request.temperature = None;
        }

        // Convert to JSON
        let mut body = serde_json::to_value(&request)?;

        // Add model name
        if let Some(obj) = body.as_object_mut() {
            obj.insert("model".to_string(), Value::String(self.model_name.clone()));

            // Handle max_completion_tokens transformation
            if self.use_max_completion_tokens {
                if let Some(max_tokens) = obj.remove("max_tokens") {
                    obj.insert("max_completion_tokens".to_string(), max_tokens);
                }
            }

            // Handle multimodal content (vision) - transform messages with images
            // OpenAI vision API expects content to be an array of parts for multimodal messages
            if let Some(messages) = obj.get_mut("messages").and_then(|m| m.as_array_mut()) {
                for msg in messages.iter_mut() {
                    if let Some(msg_obj) = msg.as_object_mut() {
                        // If message has multimodal_content, use it as the content field
                        if let Some(multimodal) = msg_obj.remove("multimodal_content") {
                            // Replace content with the multimodal array
                            msg_obj.insert("content".to_string(), multimodal);
                        }
                    }
                }
            }
        }

        Ok(body)
    }
}

#[async_trait]
impl ModelProvider for OpenAICompatible {
    fn name(&self) -> &str {
        "openai-compatible"
    }

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

    fn capabilities(&self) -> ModelCapabilities {
        let supports_image_generation = self.output_modalities.contains(&Modality::Image);
        let supports_audio_transcription =
            self.transcriptions_url.is_some() || self.input_modalities.contains(&Modality::Audio);
        let supports_speech =
            self.speech_url.is_some() || self.output_modalities.contains(&Modality::Audio);
        // Vision support: model accepts image input modality
        let supports_vision = self.input_modalities.contains(&Modality::Image);
        ModelCapabilities {
            max_tokens: self.max_tokens_default,
            max_output_tokens: self.max_tokens_default,
            supports_streaming: true,
            supports_tools: self.supports_tool_calls,
            supports_reasoning: false,
            supports_vision,
            supports_json_mode: true,
            supports_embeddings: self.supports_embeddings,
            supports_image_generation,
            supports_audio_transcription,
            supports_speech,
            supports_video_generation: false,
            pii_safe: false,
            cost_per_1m_input: self.cost_per_1m_input,
            cost_per_1m_output: self.cost_per_1m_output,
            cost_per_1m_pixels: self.cost_per_1m_pixels,
        }
    }

    async fn chat(&self, request: ChatRequest) -> anyhow::Result<ChatResponse> {
        // Transform request
        let body = self.transform_request(request)?;

        // Build HTTP request spec
        let http_req = HttpRequestSpec {
            url: self.chat_url.clone(),
            headers: self.build_headers(),
            body,
            timeout: self.timeout,
        };

        // Execute request using HTTP helper
        let json_response = post_json(&self.client, http_req).await?;

        // Parse response
        let chat_response: ChatResponse = serde_json::from_value(json_response)?;
        Ok(chat_response)
    }

    async fn embed(&self, request: EmbeddingRequest) -> anyhow::Result<EmbeddingResponse> {
        let embeddings_url = self
            .embeddings_url
            .as_ref()
            .ok_or_else(|| anyhow::anyhow!("Embeddings not supported for this model"))?;

        // Build request body with model
        let mut body = serde_json::to_value(&request)?;
        body["model"] = Value::String(
            request
                .model
                .clone()
                .unwrap_or_else(|| self.model_name.clone()),
        );

        // Build HTTP request spec
        let http_req = HttpRequestSpec {
            url: embeddings_url.clone(),
            headers: self.build_headers(),
            body,
            timeout: self.timeout,
        };

        // Execute request using HTTP helper
        let json_response = post_json(&self.client, http_req).await?;

        // Parse response
        let embedding_response: EmbeddingResponse = serde_json::from_value(json_response)?;
        Ok(embedding_response)
    }

    async fn generate_image(
        &self,
        request: ImageGenerationRequest,
    ) -> anyhow::Result<ImageGenerationResponse> {
        // Check if image generation is supported
        if !self.output_modalities.contains(&Modality::Image) {
            anyhow::bail!("Image generation not supported by model {}", self.model_id);
        }

        let images_url = self
            .images_url
            .as_ref()
            .ok_or_else(|| anyhow::anyhow!("Images URL not configured for this model"))?;

        // Build request body - different format for blackforestlabs vs OpenAI
        let body = if self.is_blackforestlabs {
            // Blackforestlabs format: uses width/height instead of size, always includes model
            let size = request.size.as_deref().unwrap_or("1024x1024");
            let parts: Vec<&str> = size.split('x').collect();
            let width: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(1024);
            let height: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(1024);

            // Extract model name from model_name (could be "provider/model" format)
            let model_name = self
                .model_name
                .rsplit('/')
                .next()
                .unwrap_or(&self.model_name);

            serde_json::json!({
                "prompt": request.prompt,
                "n": request.n.unwrap_or(1),
                "width": width,
                "height": height,
                "model": model_name,
            })
        } else {
            // OpenAI-compatible format
            let mut body = serde_json::json!({
                "prompt": request.prompt,
            });

            // Add optional parameters if provided
            if let Some(n) = request.n {
                body["n"] = serde_json::json!(n);
            }
            if let Some(ref size) = request.size {
                body["size"] = serde_json::json!(size);
            }
            if let Some(ref quality) = request.quality {
                body["quality"] = serde_json::json!(quality);
            }
            if let Some(ref style) = request.style {
                body["style"] = serde_json::json!(style);
            }
            if let Some(ref response_format) = request.response_format {
                body["response_format"] = serde_json::json!(response_format);
            }
            if let Some(ref user) = request.user {
                body["user"] = serde_json::json!(user);
            }

            // Add model name
            body["model"] = serde_json::json!(self.model_name);
            body
        };

        // Build HTTP request spec
        let http_req = HttpRequestSpec {
            url: images_url.clone(),
            headers: self.build_headers(),
            body,
            timeout: self.timeout,
        };

        // Execute request
        let json_response = post_json(&self.client, http_req).await?;

        // Parse response - OpenAI-compatible format
        let created = json_response
            .get("created")
            .and_then(|c| c.as_u64())
            .unwrap_or_else(|| {
                std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .map(|d| d.as_secs())
                    .unwrap_or(0)
            });

        let data_array = json_response
            .get("data")
            .and_then(|d| d.as_array())
            .ok_or_else(|| anyhow::anyhow!("Missing 'data' array in image response"))?;

        let data: Vec<ImageData> = data_array
            .iter()
            .map(|item| ImageData {
                url: item.get("url").and_then(|u| u.as_str()).map(String::from),
                b64_json: item
                    .get("b64_json")
                    .and_then(|b| b.as_str())
                    .map(String::from),
                revised_prompt: item
                    .get("revised_prompt")
                    .and_then(|r| r.as_str())
                    .map(String::from),
            })
            .collect();

        Ok(ImageGenerationResponse { created, data })
    }

    async fn transcribe(
        &self,
        request: AudioTranscriptionRequest,
    ) -> anyhow::Result<AudioTranscriptionResponse> {
        let transcriptions_url = self
            .transcriptions_url
            .as_ref()
            .ok_or_else(|| anyhow::anyhow!("Audio transcription not supported for this model"))?;

        // Detect MIME type from filename
        let mime_type = match request.filename.rsplit('.').next() {
            Some("wav") => "audio/wav",
            Some("mp3") => "audio/mpeg",
            Some("m4a") => "audio/mp4",
            Some("flac") => "audio/flac",
            Some("ogg") => "audio/ogg",
            Some("webm") => "audio/webm",
            _ => "audio/wav", // Default
        };

        // Build form fields
        let mut form_fields = vec![("model".to_string(), self.model_name.clone())];

        if let Some(language) = request.language {
            form_fields.push(("language".to_string(), language));
        }
        if let Some(prompt) = request.prompt {
            form_fields.push(("prompt".to_string(), prompt));
        }
        if let Some(response_format) = request.response_format {
            form_fields.push(("response_format".to_string(), response_format));
        }
        if let Some(temperature) = request.temperature {
            form_fields.push(("temperature".to_string(), temperature.to_string()));
        }

        let http_req = MultipartRequestSpec {
            url: transcriptions_url.clone(),
            headers: self.build_headers(),
            file_data: request.file,
            file_name: request.filename,
            file_mime_type: mime_type.to_string(),
            form_fields,
            timeout: self.timeout,
        };

        let json_response = post_multipart(&self.client, http_req).await?;

        // Parse response
        let response: AudioTranscriptionResponse = serde_json::from_value(json_response)?;
        Ok(response)
    }

    async fn speak(&self, request: SpeechRequest) -> anyhow::Result<SpeechResponse> {
        let speech_url = self
            .speech_url
            .as_ref()
            .ok_or_else(|| anyhow::anyhow!("Text-to-speech not supported for this model"))?;

        // Build request body
        let mut body = serde_json::json!({
            "model": self.model_name,
            "input": request.input,
            "voice": request.voice,
        });

        if let Some(response_format) = &request.response_format {
            body["response_format"] = serde_json::json!(response_format);
        }
        if let Some(speed) = request.speed {
            body["speed"] = serde_json::json!(speed);
        }

        let http_req = BinaryRequestSpec {
            url: speech_url.clone(),
            headers: self.build_headers(),
            body,
            timeout: self.timeout,
        };

        let response = post_binary(&self.client, http_req).await?;

        Ok(SpeechResponse {
            audio: response.data,
            content_type: response.content_type,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{
        AuthConfig, CapabilitiesConfig, CostConfig, GovernanceConfig, LifecycleStatus, Modality,
        Protocol, RuntimeConfig, SecretType, SecretsConfig,
    };

    fn create_test_config(auth_type: AuthType) -> ModelConfig {
        ModelConfig {
            display_name: "OpenAI Compatible Test".to_string(),
            provider_name: "openai-compatible".to_string(),
            input_modality: vec![Modality::Text],
            output_modality: vec![Modality::Text],
            runtime: RuntimeConfig {
                base_url: "http://localhost:11434".to_string(),
                full_url: "http://localhost:11434/v1/chat/completions".to_string(),
                protocol: Protocol::OpenAICompatible,
                api_version: "v1".to_string(),
                use_max_completion_tokens: false,
                has_temperature_supported: true,
                additional_notes: vec![],
                model_param: false,
                timeout_seconds: 60,
                max_tokens_default: 4096,
                temperature_default: 0.7,
            },
            auth: AuthConfig { auth_type },
            secrets: SecretsConfig {
                secret_type: SecretType::Environment,
                name: "OPENAI_API_KEY".to_string(),
            },
            capabilities: CapabilitiesConfig {
                tool_calls: true,
                reasoning: false,
            },
            governance: GovernanceConfig {
                is_use_approve: true,
                approved_by: Some("admin".to_string()),
                approval_date: Some("2024-01-01".to_string()),
                lifecycle_status: LifecycleStatus::Active,
            },
            cost: CostConfig {
                provider: Some("local".to_string()),
                provider_key: Some("llama3".to_string()),
                ..Default::default()
            },
            fallbacks: vec![],
        }
    }

    #[test]
    fn test_from_model_config() {
        let config = create_test_config(AuthType::Bearer);
        let provider =
            OpenAICompatible::from_model_config("local:text:llama3", &config, "test-key").unwrap();

        assert_eq!(provider.name(), "openai-compatible");
        assert_eq!(provider.model(), "local:text:llama3");
        assert_eq!(provider.model_name, "llama3");
    }

    #[test]
    fn test_bearer_auth_headers() {
        let config = create_test_config(AuthType::Bearer);
        let provider = OpenAICompatible::from_model_config("test", &config, "my-api-key").unwrap();

        let headers = provider.build_headers();
        assert!(headers
            .iter()
            .any(|(k, v)| k == "Authorization" && v == "Bearer my-api-key"));
    }

    #[test]
    fn test_header_auth_headers() {
        let config = create_test_config(AuthType::Header);
        let provider = OpenAICompatible::from_model_config("test", &config, "my-api-key").unwrap();

        let headers = provider.build_headers();
        assert!(headers
            .iter()
            .any(|(k, v)| k == "api-key" && v == "my-api-key"));
    }

    #[test]
    fn test_no_auth_for_empty_key() {
        let config = create_test_config(AuthType::Bearer);
        let provider = OpenAICompatible::from_model_config("test", &config, "").unwrap();

        let headers = provider.build_headers();
        assert!(!headers.iter().any(|(k, _)| k == "Authorization"));
    }

    #[test]
    fn test_request_includes_model() {
        let config = create_test_config(AuthType::Bearer);
        let provider = OpenAICompatible::from_model_config("test", &config, "key").unwrap();

        let request = ChatRequest {
            messages: vec![],
            max_tokens: Some(100),
            temperature: Some(0.5),
            tools: None,
            tool_choice: None,
        };

        let body = provider.transform_request(request).unwrap();
        let obj = body.as_object().unwrap();

        assert_eq!(obj.get("model").unwrap(), "llama3");
    }

    #[test]
    fn test_request_with_tools_includes_tools_and_tool_choice() {
        use enact_core::providers::{ChatTool, ChatToolFunction, ToolChoice};

        let config = create_test_config(AuthType::Bearer);
        let provider = OpenAICompatible::from_model_config("test", &config, "key").unwrap();

        let request = ChatRequest {
            messages: vec![],
            max_tokens: Some(100),
            temperature: Some(0.5),
            tools: Some(vec![ChatTool {
                tool_type: "function".to_string(),
                function: ChatToolFunction {
                    name: "get_weather".to_string(),
                    description: "Get weather".to_string(),
                    parameters: serde_json::json!({"type": "object"}),
                },
            }]),
            tool_choice: Some(ToolChoice::String("auto".to_string())),
        };

        let body = provider.transform_request(request).unwrap();
        let obj = body.as_object().unwrap();

        assert!(obj.contains_key("tools"), "body should contain 'tools' key");
        assert!(
            obj.contains_key("tool_choice"),
            "body should contain 'tool_choice' key"
        );
        let tools = obj.get("tools").and_then(|v| v.as_array()).unwrap();
        assert_eq!(tools.len(), 1);
        assert_eq!(
            tools[0].get("type").and_then(|v| v.as_str()),
            Some("function")
        );
        assert_eq!(
            tools[0]
                .get("function")
                .and_then(|f| f.get("name"))
                .and_then(|v| v.as_str()),
            Some("get_weather")
        );
    }
}
