use crate::config::paths::Paths;
use crate::config::Config;
use crate::providers::anthropic::AnthropicProvider;
use crate::providers::base::{ModelInfo, ProviderType};
use crate::providers::inventory::declarative_inventory_identity;
use crate::providers::ollama::OllamaProvider;
use crate::providers::openai::OpenAiProvider;
use anyhow::Result;
use include_dir::{include_dir, Dir};
use once_cell::sync::Lazy;
use serde::{Deserialize, Deserializer, Serialize};
use std::str::FromStr;

/// Deserialize an optional string, treating empty/whitespace-only values as None.
fn deserialize_non_empty_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
    D: Deserializer<'de>,
{
    let opt: Option<String> = Option::deserialize(deserializer)?;
    Ok(opt.filter(|s| !s.trim().is_empty()))
}
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use utoipa::ToSchema;

static FIXED_PROVIDERS: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/providers/declarative");

pub fn custom_providers_dir() -> std::path::PathBuf {
    Paths::config_dir().join("custom_providers")
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ProviderEngine {
    OpenAI,
    Ollama,
    Anthropic,
}

impl FromStr for ProviderEngine {
    type Err = anyhow::Error;

    fn from_str(engine: &str) -> Result<Self> {
        match engine.trim().to_lowercase().as_str() {
            "openai" | "openai_compatible" => Ok(Self::OpenAI),
            "anthropic" | "anthropic_compatible" => Ok(Self::Anthropic),
            "ollama" | "ollama_compatible" => Ok(Self::Ollama),
            _ => Err(anyhow::anyhow!("Invalid provider type: {}", engine)),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct EnvVarConfig {
    pub name: String,
    #[serde(default)]
    pub required: bool,
    #[serde(default)]
    pub secret: bool,
    /// When true, the field is shown prominently in the UI (not collapsed).
    /// Defaults to the value of `required` if not specified.
    pub primary: Option<bool>,
    pub description: Option<String>,
    pub default: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct DeclarativeProviderConfig {
    pub name: String,
    pub engine: ProviderEngine,
    pub display_name: String,
    pub description: Option<String>,
    #[serde(default)]
    pub api_key_env: String,
    pub base_url: String,
    pub models: Vec<ModelInfo>,
    pub headers: Option<HashMap<String, String>>,
    pub timeout_seconds: Option<u64>,
    pub supports_streaming: Option<bool>,
    #[serde(default = "default_requires_auth")]
    pub requires_auth: bool,
    #[serde(default)]
    pub catalog_provider_id: Option<String>,
    #[serde(default)]
    pub base_path: Option<String>,
    #[serde(default)]
    pub env_vars: Option<Vec<EnvVarConfig>>,
    /// Controls whether `fetch_supported_models` calls the provider's `/v1/models`
    /// endpoint or returns the static `models` list directly.
    ///
    /// - `Some(false)` + non-empty `models`: return the static list; no API call.
    ///   Construction fails if `models` is empty.
    /// - `Some(true)` or `None`: try the API; fall back to `models` on 404.
    #[serde(default)]
    pub dynamic_models: Option<bool>,
    #[serde(default)]
    pub skip_canonical_filtering: bool,
    #[serde(default, deserialize_with = "deserialize_non_empty_string")]
    pub model_doc_link: Option<String>,
    #[serde(default)]
    pub setup_steps: Vec<String>,
    #[serde(default, deserialize_with = "deserialize_non_empty_string")]
    pub fast_model: Option<String>,
    #[serde(default)]
    pub preserves_thinking: bool,
}

fn default_requires_auth() -> bool {
    true
}

fn should_preserve_thinking_by_default(engine: &ProviderEngine) -> bool {
    matches!(engine, ProviderEngine::OpenAI)
}

impl DeclarativeProviderConfig {
    pub fn id(&self) -> &str {
        &self.name
    }

    pub fn display_name(&self) -> &str {
        &self.display_name
    }

    pub fn models(&self) -> &[ModelInfo] {
        &self.models
    }
}

/// Expand `${VAR_NAME}` placeholders in a template string using the given env var configs.
/// Resolves values via Config (secret if `secret`, param otherwise), falls back to `default`.
/// Returns an error if a `required` var is missing.
pub fn expand_env_vars(template: &str, env_vars: &[EnvVarConfig]) -> Result<String> {
    let config = Config::global();
    let mut result = template.to_string();
    for var in env_vars {
        let placeholder = format!("${{{}}}", var.name);
        if !result.contains(&placeholder) {
            continue;
        }
        let value = if var.secret {
            config.get_secret::<String>(&var.name).ok()
        } else {
            config.get_param::<String>(&var.name).ok()
        };
        let value = match value {
            Some(v) => v,
            None => match &var.default {
                Some(d) => d.clone(),
                None if var.required => {
                    return Err(anyhow::anyhow!(
                        "Required environment variable {} is not set",
                        var.name
                    ));
                }
                None => continue,
            },
        };
        result = result.replace(&placeholder, &value);
    }
    Ok(result)
}

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct LoadedProvider {
    pub config: DeclarativeProviderConfig,
    pub is_editable: bool,
}

static ID_GENERATION_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));

pub fn generate_id(display_name: &str) -> String {
    let _guard = ID_GENERATION_LOCK.lock().unwrap();

    let normalized = display_name
        .to_lowercase()
        .chars()
        .map(|ch| {
            if ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-' {
                ch
            } else {
                '_'
            }
        })
        .collect::<String>()
        .trim_matches('_')
        .to_string();
    let base_id = format!("custom_{}", normalized);

    let custom_dir = custom_providers_dir();
    let mut candidate_id = base_id.clone();
    let mut counter = 1;

    while custom_dir.join(format!("{}.json", candidate_id)).exists() {
        candidate_id = format!("{}_{}", base_id, counter);
        counter += 1;
    }

    candidate_id
}

pub fn validate_provider_id(id: &str) -> Result<()> {
    let mut chars = id.chars();
    let Some(first) = chars.next() else {
        return Err(anyhow::anyhow!(
            "Invalid provider id: provider id cannot be empty"
        ));
    };

    if !(first.is_ascii_lowercase() || first.is_ascii_digit() || first == '_') {
        return Err(anyhow::anyhow!("Invalid provider id: {}", id));
    }

    if chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-') {
        Ok(())
    } else {
        Err(anyhow::anyhow!("Invalid provider id: {}", id))
    }
}

fn custom_provider_file_path(id: &str) -> Result<PathBuf> {
    if id.is_empty()
        || id
            .chars()
            .any(|ch| ch == '/' || ch == '\\' || ch.is_control())
    {
        return Err(anyhow::anyhow!(
            "Invalid provider id: {}",
            if id.is_empty() { "<empty>" } else { id }
        ));
    }

    Ok(custom_providers_dir().join(format!("{}.json", id)))
}

pub fn generate_api_key_name(id: &str) -> String {
    format!("{}_API_KEY", id.to_uppercase())
}

#[derive(Debug, Clone)]
pub struct CreateCustomProviderParams {
    pub engine: String,
    pub display_name: String,
    pub api_url: String,
    pub api_key: Option<String>,
    pub models: Vec<String>,
    pub supports_streaming: Option<bool>,
    pub headers: Option<HashMap<String, String>>,
    pub requires_auth: bool,
    pub catalog_provider_id: Option<String>,
    pub base_path: Option<String>,
    pub preserves_thinking: Option<bool>,
}

#[derive(Debug, Clone)]
pub struct UpdateCustomProviderParams {
    pub id: String,
    pub engine: String,
    pub display_name: String,
    pub api_url: String,
    pub api_key: Option<String>,
    pub models: Vec<String>,
    pub supports_streaming: Option<bool>,
    pub headers: Option<HashMap<String, String>>,
    pub requires_auth: bool,
    pub catalog_provider_id: Option<String>,
    pub base_path: Option<String>,
    pub preserves_thinking: Option<bool>,
}

pub fn create_custom_provider(
    params: CreateCustomProviderParams,
) -> Result<DeclarativeProviderConfig> {
    let id = generate_id(&params.display_name);
    validate_provider_id(&id)?;

    let api_key_env = if params.requires_auth {
        let api_key = params
            .api_key
            .as_deref()
            .filter(|api_key| !api_key.trim().is_empty())
            .ok_or_else(|| anyhow::anyhow!("apiKey cannot be empty"))?;
        let api_key_name = generate_api_key_name(&id);
        let config = Config::global();
        config.set_secret(&api_key_name, &api_key)?;
        api_key_name
    } else {
        String::new()
    };

    let model_infos: Vec<ModelInfo> = params
        .models
        .into_iter()
        .map(|name| ModelInfo::new(name, 128000))
        .collect();

    let engine = ProviderEngine::from_str(&params.engine)?;
    let preserves_thinking = params
        .preserves_thinking
        .unwrap_or_else(|| should_preserve_thinking_by_default(&engine));

    let provider_config = DeclarativeProviderConfig {
        name: id.clone(),
        engine,
        display_name: params.display_name.clone(),
        description: Some(format!("Custom {} provider", params.display_name)),
        api_key_env,
        base_url: params.api_url,
        models: model_infos,
        headers: params.headers,
        timeout_seconds: None,
        supports_streaming: params.supports_streaming,
        requires_auth: params.requires_auth,
        catalog_provider_id: params.catalog_provider_id,
        base_path: params.base_path,
        env_vars: None,
        dynamic_models: None,
        skip_canonical_filtering: false,
        model_doc_link: None,
        setup_steps: vec![],
        fast_model: None,
        preserves_thinking,
    };

    let custom_providers_dir = custom_providers_dir();
    std::fs::create_dir_all(&custom_providers_dir)?;

    let json_content = serde_json::to_string_pretty(&provider_config)?;
    let file_path = custom_providers_dir.join(format!("{}.json", id));
    std::fs::write(file_path, json_content)?;

    Ok(provider_config)
}

pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()> {
    let loaded_provider = load_provider(&params.id)?;
    let existing_config = loaded_provider.config;
    let editable = loaded_provider.is_editable;

    let config = Config::global();
    let api_key_env = if params.requires_auth {
        let api_key_name = if existing_config.api_key_env.is_empty() {
            generate_api_key_name(&params.id)
        } else {
            existing_config.api_key_env.clone()
        };
        if let Some(api_key) = params.api_key.as_deref() {
            config.set_secret(&api_key_name, &api_key)?;
        } else if config.get_secret::<String>(&api_key_name).is_err() {
            return Err(anyhow::anyhow!(
                "apiKey is required when auth is enabled and no secret is stored"
            ));
        }
        api_key_name
    } else {
        if existing_config.api_key_env == generate_api_key_name(&params.id) {
            config.delete_secret(&existing_config.api_key_env)?;
        }
        String::new()
    };

    if editable {
        let model_infos: Vec<ModelInfo> = params
            .models
            .into_iter()
            .map(|name| ModelInfo::new(name, 128000))
            .collect();

        let engine = ProviderEngine::from_str(&params.engine)?;
        let preserves_thinking = match params.preserves_thinking {
            Some(value) => value,
            None if existing_config.engine != engine => {
                should_preserve_thinking_by_default(&engine)
            }
            None => existing_config.preserves_thinking,
        };

        let updated_config = DeclarativeProviderConfig {
            name: params.id.clone(),
            engine,
            display_name: params.display_name,
            description: existing_config.description,
            api_key_env,
            base_url: params.api_url,
            models: model_infos,
            headers: match params.headers {
                Some(h) if h.is_empty() => None,
                Some(h) => Some(h),
                None => existing_config.headers,
            },
            timeout_seconds: existing_config.timeout_seconds,
            supports_streaming: params.supports_streaming,
            requires_auth: params.requires_auth,
            catalog_provider_id: params.catalog_provider_id,
            base_path: params.base_path,
            env_vars: existing_config.env_vars,
            dynamic_models: existing_config.dynamic_models,
            skip_canonical_filtering: existing_config.skip_canonical_filtering,
            model_doc_link: existing_config.model_doc_link,
            setup_steps: existing_config.setup_steps,
            fast_model: existing_config.fast_model.clone(),
            preserves_thinking,
        };

        let file_path = custom_provider_file_path(&updated_config.name)?;
        let json_content = serde_json::to_string_pretty(&updated_config)?;
        std::fs::write(file_path, json_content)?;
    }
    Ok(())
}

pub fn remove_custom_provider(id: &str) -> Result<()> {
    let config = Config::global();
    let loaded_provider = load_provider(id)?;
    let api_key_env = loaded_provider.config.api_key_env;
    if api_key_env == generate_api_key_name(id) {
        let _ = config.delete_secret(&api_key_env);
    }

    let file_path = custom_provider_file_path(id)?;

    if file_path.exists() {
        std::fs::remove_file(file_path)?;
    }

    Ok(())
}

pub fn load_provider(id: &str) -> Result<LoadedProvider> {
    let custom_file_path = custom_provider_file_path(id)?;

    if custom_file_path.exists() {
        let content = std::fs::read_to_string(&custom_file_path)?;
        let config = deserialize_provider_config(&content)?;
        return Ok(LoadedProvider {
            config,
            is_editable: true,
        });
    }

    for file in FIXED_PROVIDERS.files() {
        if file.path().extension().and_then(|s| s.to_str()) != Some("json") {
            continue;
        }

        let content = file
            .contents_utf8()
            .ok_or_else(|| anyhow::anyhow!("Failed to read file as UTF-8: {:?}", file.path()))?;

        let config: DeclarativeProviderConfig = match serde_json::from_str(content) {
            Ok(config) => config,
            Err(_) => continue,
        };
        if config.name == id {
            return Ok(LoadedProvider {
                config,
                is_editable: false,
            });
        }
    }

    Err(anyhow::anyhow!("Provider not found: {}", id))
}
pub fn load_custom_providers(dir: &Path) -> Result<Vec<DeclarativeProviderConfig>> {
    if !dir.exists() {
        return Ok(Vec::new());
    }

    std::fs::read_dir(dir)?
        .filter_map(|entry| {
            let path = entry.ok()?.path();
            (path.extension()? == "json").then_some(path)
        })
        .map(|path| {
            let content = std::fs::read_to_string(&path)?;
            deserialize_provider_config(&content)
                .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", path.display(), e))
        })
        .collect()
}

fn deserialize_provider_config(content: &str) -> Result<DeclarativeProviderConfig> {
    let raw: serde_json::Value = serde_json::from_str(content)?;
    let preserves_thinking_was_set = raw.get("preserves_thinking").is_some();
    let mut config: DeclarativeProviderConfig = serde_json::from_value(raw)?;

    if !preserves_thinking_was_set {
        config.preserves_thinking = should_preserve_thinking_by_default(&config.engine);
    }

    Ok(config)
}

fn load_fixed_providers() -> Result<Vec<DeclarativeProviderConfig>> {
    let mut res = Vec::new();
    for file in FIXED_PROVIDERS.files() {
        if file.path().extension().and_then(|s| s.to_str()) != Some("json") {
            continue;
        }

        let content = file
            .contents_utf8()
            .ok_or_else(|| anyhow::anyhow!("Failed to read file as UTF-8: {:?}", file.path()))?;

        match deserialize_provider_config(content) {
            Ok(config) => res.push(config),
            Err(e) => {
                tracing::warn!(
                    "Skipping invalid declarative provider {:?}: {}",
                    file.path(),
                    e
                );
            }
        }
    }

    Ok(res)
}

pub fn register_declarative_providers(
    registry: &mut crate::providers::provider_registry::ProviderRegistry,
) -> Result<()> {
    let dir = custom_providers_dir();
    let custom_providers = load_custom_providers(&dir)?;
    let fixed_providers = load_fixed_providers()?;
    for config in fixed_providers {
        register_declarative_provider(registry, config, ProviderType::Declarative);
    }

    for config in custom_providers {
        register_declarative_provider(registry, config, ProviderType::Custom);
    }

    Ok(())
}

/// Resolve `${VAR}` placeholders in the config's `base_url` and apply
/// runtime overrides from env_vars. Called lazily (at provider instantiation)
/// so values configured through the UI after startup are picked up.
fn resolve_config(config: &mut DeclarativeProviderConfig) -> Result<()> {
    if let Some(ref env_vars) = config.env_vars {
        config.base_url = expand_env_vars(&config.base_url, env_vars)?;

        // Check for streaming override via env_vars.
        // Config/env may store the value as a string ("true") or a native bool,
        // so try String first, then fall back to bool.
        let global_config = Config::global();
        for var in env_vars {
            if var.name.ends_with("_STREAMING") {
                let val: Option<bool> = global_config
                    .get_param::<String>(&var.name)
                    .ok()
                    .map(|s| s.to_lowercase() == "true")
                    .or_else(|| global_config.get_param::<bool>(&var.name).ok())
                    .or_else(|| var.default.as_deref().map(|d| d.to_lowercase() == "true"));
                if let Some(v) = val {
                    config.supports_streaming = Some(v);
                }
            }
        }
    }
    Ok(())
}

pub fn register_declarative_provider(
    registry: &mut crate::providers::provider_registry::ProviderRegistry,
    config: DeclarativeProviderConfig,
    provider_type: ProviderType,
) {
    // Each closure needs its own owned copy of config because closures are
    // moved into the registry and may be invoked much later than registration.
    // Env var expansion happens lazily inside resolve_base_url so that values
    // configured through the UI after startup are picked up.
    match config.engine {
        ProviderEngine::OpenAI => {
            let captured = config.clone();
            let identity_config = config.clone();
            registry.register_with_name::<OpenAiProvider, _, _>(
                &config,
                provider_type,
                config.dynamic_models.unwrap_or(false),
                move |model| {
                    let mut cfg = captured.clone();
                    resolve_config(&mut cfg)?;
                    OpenAiProvider::from_custom_config(model, cfg)
                },
                move || {
                    let mut cfg = identity_config.clone();
                    resolve_config(&mut cfg)?;
                    declarative_inventory_identity(&cfg)
                },
            );
        }
        ProviderEngine::Ollama => {
            let captured = config.clone();
            let identity_config = config.clone();
            registry.register_with_name::<OllamaProvider, _, _>(
                &config,
                provider_type,
                config.dynamic_models.unwrap_or(false),
                move |model| {
                    let mut cfg = captured.clone();
                    resolve_config(&mut cfg)?;
                    OllamaProvider::from_custom_config(model, cfg)
                },
                move || {
                    let mut cfg = identity_config.clone();
                    resolve_config(&mut cfg)?;
                    declarative_inventory_identity(&cfg)
                },
            );
        }
        ProviderEngine::Anthropic => {
            let captured = config.clone();
            let identity_config = config.clone();
            registry.register_with_name::<AnthropicProvider, _, _>(
                &config,
                provider_type,
                config.dynamic_models.unwrap_or(false),
                move |model| {
                    let mut cfg = captured.clone();
                    resolve_config(&mut cfg)?;
                    AnthropicProvider::from_custom_config(model, cfg)
                },
                move || {
                    let mut cfg = identity_config.clone();
                    resolve_config(&mut cfg)?;
                    declarative_inventory_identity(&cfg)
                },
            );
        }
    }
}

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

    #[test]
    fn test_tanzu_json_deserializes() {
        let json = include_str!("../providers/declarative/tanzu.json");
        let config: DeclarativeProviderConfig =
            serde_json::from_str(json).expect("tanzu.json should parse");
        assert_eq!(config.name, "tanzu_ai");
        assert_eq!(config.display_name, "VMware Tanzu Platform");
        assert!(matches!(config.engine, ProviderEngine::OpenAI));
        assert_eq!(config.api_key_env, "TANZU_AI_API_KEY");
        assert_eq!(
            config.base_url,
            "${TANZU_AI_ENDPOINT}/openai/v1/chat/completions"
        );
        assert_eq!(config.dynamic_models, Some(true));
        assert_eq!(config.supports_streaming, Some(true));

        let env_vars = config.env_vars.as_ref().expect("env_vars should be set");
        assert_eq!(env_vars.len(), 2);
        assert_eq!(env_vars[0].name, "TANZU_AI_ENDPOINT");
        assert!(env_vars[0].required);
        assert!(!env_vars[0].secret);
        assert_eq!(env_vars[1].name, "TANZU_AI_STREAMING");
        assert!(!env_vars[1].required);
        assert_eq!(env_vars[1].default, Some("true".to_string()));

        assert_eq!(config.models.len(), 1);
        assert_eq!(config.models[0].name, "openai/gpt-oss-120b");
    }

    #[test]
    fn test_llama_swap_json_deserializes() {
        let json = include_str!("../providers/declarative/llama_swap.json");
        let config: DeclarativeProviderConfig =
            serde_json::from_str(json).expect("llama_swap.json should parse");
        assert_eq!(config.name, "llama_swap");
        assert_eq!(config.display_name, "Llama Swap");
        assert!(matches!(config.engine, ProviderEngine::OpenAI));
        assert_eq!(config.api_key_env, "LLAMA_SWAP_API_KEY");
        assert!(!config.requires_auth);
        assert!(config.skip_canonical_filtering);
        assert_eq!(config.dynamic_models, Some(true));
        assert_eq!(config.supports_streaming, Some(true));
        assert_eq!(config.base_url, "${LLAMA_SWAP_HOST}/v1/chat/completions");
        assert!(config.models.is_empty());

        let env_vars = config.env_vars.as_ref().expect("env_vars should be set");
        assert_eq!(env_vars.len(), 1);
        assert_eq!(env_vars[0].name, "LLAMA_SWAP_HOST");
        assert!(!env_vars[0].required);
        assert!(!env_vars[0].secret);
        assert_eq!(env_vars[0].primary, Some(true));
        assert_eq!(
            env_vars[0].default,
            Some("http://localhost:8080".to_string())
        );
    }

    #[test]
    fn test_existing_json_files_still_deserialize_without_new_fields() {
        let json = include_str!("../providers/declarative/groq.json");
        let config =
            deserialize_provider_config(json).expect("groq.json should parse without env_vars");
        assert!(config.env_vars.is_none());
        assert!(config.dynamic_models.is_none());
        assert!(config.model_doc_link.is_none());
        assert!(config.setup_steps.is_empty());
        assert!(config.preserves_thinking);
    }

    #[test]
    fn test_custom_openai_provider_missing_preserves_thinking_defaults_true() {
        let json = r#"{
            "name": "custom_reasoning",
            "engine": "openai",
            "display_name": "Custom Reasoning",
            "description": null,
            "api_key_env": "",
            "base_url": "https://example.com/v1",
            "models": [{"name": "reasoning-model", "context_limit": 128000}],
            "headers": null,
            "timeout_seconds": null,
            "supports_streaming": true,
            "requires_auth": false
        }"#;

        let config = deserialize_provider_config(json).expect("custom provider json should parse");

        assert!(matches!(config.engine, ProviderEngine::OpenAI));
        assert!(config.preserves_thinking);
    }

    #[test]
    fn test_custom_provider_explicit_preserves_thinking_false_is_kept() {
        let json = r#"{
            "name": "custom_strict",
            "engine": "openai",
            "display_name": "Custom Strict",
            "description": null,
            "api_key_env": "",
            "base_url": "https://example.com/v1",
            "models": [{"name": "strict-model", "context_limit": 128000}],
            "headers": null,
            "timeout_seconds": null,
            "supports_streaming": true,
            "requires_auth": false,
            "preserves_thinking": false
        }"#;

        let config = deserialize_provider_config(json).expect("custom provider json should parse");

        assert!(matches!(config.engine, ProviderEngine::OpenAI));
        assert!(!config.preserves_thinking);
    }

    #[test]
    fn test_zai_json_deserializes() {
        let json = include_str!("../providers/declarative/zai.json");
        let config: DeclarativeProviderConfig =
            serde_json::from_str(json).expect("zai.json should parse");
        assert_eq!(config.name, "zai");
        assert_eq!(config.display_name, "Z.AI");
        assert!(matches!(config.engine, ProviderEngine::Anthropic));
        assert_eq!(config.api_key_env, "ZHIPU_API_KEY");
        assert_eq!(config.base_url, "${ZAI_BASE_URL}");
        assert_eq!(config.catalog_provider_id, Some("zai".to_string()));
        assert_eq!(config.fast_model, Some("glm-4.5-air".to_string()));
        assert!(config.preserves_thinking);
        assert_eq!(config.supports_streaming, Some(true));
        assert_eq!(config.models[0].name, "glm-5.1");

        let env_vars = config.env_vars.as_ref().expect("env_vars should be set");
        assert_eq!(env_vars.len(), 1);
        assert_eq!(env_vars[0].name, "ZAI_BASE_URL");
        assert_eq!(
            env_vars[0].default,
            Some("https://api.z.ai/api/anthropic".to_string())
        );
    }

    #[test]
    fn test_openai_reasoning_provider_json_preserves_thinking() {
        for (name, json) in [
            (
                "custom_deepseek",
                include_str!("../providers/declarative/deepseek.json"),
            ),
            (
                "moonshot",
                include_str!("../providers/declarative/moonshot.json"),
            ),
            (
                "novita",
                include_str!("../providers/declarative/novita.json"),
            ),
            (
                "nvidia",
                include_str!("../providers/declarative/nvidia.json"),
            ),
            (
                "custom_tensorix",
                include_str!("../providers/declarative/tensorix.json"),
            ),
            ("zhipu", include_str!("../providers/declarative/zhipu.json")),
        ] {
            let config: DeclarativeProviderConfig =
                serde_json::from_str(json).expect("provider json should parse");
            assert_eq!(config.name, name);
            assert!(matches!(config.engine, ProviderEngine::OpenAI));
            assert!(config.preserves_thinking);
        }
    }

    #[test]
    fn test_nvidia_json_deserializes() {
        let json = include_str!("../providers/declarative/nvidia.json");
        let config: DeclarativeProviderConfig =
            serde_json::from_str(json).expect("nvidia.json should parse");
        assert_eq!(config.name, "nvidia");
        assert_eq!(config.display_name, "NVIDIA");
        assert!(matches!(config.engine, ProviderEngine::OpenAI));
        assert_eq!(config.api_key_env, "NVIDIA_API_KEY");
        assert_eq!(config.base_url, "https://integrate.api.nvidia.com/v1");
        assert_eq!(config.catalog_provider_id, Some("nvidia".to_string()));
        assert_eq!(config.dynamic_models, Some(true));
        assert_eq!(config.supports_streaming, Some(true));
        assert!(!config.skip_canonical_filtering);
        assert_eq!(
            config.model_doc_link,
            Some("https://build.nvidia.com/models".to_string())
        );
        assert_eq!(config.setup_steps.len(), 4);

        assert_eq!(config.models.len(), 1);
        assert_eq!(config.models[0].name, "z-ai/glm-4.7");
        assert_eq!(config.models[0].context_limit, 131072);
    }

    #[test]
    fn test_nearai_json_deserializes() {
        let json = include_str!("../providers/declarative/nearai.json");
        let config: DeclarativeProviderConfig =
            serde_json::from_str(json).expect("nearai.json should parse");
        assert_eq!(config.name, "nearai");
        assert_eq!(config.display_name, "NEAR AI Cloud");
        assert!(matches!(config.engine, ProviderEngine::OpenAI));
        assert_eq!(config.api_key_env, "NEARAI_API_KEY");
        assert_eq!(config.base_url, "https://cloud-api.near.ai/v1");
        assert_eq!(config.catalog_provider_id, Some("nearai".to_string()));
        assert_eq!(config.dynamic_models, Some(true));
        assert_eq!(config.supports_streaming, Some(true));
        assert!(config.preserves_thinking);
        assert_eq!(
            config.model_doc_link,
            Some("https://docs.near.ai/".to_string())
        );
        assert_eq!(config.models[0].name, "zai-org/GLM-5.1-FP8");
        assert!(config.models[0].reasoning);
    }

    #[test]
    fn test_vercel_ai_gateway_json_deserializes() {
        let json = include_str!("../providers/declarative/vercel_ai_gateway.json");
        let config: DeclarativeProviderConfig =
            serde_json::from_str(json).expect("vercel_ai_gateway.json should parse");
        assert_eq!(config.name, "vercel_ai_gateway");
        assert_eq!(config.display_name, "Vercel AI Gateway");
        assert!(matches!(config.engine, ProviderEngine::OpenAI));
        assert_eq!(config.api_key_env, "AI_GATEWAY_API_KEY");
        assert_eq!(
            config.base_url,
            "https://ai-gateway.vercel.sh/v1/chat/completions"
        );
        assert_eq!(config.supports_streaming, Some(true));
        assert!(!config.models.is_empty());

        let headers = config
            .headers
            .as_ref()
            .expect("vercel_ai_gateway should set attribution headers");
        assert_eq!(
            headers.get("http-referer").map(String::as_str),
            Some("https://goose-docs.ai")
        );
        assert_eq!(headers.get("x-title").map(String::as_str), Some("goose"));
    }

    #[test]
    fn test_validate_provider_id_rejects_legacy_punctuation_for_new_ids() {
        assert!(validate_provider_id("custom_z.ai").is_err());
    }

    fn write_legacy_provider_config(id: &str, display_name: &str) {
        let custom_dir = custom_providers_dir();
        std::fs::create_dir_all(&custom_dir).unwrap();
        let content = format!(
            r#"{{
  "name": "{id}",
  "engine": "openai",
  "display_name": "{display_name}",
  "description": "legacy provider",
  "api_key_env": "",
  "base_url": "https://example.invalid/v1/chat/completions",
  "models": [],
  "requires_auth": false
}}"#
        );
        std::fs::write(custom_dir.join(format!("{id}.json")), content).unwrap();
    }

    #[test]
    fn test_load_provider_allows_legacy_custom_id_with_punctuation() {
        let temp_dir = tempfile::tempdir().unwrap();
        let temp_root = temp_dir.path().display().to_string();
        let _guard = env_lock::lock_env([("GOOSE_PATH_ROOT", Some(temp_root.as_str()))]);

        write_legacy_provider_config("custom_z.ai", "Z.AI");

        let loaded = load_provider("custom_z.ai").unwrap();
        assert!(loaded.is_editable);
        assert_eq!(loaded.config.name, "custom_z.ai");
    }

    #[test]
    fn test_update_and_remove_provider_allow_legacy_custom_id_with_punctuation() {
        let temp_dir = tempfile::tempdir().unwrap();
        let temp_root = temp_dir.path().display().to_string();
        let _guard = env_lock::lock_env([("GOOSE_PATH_ROOT", Some(temp_root.as_str()))]);

        write_legacy_provider_config("custom_z.ai", "Z.AI");

        update_custom_provider(UpdateCustomProviderParams {
            id: "custom_z.ai".to_string(),
            engine: "openai".to_string(),
            display_name: "Z.AI Updated".to_string(),
            api_url: "https://updated.example.invalid/v1/chat/completions".to_string(),
            api_key: None,
            models: vec!["z-model".to_string()],
            supports_streaming: Some(true),
            headers: None,
            requires_auth: false,
            catalog_provider_id: None,
            base_path: None,
            preserves_thinking: None,
        })
        .unwrap();

        let updated = load_provider("custom_z.ai").unwrap();
        assert_eq!(updated.config.display_name, "Z.AI Updated");
        assert_eq!(updated.config.models[0].name, "z-model");

        remove_custom_provider("custom_z.ai").unwrap();
        assert!(!custom_providers_dir().join("custom_z.ai.json").exists());
    }

    #[test]
    fn test_load_provider_rejects_path_segments() {
        assert!(load_provider("custom_../secret").is_err());
        assert!(load_provider("custom_..\\secret").is_err());
    }

    #[test]
    fn test_opencode_go_json_deserializes() {
        let json = include_str!("../providers/declarative/opencode_go.json");
        let config: DeclarativeProviderConfig =
            serde_json::from_str(json).expect("opencode_go.json should parse");
        assert_eq!(config.name, "opencode_go");
        assert_eq!(config.display_name, "OpenCode Go");
        assert!(matches!(config.engine, ProviderEngine::OpenAI));
        assert_eq!(config.api_key_env, "OPENCODE_API_KEY");
        assert_eq!(config.base_url, "https://opencode.ai/zen/go/v1");
        assert_eq!(config.catalog_provider_id, Some("opencode-go".to_string()));
        assert_eq!(config.dynamic_models, Some(true));
        assert!(config.preserves_thinking);
        assert_eq!(config.models[0].name, "kimi-k2.6");
    }

    #[test]
    fn test_expand_env_vars_replaces_placeholder() {
        let _guard = env_lock::lock_env([("TEST_EXPAND_HOST", Some("https://example.com/api"))]);

        let env_vars = vec![EnvVarConfig {
            name: "TEST_EXPAND_HOST".to_string(),
            required: true,
            secret: false,
            primary: None,
            description: None,
            default: None,
        }];

        let result = expand_env_vars("${TEST_EXPAND_HOST}/v1/chat/completions", &env_vars).unwrap();
        assert_eq!(result, "https://example.com/api/v1/chat/completions");
    }

    #[test]
    fn test_expand_env_vars_required_missing_errors() {
        let _guard = env_lock::lock_env([("TEST_EXPAND_MISSING", None::<&str>)]);

        let env_vars = vec![EnvVarConfig {
            name: "TEST_EXPAND_MISSING".to_string(),
            required: true,
            secret: false,
            primary: None,
            description: None,
            default: None,
        }];

        let result = expand_env_vars("${TEST_EXPAND_MISSING}/path", &env_vars);
        assert!(result.is_err());
        assert!(result
            .unwrap_err()
            .to_string()
            .contains("TEST_EXPAND_MISSING"));
    }

    #[test]
    fn test_expand_env_vars_uses_default_when_missing() {
        let _guard = env_lock::lock_env([("TEST_EXPAND_DEFAULT", None::<&str>)]);

        let env_vars = vec![EnvVarConfig {
            name: "TEST_EXPAND_DEFAULT".to_string(),
            required: false,
            secret: false,
            primary: None,
            description: None,
            default: Some("https://fallback.example.com".to_string()),
        }];

        let result =
            expand_env_vars("${TEST_EXPAND_DEFAULT}/v1/chat/completions", &env_vars).unwrap();
        assert_eq!(result, "https://fallback.example.com/v1/chat/completions");
    }

    #[test]
    fn test_expand_env_vars_no_placeholders_passthrough() {
        let env_vars = vec![EnvVarConfig {
            name: "UNUSED_VAR".to_string(),
            required: true,
            secret: false,
            primary: None,
            description: None,
            default: None,
        }];

        let result =
            expand_env_vars("https://static.example.com/v1/chat/completions", &env_vars).unwrap();
        assert_eq!(result, "https://static.example.com/v1/chat/completions");
    }

    #[test]
    fn test_expand_env_vars_empty_slice_passthrough() {
        let result = expand_env_vars("${WHATEVER}/path", &[]).unwrap();
        assert_eq!(result, "${WHATEVER}/path");
    }

    #[test]
    fn test_expand_env_vars_env_value_overrides_default() {
        let _guard = env_lock::lock_env([("TEST_EXPAND_OVERRIDE", Some("https://from-env.com"))]);

        let env_vars = vec![EnvVarConfig {
            name: "TEST_EXPAND_OVERRIDE".to_string(),
            required: false,
            secret: false,
            primary: None,
            description: None,
            default: Some("https://from-default.com".to_string()),
        }];

        let result = expand_env_vars("${TEST_EXPAND_OVERRIDE}/path", &env_vars).unwrap();
        assert_eq!(result, "https://from-env.com/path");
    }

    #[test]
    fn test_atomic_chat_json_deserializes() {
        let json = include_str!("../providers/declarative/atomic_chat.json");
        let config: DeclarativeProviderConfig =
            serde_json::from_str(json).expect("atomic_chat.json should parse");
        assert_eq!(config.name, "atomic_chat");
        assert_eq!(config.display_name, "Atomic Chat");
        assert_eq!(
            config.description.as_deref(),
            Some("Local models through Atomic Chat\u{2019}s OpenAI-compatible server")
        );
        assert!(matches!(config.engine, ProviderEngine::OpenAI));
        assert_eq!(config.api_key_env, "");
        assert!(!config.requires_auth);
        assert!(config.skip_canonical_filtering);
        assert_eq!(config.dynamic_models, Some(true));
        assert_eq!(config.supports_streaming, Some(true));
        assert_eq!(config.base_url, "${ATOMIC_CHAT_HOST}/v1/chat/completions");
        assert!(config.models.is_empty());
        assert!(config.model_doc_link.is_none());
        assert!(config.setup_steps.is_empty());

        let env_vars = config.env_vars.as_ref().expect("env_vars should be set");
        assert_eq!(env_vars.len(), 1);
        assert_eq!(env_vars[0].name, "ATOMIC_CHAT_HOST");
        assert!(!env_vars[0].required);
        assert!(!env_vars[0].secret);
        assert_eq!(env_vars[0].primary, Some(true));
        assert_eq!(
            env_vars[0].default,
            Some("http://localhost:1337".to_string())
        );
        assert_eq!(
            env_vars[0].description.as_deref(),
            Some("Base URL of the Atomic Chat server (default: http://localhost:1337)")
        );
    }

    #[test]
    fn test_routstr_json_deserializes() {
        let json = include_str!("../providers/declarative/routstr.json");
        let config: DeclarativeProviderConfig =
            serde_json::from_str(json).expect("routstr.json should parse");
        assert_eq!(config.name, "routstr");
        assert_eq!(config.display_name, "Routstr");
        assert!(matches!(config.engine, ProviderEngine::OpenAI));
        assert_eq!(config.api_key_env, "ROUTSTR_API_KEY");
        assert_eq!(config.base_url, "${ROUTSTR_HOST}/v1");
        assert_eq!(config.dynamic_models, Some(true));
        assert_eq!(config.supports_streaming, Some(true));
        assert!(config.skip_canonical_filtering);
        assert_eq!(config.models.len(), 6);

        let env_vars = config.env_vars.as_ref().expect("env_vars should be set");
        assert_eq!(env_vars.len(), 1);
        assert_eq!(env_vars[0].name, "ROUTSTR_HOST");
        assert!(!env_vars[0].required);
        assert!(!env_vars[0].secret);
        assert_eq!(env_vars[0].primary, Some(true));
        assert_eq!(
            env_vars[0].default,
            Some("https://api.routstr.com".to_string())
        );
    }
}
