//! Concrete adapters for structured model-catalogue enrichment.

use std::collections::BTreeSet;
use std::env;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;

use async_trait::async_trait;
use domain::model_catalogue::{CliVersion, CompatibilityMatrix};
use domain::ports::{
    CliVersionReader, CompatibilityMatrixSource, ProcessSpawner, ProviderModelCatalogue, SpawnSpec,
};
use domain::profile::StructuredAdapter;
use domain::project::ProjectPath;
use serde::Deserialize;

const VERSION_TIMEOUT: Duration = Duration::from_millis(800);
const PROVIDER_TIMEOUT: Duration = Duration::from_millis(1_500);
const EMBEDDED_MATRIX: &str = include_str!("model_compatibility_matrix.json");

/// Reads local CLI versions through `codex --version` / `claude --version`.
#[derive(Clone)]
pub struct ProcessCliVersionReader {
    spawner: Arc<dyn ProcessSpawner>,
}

impl ProcessCliVersionReader {
    /// Builds the adapter from the process-spawner port.
    #[must_use]
    pub fn new(spawner: Arc<dyn ProcessSpawner>) -> Self {
        Self { spawner }
    }

    fn spec(adapter: StructuredAdapter) -> Option<SpawnSpec> {
        let command = match adapter {
            StructuredAdapter::Claude => "claude",
            StructuredAdapter::Codex => "codex",
            StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => return None,
        };
        Some(SpawnSpec {
            command: command.to_owned(),
            args: vec!["--version".to_owned()],
            cwd: ProjectPath::new("/").expect("root project path is valid"),
            env: Vec::new(),
            context_plan: None,
            sandbox: None,
        })
    }
}

#[async_trait]
impl CliVersionReader for ProcessCliVersionReader {
    async fn read_cli_version(
        &self,
        adapter: StructuredAdapter,
    ) -> Result<Option<CliVersion>, String> {
        let Some(spec) = Self::spec(adapter) else {
            return Ok(None);
        };
        let command = spec.command.clone();
        let output = tokio::time::timeout(VERSION_TIMEOUT, self.spawner.run(spec))
            .await
            .map_err(|_| format!("{command} --version timed out"))?
            .map_err(|e| format!("{command} --version failed: {e}"))?;
        if output.status.code != Some(0) {
            return Err(format!(
                "{command} --version exited with {:?}",
                output.status.code
            ));
        }
        let text = String::from_utf8_lossy(&output.stdout)
            .trim()
            .to_owned()
            .if_empty_then(|| String::from_utf8_lossy(&output.stderr).trim().to_owned());
        if text.is_empty() {
            return Ok(None);
        }
        CliVersion::parse(text)
            .map(Some)
            .map_err(|e| format!("{command} --version was not parseable: {e}"))
    }
}

trait EmptyStringExt {
    fn if_empty_then(self, fallback: impl FnOnce() -> String) -> String;
}

impl EmptyStringExt for String {
    fn if_empty_then(self, fallback: impl FnOnce() -> String) -> String {
        if self.is_empty() {
            fallback()
        } else {
            self
        }
    }
}

/// Provider HTTP catalogue using existing API keys from the process environment.
#[derive(Clone)]
pub struct HttpProviderModelCatalogue {
    client: reqwest::Client,
}

impl HttpProviderModelCatalogue {
    /// Builds the adapter.
    #[must_use]
    pub fn new() -> Self {
        Self {
            client: reqwest::Client::new(),
        }
    }

    async fn list_openai(&self, key: String) -> Result<Vec<String>, String> {
        #[derive(Deserialize)]
        struct Response {
            data: Vec<Model>,
        }
        #[derive(Deserialize)]
        struct Model {
            id: String,
        }

        let response = tokio::time::timeout(
            PROVIDER_TIMEOUT,
            self.client
                .get("https://api.openai.com/v1/models")
                .bearer_auth(key)
                .send(),
        )
        .await
        .map_err(|_| "OpenAI model catalogue timed out".to_owned())?
        .map_err(|e| format!("OpenAI model catalogue unavailable: {e}"))?;
        if !response.status().is_success() {
            return Err(format!(
                "OpenAI model catalogue returned HTTP {}",
                response.status()
            ));
        }
        let parsed = response
            .json::<Response>()
            .await
            .map_err(|e| format!("OpenAI model catalogue parse failed: {e}"))?;
        Ok(dedup_non_empty(
            parsed.data.into_iter().map(|model| model.id),
        ))
    }

    async fn list_anthropic(&self, key: String) -> Result<Vec<String>, String> {
        #[derive(Deserialize)]
        struct Response {
            data: Vec<Model>,
        }
        #[derive(Deserialize)]
        struct Model {
            id: String,
        }

        let response = tokio::time::timeout(
            PROVIDER_TIMEOUT,
            self.client
                .get("https://api.anthropic.com/v1/models")
                .header("x-api-key", key)
                .header("anthropic-version", "2023-06-01")
                .send(),
        )
        .await
        .map_err(|_| "Anthropic model catalogue timed out".to_owned())?
        .map_err(|e| format!("Anthropic model catalogue unavailable: {e}"))?;
        if !response.status().is_success() {
            return Err(format!(
                "Anthropic model catalogue returned HTTP {}",
                response.status()
            ));
        }
        let parsed = response
            .json::<Response>()
            .await
            .map_err(|e| format!("Anthropic model catalogue parse failed: {e}"))?;
        Ok(dedup_non_empty(
            parsed.data.into_iter().map(|model| model.id),
        ))
    }
}

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

#[async_trait]
impl ProviderModelCatalogue for HttpProviderModelCatalogue {
    async fn list_provider_models(
        &self,
        adapter: StructuredAdapter,
    ) -> Result<Vec<String>, String> {
        match adapter {
            StructuredAdapter::Codex => match env::var("OPENAI_API_KEY") {
                Ok(key) if !key.trim().is_empty() => self.list_openai(key).await,
                _ => Ok(Vec::new()),
            },
            StructuredAdapter::Claude => match env::var("ANTHROPIC_API_KEY") {
                Ok(key) if !key.trim().is_empty() => self.list_anthropic(key).await,
                _ => Ok(Vec::new()),
            },
            StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => Ok(Vec::new()),
        }
    }
}

fn dedup_non_empty(values: impl Iterator<Item = String>) -> Vec<String> {
    values
        .map(|value| value.trim().to_owned())
        .filter(|value| !value.is_empty())
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect()
}

/// Compatibility matrix source backed by an embedded JSON seed and optional
/// app-data override.
#[derive(Debug, Clone)]
pub struct EmbeddedCompatibilityMatrix {
    override_path: Option<PathBuf>,
}

impl EmbeddedCompatibilityMatrix {
    /// Builds a matrix source with no override.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            override_path: None,
        }
    }

    /// Builds a matrix source reading `model-compat.json` from the app data dir
    /// before falling back to the embedded seed.
    #[must_use]
    pub fn with_app_data_dir(app_data_dir: impl Into<PathBuf>) -> Self {
        Self {
            override_path: Some(app_data_dir.into().join("model-compat.json")),
        }
    }

    fn embedded() -> CompatibilityMatrix {
        serde_json::from_str(EMBEDDED_MATRIX).expect("embedded compatibility matrix is valid")
    }
}

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

impl CompatibilityMatrixSource for EmbeddedCompatibilityMatrix {
    fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec<String>) {
        let Some(path) = &self.override_path else {
            return (Self::embedded(), Vec::new());
        };
        match std::fs::read_to_string(path) {
            Ok(raw) => match serde_json::from_str::<CompatibilityMatrix>(&raw) {
                Ok(matrix) => (matrix, Vec::new()),
                Err(e) => (
                    Self::embedded(),
                    vec![format!(
                        "model compatibility override ignored because it is invalid: {e}"
                    )],
                ),
            },
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (Self::embedded(), Vec::new()),
            Err(e) => (
                Self::embedded(),
                vec![format!(
                    "model compatibility override ignored because it is unreadable: {e}"
                )],
            ),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use domain::ports::{ExitStatus, Output, ProcessError};
    use std::sync::Mutex;

    struct RecordingSpawner {
        specs: Mutex<Vec<SpawnSpec>>,
    }

    #[async_trait]
    impl ProcessSpawner for RecordingSpawner {
        async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError> {
            self.specs.lock().unwrap().push(spec);
            Ok(Output {
                status: ExitStatus { code: Some(0) },
                stdout: b"codex-cli 0.45.1\n".to_vec(),
                stderr: Vec::new(),
            })
        }
    }

    #[tokio::test]
    async fn cli_version_reader_runs_only_version_probe() {
        let spawner = Arc::new(RecordingSpawner {
            specs: Mutex::new(Vec::new()),
        });
        let reader = ProcessCliVersionReader::new(spawner.clone());
        let version = reader
            .read_cli_version(StructuredAdapter::Codex)
            .await
            .unwrap()
            .unwrap();

        assert_eq!(version.raw, "codex-cli 0.45.1");
        let specs = spawner.specs.lock().unwrap();
        assert_eq!(specs.len(), 1);
        assert_eq!(specs[0].command, "codex");
        assert_eq!(specs[0].args, vec!["--version"]);
    }

    #[test]
    fn embedded_matrix_is_valid() {
        let (matrix, warnings) = EmbeddedCompatibilityMatrix::new().compatibility_matrix();
        assert!(warnings.is_empty());
        assert!(matrix.codex.contains_key("gpt-5-codex"));
        assert!(matrix.claude.contains_key("claude-sonnet-5"));
    }
}
