//! Catalogue of OpenCode **cloud** providers (ticket #92, lots B3 and the
//! dynamic-catalogue follow-up).
//!
//! No OpenCode sub-command exposes a stable, machine-readable provider list
//! (cadrage Architect), but OpenCode itself maintains a local cache of the
//! [models.dev](https://models.dev) registry it downloads on startup
//! (`<cache>/opencode/models.json`, format `{providerId: {name, models:
//! {modelId: {name, ...}}}}`). [`opencode_provider_catalogue`] reads that
//! cache when present and well-formed, so the picker reflects the *real*
//! OpenCode registry (hundreds of providers) instead of a hand-picked few.
//!
//! The cache is **not guaranteed** to exist or parse (OpenCode never run on
//! this machine, cache cleared, upstream format change, …), so a small
//! static fallback — the original three entries from lot B3 — is always
//! available and used whenever the cache can't be read. This fallback is
//! infallible by construction: [`ListOpenCodeProviders::execute`] never
//! errors.

use std::collections::BTreeMap;
use std::path::PathBuf;

use serde::Deserialize;

use domain::profile::CustomProviderConfig;

const BUILT_IN_PROVIDERS: [&str; 3] = ["anthropic", "openai", "openrouter"];

/// One entry of the OpenCode cloud-provider catalogue.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenCodeProviderCatalogEntry {
    /// Identifier in the OpenCode provider registry (e.g. `"anthropic"`).
    pub provider_id: String,
    /// Human-readable label for the picker UI.
    pub display_name: String,
    /// Model identifiers this provider serves, offered for selection.
    pub models: Vec<String>,
    /// Runtime metadata kept inside the application layer. It is deliberately
    /// not exposed by the existing frontend DTO.
    pub(crate) npm: Option<String>,
    pub(crate) api: Option<String>,
    pub(crate) model_labels: BTreeMap<String, String>,
}

/// The original lot-B3 catalogue: three well-known providers, used whenever
/// the real OpenCode model cache can't be read or parsed.
fn static_fallback_catalogue() -> Vec<OpenCodeProviderCatalogEntry> {
    vec![
        OpenCodeProviderCatalogEntry {
            provider_id: "anthropic".to_owned(),
            display_name: "Anthropic".to_owned(),
            models: vec![
                "claude-sonnet-5".to_owned(),
                "claude-opus-4-8".to_owned(),
                "claude-haiku-4-5-20251001".to_owned(),
            ],
            npm: None,
            api: None,
            model_labels: BTreeMap::new(),
        },
        OpenCodeProviderCatalogEntry {
            provider_id: "openrouter".to_owned(),
            display_name: "OpenRouter".to_owned(),
            models: vec![
                "anthropic/claude-sonnet-5".to_owned(),
                "openai/gpt-5".to_owned(),
                "google/gemini-3-pro".to_owned(),
            ],
            npm: None,
            api: None,
            model_labels: BTreeMap::new(),
        },
        OpenCodeProviderCatalogEntry {
            provider_id: "openai".to_owned(),
            display_name: "OpenAI".to_owned(),
            models: vec!["gpt-5".to_owned(), "gpt-5-mini".to_owned()],
            npm: None,
            api: None,
            model_labels: BTreeMap::new(),
        },
    ]
}

/// Resolves the path OpenCode itself uses for its models.dev cache.
///
/// OpenCode has a documented upstream bug
/// (<https://github.com/sst/opencode/issues/8235>): it resolves its cache
/// directory via the **Linux XDG convention** (`$XDG_CACHE_HOME`, else
/// `~/.cache`) on every platform it runs on, including Windows, instead of
/// the platform-appropriate directory `dirs::cache_dir()` would return
/// (`%LOCALAPPDATA%` on Windows). To find the *same file OpenCode actually
/// wrote*, this function deliberately mimics that non-conforming behaviour —
/// it resolves from `dirs::home_dir()` plus the literal suffix
/// `.cache/opencode/models.json`, NOT `dirs::cache_dir()`. This is a
/// **permanent** compatibility shim tied to the upstream bug, not a
/// workaround for "OpenCode never launched": even on a machine where OpenCode
/// runs regularly, this is the only path that matches its actual output.
pub fn opencode_models_cache_path() -> Option<PathBuf> {
    let cache_home = std::env::var_os("XDG_CACHE_HOME")
        .map(PathBuf::from)
        .or_else(|| dirs::home_dir().map(|home| home.join(".cache")))?;
    Some(cache_home.join("opencode").join("models.json"))
}

/// One provider entry as shaped by the models.dev registry / OpenCode cache.
#[derive(Debug, Deserialize)]
struct ModelsDevProvider {
    #[serde(default)]
    name: Option<String>,
    #[serde(default)]
    npm: Option<String>,
    #[serde(default)]
    api: Option<String>,
    #[serde(default)]
    models: BTreeMap<String, ModelsDevModel>,
}

#[derive(Debug, Deserialize)]
struct ModelsDevModel {
    #[serde(default)]
    name: Option<String>,
}

/// Parses a models.dev-shaped cache payload into catalogue entries.
///
/// Returns `None` if `bytes` isn't valid JSON in the expected shape, or if it
/// parses to zero usable (non-empty-models) providers — either way the caller
/// falls back to [`static_fallback_catalogue`]. Entries are sorted by
/// `provider_id` for a deterministic, diffable order.
fn parse_models_dev_cache(bytes: &[u8]) -> Option<Vec<OpenCodeProviderCatalogEntry>> {
    let providers: BTreeMap<String, ModelsDevProvider> = serde_json::from_slice(bytes).ok()?;
    let mut entries: Vec<OpenCodeProviderCatalogEntry> = providers
        .into_iter()
        .filter_map(|(provider_id, provider)| {
            let model_labels = provider
                .models
                .iter()
                .filter_map(|(id, model)| {
                    model
                        .name
                        .as_ref()
                        .filter(|name| !name.trim().is_empty())
                        .map(|name| (id.clone(), name.clone()))
                })
                .collect();
            let models: Vec<String> = provider.models.into_keys().collect();
            if models.is_empty() {
                return None;
            }
            let display_name = provider
                .name
                .filter(|name| !name.trim().is_empty())
                .unwrap_or_else(|| provider_id.clone());
            Some(OpenCodeProviderCatalogEntry {
                provider_id,
                display_name,
                models,
                npm: provider.npm,
                api: provider.api,
                model_labels,
            })
        })
        .collect();
    if entries.is_empty() {
        return None;
    }
    entries.sort_by(|a, b| a.provider_id.cmp(&b.provider_id));
    Some(entries)
}

/// Builds the catalogue from raw cache bytes (`None` = cache file absent or
/// unreadable), falling back to [`static_fallback_catalogue`] whenever the
/// bytes are missing, malformed, or parse to nothing usable. Split out from
/// [`opencode_provider_catalogue`] so the fallback logic is testable without
/// touching the real filesystem.
fn catalogue_from_cache_bytes(bytes: Option<Vec<u8>>) -> Vec<OpenCodeProviderCatalogEntry> {
    bytes
        .and_then(|bytes| parse_models_dev_cache(&bytes))
        .unwrap_or_else(static_fallback_catalogue)
}

/// Returns the OpenCode cloud-provider catalogue: the real OpenCode models
/// cache when it can be read and parsed, otherwise the static fallback.
/// Never fails — see the module docs for why the fallback is permanent, not
/// just a first-launch corner case.
#[must_use]
pub fn opencode_provider_catalogue() -> Vec<OpenCodeProviderCatalogEntry> {
    let bytes = opencode_models_cache_path().and_then(|path| std::fs::read(path).ok());
    catalogue_from_cache_bytes(bytes)
}

/// Builds the persisted standalone-provider metadata for a catalogue provider.
///
/// OpenCode only knows the three allowlisted providers without a registry
/// cache. Every other provider must therefore carry enough configuration to
/// remain runnable after the host cache disappears.
pub(crate) fn catalogue_custom_provider(
    provider_id: &str,
    model_id: &str,
) -> Result<Option<CustomProviderConfig>, String> {
    catalogue_custom_provider_from(opencode_provider_catalogue(), provider_id, model_id)
}

fn catalogue_custom_provider_from(
    catalogue: Vec<OpenCodeProviderCatalogEntry>,
    provider_id: &str,
    model_id: &str,
) -> Result<Option<CustomProviderConfig>, String> {
    if BUILT_IN_PROVIDERS.contains(&provider_id) {
        return Ok(None);
    }

    let provider = catalogue
        .into_iter()
        .find(|entry| entry.provider_id == provider_id)
        .ok_or_else(|| format!("provider `{provider_id}` is absent from models.dev"))?;
    if !provider.models.iter().any(|model| model == model_id) {
        return Err(format!(
            "model `{model_id}` is absent from models.dev provider `{provider_id}`"
        ));
    }

    let npm = required_metadata(provider.npm, "npm", provider_id)?;
    let api = required_metadata(provider.api, "api", provider_id)?;
    if !(api.starts_with("http://") || api.starts_with("https://")) {
        return Err(format!(
            "models.dev provider `{provider_id}` has an invalid api URL"
        ));
    }
    let label = provider
        .model_labels
        .get(model_id)
        .cloned()
        .filter(|label| !label.trim().is_empty())
        .ok_or_else(|| {
            format!("model `{model_id}` of models.dev provider `{provider_id}` has no label")
        })?;

    CustomProviderConfig::new(npm, api, Some(label))
        .map(Some)
        .map_err(|error| error.to_string())
}

fn required_metadata(
    value: Option<String>,
    field: &str,
    provider_id: &str,
) -> Result<String, String> {
    value
        .filter(|value| !value.trim().is_empty())
        .ok_or_else(|| format!("models.dev provider `{provider_id}` has no valid `{field}`"))
}

/// Use case exposing [`opencode_provider_catalogue`] to the driving side. No
/// port: reading the OpenCode cache is a best-effort local convenience read,
/// never a failure mode the driving side needs to react to (see the
/// guaranteed fallback).
pub struct ListOpenCodeProviders;

/// Output of [`ListOpenCodeProviders::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListOpenCodeProvidersOutput {
    /// The catalogue entries.
    pub providers: Vec<OpenCodeProviderCatalogEntry>,
}

impl ListOpenCodeProviders {
    /// Builds the use case (stateless, no ports to inject).
    #[must_use]
    pub const fn new() -> Self {
        Self
    }

    /// Lists the OpenCode cloud-provider catalogue.
    #[must_use]
    pub fn execute(&self) -> ListOpenCodeProvidersOutput {
        ListOpenCodeProvidersOutput {
            providers: opencode_provider_catalogue(),
        }
    }
}

impl Default for ListOpenCodeProviders {
    fn default() -> Self {
        Self::new()
    }
}

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

    #[test]
    fn static_fallback_entries_have_non_empty_ids_and_models() {
        for entry in static_fallback_catalogue() {
            assert!(!entry.provider_id.is_empty());
            assert!(!entry.display_name.is_empty());
            assert!(!entry.models.is_empty());
        }
    }

    #[test]
    fn static_fallback_provider_ids_are_unique() {
        let catalogue = static_fallback_catalogue();
        for (i, a) in catalogue.iter().enumerate() {
            for b in &catalogue[i + 1..] {
                assert_ne!(a.provider_id, b.provider_id);
            }
        }
    }

    #[test]
    fn catalogue_from_cache_bytes_falls_back_when_absent() {
        assert_eq!(
            catalogue_from_cache_bytes(None),
            static_fallback_catalogue()
        );
    }

    #[test]
    fn catalogue_from_cache_bytes_falls_back_when_malformed() {
        let malformed = b"{ not json at all".to_vec();
        assert_eq!(
            catalogue_from_cache_bytes(Some(malformed)),
            static_fallback_catalogue()
        );
    }

    #[test]
    fn catalogue_from_cache_bytes_falls_back_when_empty_object() {
        assert_eq!(
            catalogue_from_cache_bytes(Some(b"{}".to_vec())),
            static_fallback_catalogue()
        );
    }

    #[test]
    fn catalogue_from_cache_bytes_falls_back_when_no_provider_has_models() {
        let json = br#"{"foo": {"name": "Foo", "models": {}}}"#.to_vec();
        assert_eq!(
            catalogue_from_cache_bytes(Some(json)),
            static_fallback_catalogue()
        );
    }

    #[test]
    fn catalogue_from_cache_bytes_parses_a_valid_models_dev_payload() {
        let json = br#"{
            "openai": {
                "name": "OpenAI",
                "models": {
                    "gpt-5": {"name": "GPT-5"},
                    "gpt-5-mini": {"name": "GPT-5 Mini"}
                }
            },
            "anthropic": {
                "name": "Anthropic",
                "models": {
                    "claude-sonnet-5": {"name": "Claude Sonnet 5"}
                }
            }
        }"#
        .to_vec();

        let entries = catalogue_from_cache_bytes(Some(json));

        // Sorted by provider_id.
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0].provider_id, "anthropic");
        assert_eq!(entries[0].display_name, "Anthropic");
        assert_eq!(entries[0].models, vec!["claude-sonnet-5".to_owned()]);
        assert_eq!(entries[1].provider_id, "openai");
        assert_eq!(entries[1].display_name, "OpenAI");
        let mut models = entries[1].models.clone();
        models.sort();
        assert_eq!(models, vec!["gpt-5".to_owned(), "gpt-5-mini".to_owned()]);
    }

    #[test]
    fn models_dev_parser_keeps_runtime_metadata_and_model_labels() {
        let json = br#"{
            "zai": {
                "name": "Z.AI",
                "npm": "@ai-sdk/openai-compatible",
                "api": "https://api.z.ai/api/paas/v4",
                "models": {
                    "glm-5.1": {"name": "GLM-5.1"}
                }
            }
        }"#;
        let entries = parse_models_dev_cache(json).unwrap();
        let zai = &entries[0];
        assert_eq!(zai.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
        assert_eq!(zai.api.as_deref(), Some("https://api.z.ai/api/paas/v4"));
        assert_eq!(
            zai.model_labels.get("glm-5.1").map(String::as_str),
            Some("GLM-5.1")
        );
    }

    #[test]
    fn zai_is_materialized_as_a_standalone_provider() {
        let catalogue = parse_models_dev_cache(
            br#"{"zai":{"npm":"@ai-sdk/openai-compatible","api":"https://api.z.ai/api/paas/v4","models":{"glm-5.1":{"name":"GLM-5.1"}}}}"#,
        )
        .unwrap();
        let custom = catalogue_custom_provider_from(catalogue, "zai", "glm-5.1")
            .unwrap()
            .unwrap();
        assert_eq!(custom.npm, "@ai-sdk/openai-compatible");
        assert_eq!(custom.base_url, "https://api.z.ai/api/paas/v4");
        assert_eq!(custom.display_name.as_deref(), Some("GLM-5.1"));
    }

    #[test]
    fn built_ins_do_not_require_models_dev_runtime_metadata() {
        assert_eq!(
            catalogue_custom_provider_from(Vec::new(), "anthropic", "claude-sonnet-5").unwrap(),
            None
        );
    }

    #[test]
    fn standalone_provider_rejects_missing_or_invalid_runtime_metadata() {
        let missing_label = parse_models_dev_cache(
            br#"{"zai":{"npm":"@ai-sdk/openai-compatible","api":"https://api.z.ai/api/paas/v4","models":{"glm-5.1":{}}}}"#,
        )
        .unwrap();
        assert!(
            catalogue_custom_provider_from(missing_label, "zai", "glm-5.1")
                .unwrap_err()
                .contains("has no label")
        );

        let invalid_api = parse_models_dev_cache(
            br#"{"zai":{"npm":"@ai-sdk/openai-compatible","api":"api.z.ai","models":{"glm-5.1":{"name":"GLM-5.1"}}}}"#,
        )
        .unwrap();
        assert!(
            catalogue_custom_provider_from(invalid_api, "zai", "glm-5.1")
                .unwrap_err()
                .contains("invalid api URL")
        );
    }

    #[test]
    fn catalogue_from_cache_bytes_defaults_display_name_to_provider_id() {
        let json = br#"{"mystery": {"models": {"m1": {}}}}"#.to_vec();
        let entries = catalogue_from_cache_bytes(Some(json));
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].provider_id, "mystery");
        assert_eq!(entries[0].display_name, "mystery");
    }

    #[test]
    fn opencode_provider_catalogue_is_never_empty() {
        // Whatever this machine's OpenCode cache state is, the catalogue is
        // either the real cache or the guaranteed static fallback.
        assert!(!opencode_provider_catalogue().is_empty());
    }

    #[test]
    fn provider_ids_are_unique() {
        let catalogue = opencode_provider_catalogue();
        for (i, a) in catalogue.iter().enumerate() {
            for b in &catalogue[i + 1..] {
                assert_ne!(a.provider_id, b.provider_id);
            }
        }
    }

    #[test]
    fn list_opencode_providers_returns_a_non_empty_catalogue() {
        let output = ListOpenCodeProviders::new().execute();
        assert!(!output.providers.is_empty());
    }
}
