//! Curated and best-effort model catalogues for structured Claude/Codex profiles.
//!
//! The CLIs do not expose a stable machine-readable model catalogue and must not
//! be asked to enumerate models. The only local probe allowed here is
//! `<cli> --version`; provider APIs are optional and failures degrade to the
//! static seed plus warnings.

use std::collections::BTreeSet;
use std::sync::Arc;

use domain::model_catalogue::{evaluate_compatibility, CliVersion};
use domain::ports::{CliVersionReader, CompatibilityMatrixSource, ProviderModelCatalogue};
use domain::profile::StructuredAdapter;
use domain::{ModelCatalogSource, ModelCompatibility};

/// One searchable model entry for a structured profile adapter.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProfileModelCatalogEntry {
    /// Structured adapter this model belongs to.
    pub adapter: StructuredAdapter,
    /// Exact model identifier to persist on [`domain::profile::AgentProfile::model`].
    pub model_id: String,
    /// Human-readable label for picker display.
    pub display_name: String,
    /// Extra search tokens useful to the frontend.
    pub aliases: Vec<String>,
    /// Whether this entry is the conservative default suggestion.
    pub recommended: bool,
    /// Compatibility state against the locally detected CLI version.
    pub compatibility: ModelCompatibility,
    /// Source that contributed the model entry.
    pub source: ModelCatalogSource,
}

fn entry(
    adapter: StructuredAdapter,
    model_id: &str,
    display_name: &str,
    aliases: &[&str],
    recommended: bool,
) -> ProfileModelCatalogEntry {
    ProfileModelCatalogEntry {
        adapter,
        model_id: model_id.to_owned(),
        display_name: display_name.to_owned(),
        aliases: aliases.iter().map(|alias| (*alias).to_owned()).collect(),
        recommended,
        compatibility: ModelCompatibility::Unknown,
        source: ModelCatalogSource::Catalogue,
    }
}

fn provider_entry(adapter: StructuredAdapter, model_id: String) -> ProfileModelCatalogEntry {
    ProfileModelCatalogEntry {
        adapter,
        display_name: model_id.clone(),
        model_id,
        aliases: Vec::new(),
        recommended: false,
        compatibility: ModelCompatibility::Unknown,
        source: ModelCatalogSource::Provider,
    }
}

/// Static Claude Code model catalogue.
#[must_use]
pub fn claude_model_catalogue() -> Vec<ProfileModelCatalogEntry> {
    vec![
        entry(
            StructuredAdapter::Claude,
            "claude-sonnet-5",
            "Claude Sonnet 5",
            &["sonnet"],
            true,
        ),
        entry(
            StructuredAdapter::Claude,
            "claude-opus-4-8",
            "Claude Opus 4.8",
            &["opus"],
            false,
        ),
        entry(
            StructuredAdapter::Claude,
            "claude-haiku-4-5-20251001",
            "Claude Haiku 4.5",
            &["haiku"],
            false,
        ),
    ]
}

/// Static OpenAI Codex CLI model catalogue.
#[must_use]
pub fn codex_model_catalogue() -> Vec<ProfileModelCatalogEntry> {
    vec![
        entry(
            StructuredAdapter::Codex,
            "gpt-5-codex",
            "GPT-5 Codex",
            &["codex"],
            true,
        ),
        entry(
            StructuredAdapter::Codex,
            "gpt-5",
            "GPT-5",
            &["general"],
            false,
        ),
        entry(
            StructuredAdapter::Codex,
            "gpt-5-mini",
            "GPT-5 mini",
            &["mini", "fast"],
            false,
        ),
    ]
}

/// Output of structured model-catalogue resolution.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListModelsOutput {
    /// The catalogue entries.
    pub models: Vec<ProfileModelCatalogEntry>,
    /// Best-effort local CLI version.
    pub cli_version: Option<CliVersion>,
    /// Non-fatal fallback/degradation warnings.
    pub warnings: Vec<String>,
}

/// Output of [`ListClaudeModels::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListClaudeModelsOutput {
    /// The catalogue entries.
    pub models: Vec<ProfileModelCatalogEntry>,
    /// Best-effort local CLI version.
    pub cli_version: Option<CliVersion>,
    /// Non-fatal fallback/degradation warnings.
    pub warnings: Vec<String>,
}

impl From<ListModelsOutput> for ListClaudeModelsOutput {
    fn from(out: ListModelsOutput) -> Self {
        Self {
            models: out.models,
            cli_version: out.cli_version,
            warnings: out.warnings,
        }
    }
}

/// Output of [`ListCodexModels::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListCodexModelsOutput {
    /// The catalogue entries.
    pub models: Vec<ProfileModelCatalogEntry>,
    /// Best-effort local CLI version.
    pub cli_version: Option<CliVersion>,
    /// Non-fatal fallback/degradation warnings.
    pub warnings: Vec<String>,
}

impl From<ListModelsOutput> for ListCodexModelsOutput {
    fn from(out: ListModelsOutput) -> Self {
        Self {
            models: out.models,
            cli_version: out.cli_version,
            warnings: out.warnings,
        }
    }
}

/// Use case resolving an enriched structured model catalogue.
pub struct ResolveModelCatalogue {
    cli_versions: Arc<dyn CliVersionReader>,
    provider_catalogue: Arc<dyn ProviderModelCatalogue>,
    matrix_source: Arc<dyn CompatibilityMatrixSource>,
}

impl ResolveModelCatalogue {
    /// Builds the use case from hexagonal ports.
    #[must_use]
    pub fn new(
        cli_versions: Arc<dyn CliVersionReader>,
        provider_catalogue: Arc<dyn ProviderModelCatalogue>,
        matrix_source: Arc<dyn CompatibilityMatrixSource>,
    ) -> Self {
        Self {
            cli_versions,
            provider_catalogue,
            matrix_source,
        }
    }

    /// Resolves models for one structured adapter. Infallible by design.
    pub async fn execute(&self, adapter: StructuredAdapter) -> ListModelsOutput {
        let mut warnings = Vec::new();
        let (matrix, matrix_warnings) = self.matrix_source.compatibility_matrix();
        warnings.extend(matrix_warnings);

        let cli_version = match self.cli_versions.read_cli_version(adapter).await {
            Ok(version) => version,
            Err(warning) => {
                warnings.push(warning);
                None
            }
        };

        let mut models = match adapter {
            StructuredAdapter::Claude => claude_model_catalogue(),
            StructuredAdapter::Codex => codex_model_catalogue(),
            StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => Vec::new(),
        };

        match self.provider_catalogue.list_provider_models(adapter).await {
            Ok(provider_models) => {
                let existing = models
                    .iter()
                    .map(|model| model.model_id.clone())
                    .collect::<BTreeSet<_>>();
                models.extend(
                    provider_models
                        .into_iter()
                        .filter(|model_id| !existing.contains(model_id))
                        .map(|model_id| provider_entry(adapter, model_id)),
                );
            }
            Err(warning) => warnings.push(warning),
        }

        for model in &mut models {
            model.compatibility =
                evaluate_compatibility(&matrix, adapter, &model.model_id, cli_version.as_ref());
        }

        models.sort_by(|a, b| {
            b.recommended
                .cmp(&a.recommended)
                .then_with(|| a.display_name.cmp(&b.display_name))
                .then_with(|| a.model_id.cmp(&b.model_id))
        });

        ListModelsOutput {
            models,
            cli_version,
            warnings,
        }
    }
}

/// Use case exposing the Claude model catalogue.
pub struct ListClaudeModels {
    resolver: ResolveModelCatalogue,
}

impl ListClaudeModels {
    /// Builds the use case.
    #[must_use]
    pub fn new(
        cli_versions: Arc<dyn CliVersionReader>,
        provider_catalogue: Arc<dyn ProviderModelCatalogue>,
        matrix_source: Arc<dyn CompatibilityMatrixSource>,
    ) -> Self {
        Self {
            resolver: ResolveModelCatalogue::new(cli_versions, provider_catalogue, matrix_source),
        }
    }

    /// Lists Claude models. Infallible.
    pub async fn execute(&self) -> ListClaudeModelsOutput {
        self.resolver
            .execute(StructuredAdapter::Claude)
            .await
            .into()
    }
}

/// Use case exposing the Codex model catalogue.
pub struct ListCodexModels {
    resolver: ResolveModelCatalogue,
}

impl ListCodexModels {
    /// Builds the use case.
    #[must_use]
    pub fn new(
        cli_versions: Arc<dyn CliVersionReader>,
        provider_catalogue: Arc<dyn ProviderModelCatalogue>,
        matrix_source: Arc<dyn CompatibilityMatrixSource>,
    ) -> Self {
        Self {
            resolver: ResolveModelCatalogue::new(cli_versions, provider_catalogue, matrix_source),
        }
    }

    /// Lists Codex models. Infallible.
    pub async fn execute(&self) -> ListCodexModelsOutput {
        self.resolver.execute(StructuredAdapter::Codex).await.into()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use async_trait::async_trait;
    use domain::{CliVersion, CompatibilityMatrix};
    use std::collections::HashMap;

    struct FakeCliVersionReader(Option<Result<Option<CliVersion>, String>>);

    #[async_trait]
    impl CliVersionReader for FakeCliVersionReader {
        async fn read_cli_version(
            &self,
            _adapter: StructuredAdapter,
        ) -> Result<Option<CliVersion>, String> {
            self.0
                .clone()
                .unwrap_or_else(|| Ok(Some(CliVersion::parse("1.0.0").unwrap())))
        }
    }

    struct FakeProvider(Vec<String>, Option<String>);

    #[async_trait]
    impl ProviderModelCatalogue for FakeProvider {
        async fn list_provider_models(
            &self,
            _adapter: StructuredAdapter,
        ) -> Result<Vec<String>, String> {
            if let Some(warning) = &self.1 {
                Err(warning.clone())
            } else {
                Ok(self.0.clone())
            }
        }
    }

    struct FakeMatrixSource(CompatibilityMatrix, Vec<String>);

    impl CompatibilityMatrixSource for FakeMatrixSource {
        fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec<String>) {
            (self.0.clone(), self.1.clone())
        }
    }

    fn resolver(
        version: Option<Result<Option<CliVersion>, String>>,
        provider: Vec<String>,
        provider_warning: Option<String>,
    ) -> ResolveModelCatalogue {
        ResolveModelCatalogue::new(
            Arc::new(FakeCliVersionReader(version)),
            Arc::new(FakeProvider(provider, provider_warning)),
            Arc::new(FakeMatrixSource(
                CompatibilityMatrix {
                    version: 1,
                    claude: HashMap::from([
                        ("claude-sonnet-5".to_owned(), "1.0.0".to_owned()),
                        ("claude-opus-4-8".to_owned(), "2.0.0".to_owned()),
                    ]),
                    codex: HashMap::from([("gpt-5-codex".to_owned(), "1.0.0".to_owned())]),
                },
                vec![],
            )),
        )
    }

    #[test]
    fn static_catalogues_are_non_empty_searchable_and_have_one_default() {
        for (adapter, models) in [
            (StructuredAdapter::Claude, claude_model_catalogue()),
            (StructuredAdapter::Codex, codex_model_catalogue()),
        ] {
            assert!(!models.is_empty());
            assert_eq!(
                models.iter().filter(|model| model.recommended).count(),
                1,
                "{adapter:?} should expose one default suggestion"
            );
            for model in models {
                assert_eq!(model.adapter, adapter);
                assert!(!model.model_id.trim().is_empty());
                assert!(!model.display_name.trim().is_empty());
                assert_eq!(model.compatibility, ModelCompatibility::Unknown);
                assert_eq!(model.source, ModelCatalogSource::Catalogue);
            }
        }
    }

    #[tokio::test]
    async fn resolver_enriches_static_catalogue_with_cli_compatibility() {
        let out = resolver(
            Some(Ok(Some(CliVersion::parse("1.0.0").unwrap()))),
            vec![],
            None,
        )
        .execute(StructuredAdapter::Claude)
        .await;

        let sonnet = out
            .models
            .iter()
            .find(|model| model.model_id == "claude-sonnet-5")
            .unwrap();
        let opus = out
            .models
            .iter()
            .find(|model| model.model_id == "claude-opus-4-8")
            .unwrap();
        assert_eq!(sonnet.compatibility, ModelCompatibility::Compatible);
        assert_eq!(opus.compatibility, ModelCompatibility::LikelyTooRecent);
        assert_eq!(out.cli_version.unwrap().raw, "1.0.0");
        assert!(out.warnings.is_empty());
    }

    #[tokio::test]
    async fn resolver_keeps_provider_and_cli_failures_non_blocking() {
        let out = resolver(
            Some(Err("codex version unavailable".to_owned())),
            vec![],
            Some("provider unavailable".to_owned()),
        )
        .execute(StructuredAdapter::Codex)
        .await;

        assert!(!out.models.is_empty());
        assert_eq!(out.cli_version, None);
        assert_eq!(
            out.warnings,
            vec![
                "codex version unavailable".to_owned(),
                "provider unavailable".to_owned()
            ]
        );
        assert!(out
            .models
            .iter()
            .all(|model| model.compatibility == ModelCompatibility::Unknown));
    }

    #[tokio::test]
    async fn resolver_adds_provider_only_models_without_duplicate_seed_entries() {
        let out = resolver(
            None,
            vec!["gpt-5-codex".to_owned(), "gpt-5-provider".to_owned()],
            None,
        )
        .execute(StructuredAdapter::Codex)
        .await;

        assert_eq!(
            out.models
                .iter()
                .filter(|model| model.model_id == "gpt-5-codex")
                .count(),
            1
        );
        let provider = out
            .models
            .iter()
            .find(|model| model.model_id == "gpt-5-provider")
            .unwrap();
        assert_eq!(provider.source, ModelCatalogSource::Provider);
        assert_eq!(provider.compatibility, ModelCompatibility::Unknown);
    }
}
