//! Effective model registry merged from built-in catalog and configured self-hosted aliases.

use crate::Provider;
use crate::config::{
    Config, ConfigError, CustomModelConfig, SelfHostedApiStyle, SelfHostedConfig,
    SelfHostedTransport,
};
use crate::model_profile::{ModelCatalog, ModelProfile, catalog::ModelTier};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelfHostedServerRef {
    pub server_id: String,
    pub remote_model: String,
    pub transport: SelfHostedTransport,
    pub api_style: SelfHostedApiStyle,
    pub base_url: String,
}

#[derive(Debug, Clone)]
pub struct ModelRegistryEntry {
    pub id: String,
    pub display_name: String,
    pub provider: Provider,
    pub tier: ModelTier,
    pub context_window: Option<u32>,
    pub max_output_tokens: Option<u32>,
    pub self_hosted: Option<SelfHostedServerRef>,
}

#[derive(Debug, Clone)]
pub struct ModelRegistry {
    entries: BTreeMap<String, ModelRegistryEntry>,
    profiles: BTreeMap<(Provider, String), ModelProfile>,
    defaults: BTreeMap<Provider, String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModelCapability {
    InlineVideo,
}

impl ModelCapability {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::InlineVideo => "inline_video",
        }
    }

    fn display_name(self) -> &'static str {
        match self {
            Self::InlineVideo => "inline video",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UnsupportedModelCapabilityReason {
    CapabilityDisabled,
    ProviderModelProfileMissing,
    CapabilityRegistryUnavailable,
}

impl UnsupportedModelCapabilityReason {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::CapabilityDisabled => "capability_disabled",
            Self::ProviderModelProfileMissing => "provider_model_profile_missing",
            Self::CapabilityRegistryUnavailable => "capability_registry_unavailable",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnsupportedModelCapabilityEvidence {
    pub capability: ModelCapability,
    pub provider: Provider,
    pub model: String,
    pub reason: UnsupportedModelCapabilityReason,
}

impl UnsupportedModelCapabilityEvidence {
    pub fn inline_video(
        provider: Provider,
        model: impl Into<String>,
        reason: UnsupportedModelCapabilityReason,
    ) -> Self {
        Self {
            capability: ModelCapability::InlineVideo,
            provider,
            model: model.into(),
            reason,
        }
    }

    pub fn details(&self) -> serde_json::Value {
        serde_json::json!({
            "unsupported_capability": {
                "capability": self.capability.as_str(),
                "provider": self.provider.as_str(),
                "model": self.model.as_str(),
                "reason": self.reason.as_str(),
            },
        })
    }
}

impl fmt::Display for UnsupportedModelCapabilityEvidence {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} input is not supported by model '{}' on provider '{}' (capability: {}, reason: {})",
            self.capability.display_name(),
            self.model,
            self.provider.as_str(),
            self.capability.as_str(),
            self.reason.as_str()
        )
    }
}

impl ModelRegistry {
    /// Build the effective registry from a config snapshot and an explicitly
    /// injected model catalog (canonically `meerkat_models::canonical()`).
    pub fn from_config(config: &Config, catalog: ModelCatalog) -> Result<Self, ConfigError> {
        Self::from_config_with_models(config, &BTreeMap::new(), catalog)
    }

    /// Build the effective registry from a config snapshot plus caller-scoped
    /// custom model definitions (e.g. mob-definition `[models.<id>]` entries).
    ///
    /// Config-owned `[models.<id>]` entries merge first, then `extra_models`.
    /// Model ids must stay unique across catalog, config, self-hosted, and
    /// caller-scoped entries (fail closed on conflict).
    pub fn from_config_with_models(
        config: &Config,
        extra_models: &BTreeMap<String, CustomModelConfig>,
        catalog: ModelCatalog,
    ) -> Result<Self, ConfigError> {
        let mut entries = BTreeMap::new();
        let mut profiles = BTreeMap::new();
        let mut defaults = BTreeMap::new();

        for &provider in catalog.providers {
            let default_model = catalog.default_model(provider).ok_or_else(|| {
                ConfigError::InternalError(format!(
                    "missing catalog default for '{}'",
                    provider.as_str()
                ))
            })?;
            defaults.insert(provider, default_model.to_string());

            for entry in catalog
                .entries
                .iter()
                .filter(|entry| entry.provider == provider.as_str())
            {
                let profile = catalog.profile_for(provider, entry.id).ok_or_else(|| {
                    ConfigError::InternalError(format!(
                        "missing catalog profile for {}:{}",
                        entry.provider, entry.id
                    ))
                })?;
                insert_unique(
                    &mut entries,
                    &mut profiles,
                    ModelRegistryEntry {
                        id: entry.id.to_string(),
                        display_name: entry.display_name.to_string(),
                        provider,
                        tier: entry.tier,
                        context_window: entry.context_window,
                        max_output_tokens: entry.max_output_tokens,
                        self_hosted: None,
                    },
                    profile,
                )?;
            }
        }

        append_custom_models(&mut entries, &mut profiles, &config.models.custom)?;
        append_custom_models(&mut entries, &mut profiles, extra_models)?;

        append_self_hosted(
            &mut entries,
            &mut profiles,
            &mut defaults,
            &config.self_hosted,
        )?;

        Ok(Self {
            entries,
            profiles,
            defaults,
        })
    }

    /// Returns model projection metadata by id.
    ///
    /// This model-only lookup intentionally does not expose `ModelProfile` or
    /// capability fields (`ModelRegistryEntry` has no profile accessor).
    /// Capability decisions must use typed provider-aware lookup through
    /// [`ModelRegistry::profile_for_provider`].
    pub fn entry(&self, model_id: &str) -> Option<&ModelRegistryEntry> {
        self.entries.get(model_id)
    }

    pub fn entry_for_provider(
        &self,
        provider: Provider,
        model_id: &str,
    ) -> Option<&ModelRegistryEntry> {
        self.entry(model_id)
            .filter(|entry| entry.provider == provider)
    }

    pub fn provider_override_mismatch_reason(
        &self,
        provider: Provider,
        model_id: &str,
    ) -> Option<String> {
        let registered_provider = self.entry(model_id)?.provider;
        if registered_provider == provider {
            return None;
        }

        Some(format!(
            "model '{model_id}' is registered for provider '{}', not provider '{}'; explicit provider overrides must match catalog ownership",
            registered_provider.as_str(),
            provider.as_str()
        ))
    }

    pub fn profile_for_provider(&self, provider: Provider, model_id: &str) -> Option<ModelProfile> {
        self.entry_for_provider(provider, model_id)?;
        self.profiles
            .get(&(provider, model_id.to_string()))
            .cloned()
    }

    pub fn require_inline_video_for_provider(
        &self,
        provider: Provider,
        model_id: &str,
    ) -> Result<(), UnsupportedModelCapabilityEvidence> {
        let Some(profile) = self.profile_for_provider(provider, model_id) else {
            return Err(UnsupportedModelCapabilityEvidence::inline_video(
                provider,
                model_id,
                UnsupportedModelCapabilityReason::ProviderModelProfileMissing,
            ));
        };

        if profile.inline_video {
            Ok(())
        } else {
            Err(UnsupportedModelCapabilityEvidence::inline_video(
                provider,
                model_id,
                UnsupportedModelCapabilityReason::CapabilityDisabled,
            ))
        }
    }

    pub fn default_model(&self, provider: Provider) -> Option<&str> {
        self.defaults.get(&provider).map(String::as_str)
    }

    pub fn entries_for_provider(
        &self,
        provider: Provider,
    ) -> impl Iterator<Item = &ModelRegistryEntry> {
        self.entries
            .values()
            .filter(move |entry| entry.provider == provider)
    }

    pub fn provider_defaults(&self) -> impl Iterator<Item = (Provider, &str)> {
        self.defaults
            .iter()
            .map(|(provider, default_model)| (*provider, default_model.as_str()))
    }
}

/// Merge user-defined `[models.<id>]` entries into the registry.
///
/// One definition is the single owner for provider inference, compaction
/// scaling (via `context_window`), capability gates, and call timeouts.
/// Capability flags default conservatively (absent unless declared).
fn append_custom_models(
    entries: &mut BTreeMap<String, ModelRegistryEntry>,
    profiles: &mut BTreeMap<(Provider, String), ModelProfile>,
    models: &BTreeMap<String, CustomModelConfig>,
) -> Result<(), ConfigError> {
    for (model_id, model) in models {
        match model.provider {
            Provider::Anthropic | Provider::OpenAI | Provider::Gemini => {}
            Provider::SelfHosted => {
                return Err(ConfigError::Validation(format!(
                    "models.{model_id}: self-hosted models must be declared under \
                     [self_hosted.models], not [models]"
                )));
            }
            Provider::Other => {
                return Err(ConfigError::Validation(format!(
                    "models.{model_id}: provider must be a concrete API provider \
                     (anthropic, openai, gemini)"
                )));
            }
        }

        let vision = model.vision.unwrap_or(false);
        let profile = ModelProfile {
            provider: model.provider,
            model_family: model_id.clone(),
            supports_temperature: true,
            supports_thinking: false,
            supports_reasoning: false,
            supports_web_search: model.web_search.unwrap_or(false),
            inline_video: false,
            vision,
            image_input: vision,
            image_tool_results: vision,
            realtime: false,
            image_generation: false,
            params_schema: serde_json::json!({}),
            beta_headers: Vec::new(),
            call_timeout_secs: model.call_timeout_secs,
        };

        insert_unique(
            entries,
            profiles,
            ModelRegistryEntry {
                id: model_id.clone(),
                display_name: model
                    .display_name
                    .clone()
                    .unwrap_or_else(|| model_id.clone()),
                provider: model.provider,
                tier: ModelTier::Supported,
                context_window: model.context_window,
                max_output_tokens: model.max_output_tokens,
                self_hosted: None,
            },
            profile,
        )?;
    }
    Ok(())
}

fn append_self_hosted(
    entries: &mut BTreeMap<String, ModelRegistryEntry>,
    profiles: &mut BTreeMap<(Provider, String), ModelProfile>,
    defaults: &mut BTreeMap<Provider, String>,
    config: &SelfHostedConfig,
) -> Result<(), ConfigError> {
    if config.models.is_empty() {
        return Ok(());
    }

    // The self-hosted default is a declared choice owned by the config, never a
    // `BTreeMap` key-order artifact. An explicit `default_model` must reference
    // a configured model; absent that, a single configured model is the
    // unambiguous default, while multiple models require an explicit
    // declaration (fail closed).
    let default_model = match &config.default_model {
        Some(declared) => {
            if !config.models.contains_key(declared) {
                return Err(ConfigError::Validation(format!(
                    "self_hosted.default_model '{declared}' does not reference a configured \
                     self_hosted.models entry"
                )));
            }
            declared.clone()
        }
        None => {
            let mut model_ids = config.models.keys();
            match (model_ids.next(), model_ids.next()) {
                (Some(only), None) => only.clone(),
                (Some(_), Some(_)) => {
                    return Err(ConfigError::MissingField(
                        "self_hosted.default_model (required when more than one self_hosted.models \
                         entry is configured)"
                            .to_string(),
                    ));
                }
                _ => {
                    return Err(ConfigError::InternalError(
                        "self-hosted models unexpectedly empty".to_string(),
                    ));
                }
            }
        }
    };
    defaults.insert(Provider::SelfHosted, default_model);

    for (model_id, model) in &config.models {
        let server = config.servers.get(&model.server).ok_or_else(|| {
            ConfigError::Validation(format!(
                "self_hosted.models.{model_id} references unknown server '{}'",
                model.server
            ))
        })?;
        let self_hosted = SelfHostedServerRef {
            server_id: model.server.clone(),
            remote_model: model.remote_model.clone(),
            transport: server.transport,
            api_style: server.api_style,
            base_url: normalize_base_url(&server.base_url),
        };
        let image_tool_results = model.image_tool_results
            && matches!(
                (server.transport, server.api_style),
                (
                    SelfHostedTransport::OpenAiCompatible,
                    SelfHostedApiStyle::Responses
                )
            );
        let profile = ModelProfile {
            provider: Provider::SelfHosted,
            model_family: model.family.clone(),
            supports_temperature: model.supports_temperature,
            supports_thinking: model.supports_thinking,
            supports_reasoning: model.supports_reasoning,
            supports_web_search: model.supports_web_search,
            inline_video: model.inline_video,
            vision: model.vision,
            image_input: model.vision,
            image_tool_results,
            realtime: false,
            image_generation: false,
            params_schema: serde_json::json!({}),
            beta_headers: Vec::new(),
            call_timeout_secs: model.call_timeout_secs,
        };

        insert_unique(
            entries,
            profiles,
            ModelRegistryEntry {
                id: model_id.clone(),
                display_name: model.display_name.clone(),
                provider: Provider::SelfHosted,
                tier: model.tier,
                context_window: model.context_window,
                max_output_tokens: model.max_output_tokens,
                self_hosted: Some(self_hosted),
            },
            profile,
        )?;
    }

    Ok(())
}

fn insert_unique(
    entries: &mut BTreeMap<String, ModelRegistryEntry>,
    profiles: &mut BTreeMap<(Provider, String), ModelProfile>,
    entry: ModelRegistryEntry,
    profile: ModelProfile,
) -> Result<(), ConfigError> {
    let model_id = entry.id.clone();
    let provider = entry.provider;
    if entries.insert(model_id.clone(), entry).is_some() {
        return Err(ConfigError::Validation(format!(
            "model id '{model_id}' must be unique across built-in, custom, and self-hosted entries"
        )));
    }
    profiles.insert((provider, model_id), profile);
    Ok(())
}

pub fn normalize_base_url(base_url: &str) -> String {
    let trimmed = base_url.trim_end_matches('/');
    if trimmed.ends_with("/v1") {
        trimmed.to_string()
    } else {
        format!("{trimmed}/v1")
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::panic)]

    use super::*;
    use crate::config::{
        SelfHostedApiStyle, SelfHostedModelConfig, SelfHostedServerConfig, SelfHostedTransport,
    };
    use crate::model_profile::test_catalog::{
        ANTHROPIC_MODEL, OPENAI_MODEL, TEST_CATALOG, VIDEO_MODEL,
    };

    fn test_catalog() -> ModelCatalog {
        *TEST_CATALOG
    }

    fn config_with_self_hosted() -> Config {
        let mut config = Config::default();
        config.self_hosted.servers.insert(
            "local".to_string(),
            SelfHostedServerConfig {
                transport: SelfHostedTransport::OpenAiCompatible,
                base_url: "http://127.0.0.1:11434".to_string(),
                api_style: SelfHostedApiStyle::Responses,
            },
        );
        config.self_hosted.models.insert(
            "gemma-4-31b".to_string(),
            SelfHostedModelConfig {
                server: "local".to_string(),
                remote_model: "gemma4:31b".to_string(),
                display_name: "Gemma 4 31B".to_string(),
                family: "gemma-4".to_string(),
                tier: ModelTier::Supported,
                context_window: Some(256_000),
                max_output_tokens: Some(8_192),
                vision: true,
                image_tool_results: true,
                inline_video: false,
                supports_temperature: true,
                supports_thinking: false,
                supports_reasoning: false,
                supports_web_search: false,
                call_timeout_secs: Some(600),
            },
        );
        config
    }

    #[test]
    fn merges_self_hosted_models_into_registry() {
        let config = config_with_self_hosted();
        let registry = match ModelRegistry::from_config(&config, test_catalog()) {
            Ok(registry) => registry,
            Err(err) => panic!("registry construction failed: {err}"),
        };
        let entry = match registry.entry("gemma-4-31b") {
            Some(entry) => entry,
            None => panic!("missing self-hosted entry for gemma-4-31b"),
        };
        assert_eq!(entry.provider, Provider::SelfHosted);
        assert_eq!(entry.display_name, "Gemma 4 31B");
        assert_eq!(
            entry
                .self_hosted
                .as_ref()
                .map(|server| server.server_id.as_str()),
            Some("local")
        );
        assert_eq!(
            entry
                .self_hosted
                .as_ref()
                .map(|server| server.remote_model.as_str()),
            Some("gemma4:31b")
        );
        assert_eq!(
            entry
                .self_hosted
                .as_ref()
                .map(|server| server.base_url.as_str()),
            Some("http://127.0.0.1:11434/v1")
        );
        assert_eq!(
            registry.default_model(Provider::SelfHosted),
            Some("gemma-4-31b")
        );
        let profile = match registry.profile_for_provider(Provider::SelfHosted, "gemma-4-31b") {
            Some(profile) => profile,
            None => panic!("missing self-hosted profile"),
        };
        assert!(
            profile.image_tool_results,
            "Responses-mode self-hosted models should retain configured image tool-result support"
        );
    }

    #[test]
    fn self_hosted_chat_completions_downgrades_image_tool_result_support() {
        let mut config = config_with_self_hosted();
        match config.self_hosted.servers.get_mut("local") {
            Some(server) => server.api_style = SelfHostedApiStyle::ChatCompletions,
            None => panic!("missing local self-hosted server"),
        }
        let registry = match ModelRegistry::from_config(&config, test_catalog()) {
            Ok(registry) => registry,
            Err(err) => panic!("registry construction failed: {err}"),
        };
        let profile = match registry.profile_for_provider(Provider::SelfHosted, "gemma-4-31b") {
            Some(profile) => profile,
            None => panic!("missing self-hosted profile"),
        };
        assert!(
            !profile.image_tool_results,
            "Chat Completions self-hosted models text-project tool results even when raw config requests image support"
        );
    }

    fn insert_second_self_hosted_model(config: &mut Config) {
        config.self_hosted.models.insert(
            "gemma-4-9b".to_string(),
            SelfHostedModelConfig {
                server: "local".to_string(),
                remote_model: "gemma4:9b".to_string(),
                display_name: "Gemma 4 9B".to_string(),
                family: "gemma-4".to_string(),
                tier: ModelTier::Supported,
                context_window: Some(128_000),
                max_output_tokens: Some(8_192),
                vision: false,
                image_tool_results: false,
                inline_video: false,
                supports_temperature: true,
                supports_thinking: false,
                supports_reasoning: false,
                supports_web_search: false,
                call_timeout_secs: Some(600),
            },
        );
    }

    #[test]
    fn multiple_self_hosted_models_require_explicit_default() {
        let mut config = config_with_self_hosted();
        insert_second_self_hosted_model(&mut config);
        // No `default_model` declared: the default must not be silently chosen
        // by `BTreeMap` key order. Fail closed.
        let err = match ModelRegistry::from_config(&config, test_catalog()) {
            Ok(_) => panic!("multi-model self-hosted config without explicit default should fail"),
            Err(err) => err,
        };
        assert!(
            err.to_string().contains("self_hosted.default_model"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn explicit_self_hosted_default_is_honored_not_lexicographic() {
        let mut config = config_with_self_hosted();
        insert_second_self_hosted_model(&mut config);
        // Declare the lexicographically-larger id as the default to prove the
        // choice is declared, not a `.min()` key-order artifact.
        config.self_hosted.default_model = Some("gemma-4-9b".to_string());
        let registry = match ModelRegistry::from_config(&config, test_catalog()) {
            Ok(registry) => registry,
            Err(err) => panic!("registry construction failed: {err}"),
        };
        assert_eq!(
            registry.default_model(Provider::SelfHosted),
            Some("gemma-4-9b")
        );
    }

    #[test]
    fn explicit_self_hosted_default_must_reference_configured_model() {
        let mut config = config_with_self_hosted();
        insert_second_self_hosted_model(&mut config);
        config.self_hosted.default_model = Some("does-not-exist".to_string());
        let err = match ModelRegistry::from_config(&config, test_catalog()) {
            Ok(_) => panic!("default_model referencing an absent model should fail"),
            Err(err) => err,
        };
        assert!(
            err.to_string()
                .contains("does not reference a configured self_hosted.models entry"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn rejects_unknown_server_reference() {
        let mut config = Config::default();
        config.self_hosted.models.insert(
            "gemma-4-31b".to_string(),
            SelfHostedModelConfig {
                server: "missing".to_string(),
                remote_model: "gemma4:31b".to_string(),
                display_name: "Gemma 4 31B".to_string(),
                family: "gemma-4".to_string(),
                tier: ModelTier::Supported,
                context_window: None,
                max_output_tokens: None,
                vision: true,
                image_tool_results: true,
                inline_video: false,
                supports_temperature: true,
                supports_thinking: false,
                supports_reasoning: false,
                supports_web_search: false,
                call_timeout_secs: None,
            },
        );
        let err = match ModelRegistry::from_config(&config, test_catalog()) {
            Ok(_) => panic!("unknown server should fail"),
            Err(err) => err,
        };
        assert!(err.to_string().contains("references unknown server"));
    }

    #[test]
    fn rejects_duplicate_model_ids() {
        let mut config = Config::default();
        config.self_hosted.servers.insert(
            "local".to_string(),
            SelfHostedServerConfig {
                transport: SelfHostedTransport::OpenAiCompatible,
                base_url: "http://127.0.0.1:11434".to_string(),
                api_style: SelfHostedApiStyle::Responses,
            },
        );
        config.self_hosted.models.insert(
            OPENAI_MODEL.to_string(),
            SelfHostedModelConfig {
                server: "local".to_string(),
                remote_model: "override".to_string(),
                display_name: "Override".to_string(),
                family: "override".to_string(),
                tier: ModelTier::Supported,
                context_window: None,
                max_output_tokens: None,
                vision: false,
                image_tool_results: false,
                inline_video: false,
                supports_temperature: true,
                supports_thinking: false,
                supports_reasoning: false,
                supports_web_search: false,
                call_timeout_secs: None,
            },
        );
        let err = match ModelRegistry::from_config(&config, test_catalog()) {
            Ok(_) => panic!("duplicate model id should fail"),
            Err(err) => err,
        };
        assert!(err.to_string().contains("must be unique"));
    }

    fn custom_model(provider: Provider) -> CustomModelConfig {
        CustomModelConfig {
            provider,
            display_name: Some("Claude Custom".to_string()),
            context_window: Some(500_000),
            max_output_tokens: Some(16_384),
            vision: Some(true),
            web_search: None,
            call_timeout_secs: Some(900),
        }
    }

    #[test]
    fn custom_models_merge_into_registry_with_provider_inference() {
        let mut config = Config::default();
        config.models.custom.insert(
            "custom-internal-preview".to_string(),
            custom_model(Provider::Anthropic),
        );
        let registry = match ModelRegistry::from_config(&config, test_catalog()) {
            Ok(registry) => registry,
            Err(err) => panic!("registry construction failed: {err}"),
        };

        // One definition feeds provider inference...
        let entry = registry
            .entry("custom-internal-preview")
            .unwrap_or_else(|| panic!("custom entry must be registered"));
        assert_eq!(entry.provider, Provider::Anthropic);
        assert_eq!(entry.display_name, "Claude Custom");
        // ...compaction scaling inputs...
        assert_eq!(entry.context_window, Some(500_000));
        assert_eq!(entry.max_output_tokens, Some(16_384));
        // ...capability gates and call timeouts via the typed profile owner.
        let profile = registry
            .profile_for_provider(Provider::Anthropic, "custom-internal-preview")
            .unwrap_or_else(|| panic!("custom profile must resolve for declared provider"));
        assert!(profile.vision);
        assert!(profile.image_input);
        assert!(
            !profile.supports_web_search,
            "undeclared capability flags must default conservatively"
        );
        assert_eq!(profile.call_timeout_secs, Some(900));
        assert!(
            registry
                .profile_for_provider(Provider::OpenAI, "custom-internal-preview")
                .is_none(),
            "custom capability truth must stay scoped to the declared provider"
        );
    }

    #[test]
    fn extra_custom_models_merge_via_from_config_with_models() {
        let mut extra = BTreeMap::new();
        extra.insert(
            "mob-defined-model".to_string(),
            custom_model(Provider::Gemini),
        );
        let registry = match ModelRegistry::from_config_with_models(
            &Config::default(),
            &extra,
            test_catalog(),
        ) {
            Ok(registry) => registry,
            Err(err) => panic!("registry construction failed: {err}"),
        };
        assert_eq!(
            registry
                .entry("mob-defined-model")
                .map(|entry| entry.provider),
            Some(Provider::Gemini)
        );
    }

    #[test]
    fn custom_models_reject_catalog_id_conflicts() {
        let mut config = Config::default();
        config
            .models
            .custom
            .insert(OPENAI_MODEL.to_string(), custom_model(Provider::OpenAI));
        let err = match ModelRegistry::from_config(&config, test_catalog()) {
            Ok(_) => panic!("custom entry shadowing a catalog id must fail"),
            Err(err) => err,
        };
        assert!(err.to_string().contains("must be unique"));
    }

    #[test]
    fn custom_models_reject_self_hosted_and_other_providers() {
        for provider in [Provider::SelfHosted, Provider::Other] {
            let mut config = Config::default();
            config
                .models
                .custom
                .insert("custom-x".to_string(), custom_model(provider));
            let err = match ModelRegistry::from_config(&config, test_catalog()) {
                Ok(_) => panic!("non-concrete custom model provider must fail closed"),
                Err(err) => err,
            };
            assert!(
                err.to_string().contains("models.custom-x"),
                "unexpected error: {err}"
            );
        }
    }

    #[test]
    fn custom_model_toml_ingress_is_fail_closed_on_provider() {
        // `[models.<id>]` parses the provider into the typed closed vocabulary.
        let parsed: Result<crate::config::ModelDefaults, _> = toml::from_str(
            r#"
anthropic = "custom-anthropic-default"

[custom-internal-preview]
provider = "anthropic"
context_window = 500000
"#,
        );
        let defaults = match parsed {
            Ok(defaults) => defaults,
            Err(err) => panic!("custom model table must parse: {err}"),
        };
        assert_eq!(
            defaults
                .custom
                .get("custom-internal-preview")
                .map(|model| model.provider),
            Some(Provider::Anthropic)
        );

        let rejected: Result<crate::config::ModelDefaults, _> = toml::from_str(
            r#"
[custom-internal-preview]
provider = "not-a-provider"
"#,
        );
        assert!(
            rejected.is_err(),
            "unknown provider names must fail closed at config ingress"
        );
    }

    #[test]
    fn uncatalogued_models_do_not_use_provider_prefix_inference() {
        let registry = match ModelRegistry::from_config(&Config::default(), test_catalog()) {
            Ok(registry) => registry,
            Err(err) => panic!("registry construction failed: {err}"),
        };
        assert!(
            registry
                .profile_for_provider(Provider::OpenAI, "test-openai-unknown-preview")
                .is_none()
        );
        assert!(
            registry
                .profile_for_provider(Provider::Anthropic, "test-anthropic-unknown-preview")
                .is_none()
        );
        assert!(
            registry
                .profile_for_provider(Provider::Gemini, "test-gemini-unknown-preview")
                .is_none()
        );
    }

    #[test]
    fn provider_aware_profile_lookup_requires_matching_provider() {
        let registry = match ModelRegistry::from_config(&Config::default(), test_catalog()) {
            Ok(registry) => registry,
            Err(err) => panic!("registry construction failed: {err}"),
        };

        let profile = registry.profile_for_provider(Provider::OpenAI, OPENAI_MODEL);
        assert_eq!(
            profile.and_then(|profile| profile.call_timeout_secs),
            Some(600)
        );
        assert!(
            registry
                .profile_for_provider(Provider::Anthropic, OPENAI_MODEL)
                .is_none(),
            "provider-aware lookup must not share OpenAI defaults with Anthropic"
        );
        assert!(
            registry
                .profile_for_provider(Provider::OpenAI, VIDEO_MODEL)
                .is_none(),
            "provider-aware lookup must not let provider strings select another provider's capabilities"
        );
    }

    #[test]
    fn model_only_entries_are_projection_metadata_not_capability_authority() {
        let registry = match ModelRegistry::from_config(&Config::default(), test_catalog()) {
            Ok(registry) => registry,
            Err(err) => panic!("registry construction failed: {err}"),
        };

        let entry = match registry.entry(VIDEO_MODEL) {
            Some(entry) => entry,
            None => panic!("catalog entry must exist"),
        };
        assert_eq!(entry.provider, Provider::Gemini);
        assert_eq!(entry.id, VIDEO_MODEL);
        let rendered = format!("{entry:?}");
        assert!(
            !rendered.contains("inline_video") && !rendered.contains("supports_temperature"),
            "model-only projection entry must not expose capability fields: {rendered}"
        );

        let profile = match registry.profile_for_provider(Provider::Gemini, VIDEO_MODEL) {
            Some(profile) => profile,
            None => panic!("typed provider-aware capability lookup should resolve"),
        };
        assert!(profile.inline_video);
        assert!(
            registry
                .profile_for_provider(Provider::OpenAI, VIDEO_MODEL)
                .is_none(),
            "display/catalog lookup must not let another typed provider read capability truth"
        );
    }

    #[test]
    fn provider_aware_profile_lookup_fails_closed_for_unknown_pairs() {
        let registry = match ModelRegistry::from_config(&Config::default(), test_catalog()) {
            Ok(registry) => registry,
            Err(err) => panic!("registry construction failed: {err}"),
        };

        assert!(
            registry
                .profile_for_provider(Provider::Other, OPENAI_MODEL)
                .is_none(),
            "unknown typed provider must not receive known model defaults"
        );
        assert!(
            registry
                .profile_for_provider(Provider::Other, "uncatalogued-compatible")
                .is_none(),
            "unknown provider/model pairs must fail closed"
        );
        assert!(
            registry
                .profile_for_provider(Provider::OpenAI, "uncatalogued-compatible")
                .is_none(),
            "known provider plus uncatalogued model must fail closed"
        );
    }

    #[test]
    fn inline_video_capability_requires_typed_provider_owner() {
        let registry = match ModelRegistry::from_config(&Config::default(), test_catalog()) {
            Ok(registry) => registry,
            Err(err) => panic!("registry construction failed: {err}"),
        };

        if let Err(err) = registry.require_inline_video_for_provider(Provider::Gemini, VIDEO_MODEL)
        {
            panic!("Gemini catalog owner should authorize inline video: {err}");
        }

        let err = match registry.require_inline_video_for_provider(Provider::OpenAI, VIDEO_MODEL) {
            Ok(()) => panic!("same model name under another provider must fail closed"),
            Err(err) => err,
        };
        assert_eq!(err.capability, ModelCapability::InlineVideo);
        assert_eq!(err.provider, Provider::OpenAI);
        assert_eq!(err.model, VIDEO_MODEL);
        assert_eq!(
            err.reason,
            UnsupportedModelCapabilityReason::ProviderModelProfileMissing
        );
    }

    #[test]
    fn inline_video_capability_evidence_distinguishes_disabled_and_unknown() {
        let registry = match ModelRegistry::from_config(&Config::default(), test_catalog()) {
            Ok(registry) => registry,
            Err(err) => panic!("registry construction failed: {err}"),
        };

        let disabled =
            match registry.require_inline_video_for_provider(Provider::OpenAI, OPENAI_MODEL) {
                Ok(()) => panic!("known OpenAI model has catalog-owned inline video disabled"),
                Err(err) => err,
            };
        assert_eq!(
            disabled.reason,
            UnsupportedModelCapabilityReason::CapabilityDisabled
        );

        let unknown = match registry
            .require_inline_video_for_provider(Provider::Other, "uncatalogued-video-model")
        {
            Ok(()) => panic!("unknown provider/model pair must fail closed"),
            Err(err) => err,
        };
        assert_eq!(
            unknown.reason,
            UnsupportedModelCapabilityReason::ProviderModelProfileMissing
        );
        let details = unknown.details();
        assert_eq!(
            details["unsupported_capability"]["capability"],
            serde_json::json!("inline_video")
        );
        assert_eq!(
            details["unsupported_capability"]["reason"],
            serde_json::json!("provider_model_profile_missing")
        );
    }

    #[test]
    fn provider_override_mismatch_reason_reports_catalog_owner_contradictions() {
        let registry = match ModelRegistry::from_config(&Config::default(), test_catalog()) {
            Ok(registry) => registry,
            Err(err) => panic!("registry construction failed: {err}"),
        };

        let reason =
            match registry.provider_override_mismatch_reason(Provider::Anthropic, OPENAI_MODEL) {
                Some(reason) => reason,
                None => panic!("wrong-provider override for a catalog model should be rejected"),
            };
        assert!(reason.contains(&format!("model '{OPENAI_MODEL}'")));
        assert!(reason.contains("registered for provider 'openai'"));
        assert!(reason.contains("not provider 'anthropic'"));
        assert!(reason.contains("explicit provider overrides"));

        assert!(
            registry
                .provider_override_mismatch_reason(Provider::OpenAI, OPENAI_MODEL)
                .is_none(),
            "matching provider override should remain valid"
        );
        assert!(
            registry
                .provider_override_mismatch_reason(Provider::OpenAI, "uncatalogued-compatible")
                .is_none(),
            "uncatalogued models have no catalog owner to contradict"
        );
    }

    #[test]
    fn registry_defaults_come_from_injected_catalog() {
        let registry = match ModelRegistry::from_config(&Config::default(), test_catalog()) {
            Ok(registry) => registry,
            Err(err) => panic!("registry construction failed: {err}"),
        };
        assert_eq!(
            registry.default_model(Provider::Anthropic),
            Some(ANTHROPIC_MODEL)
        );
        assert_eq!(registry.default_model(Provider::OpenAI), Some(OPENAI_MODEL));
        assert_eq!(registry.default_model(Provider::Gemini), Some(VIDEO_MODEL));
    }
}
