//! Cloud embedding provider — routes through the OpenHuman backend's
//! `POST /openai/v1/embeddings` surface (Voyage-backed) using the same
//! session JWT that the `OpenHumanBackendProvider` chat path uses.
//!
//! This is the default embedder for a fresh install. The local Ollama path
//! stays available, but the user has to explicitly opt in (either by setting
//! `memory.embedding_provider = "ollama"` in `config.toml`, or by enabling
//! the local-AI runtime with `local_ai.usage.embeddings = true`).
//!
//! The JWT and API URL are resolved per call so a session refresh between
//! embed batches is picked up transparently — matching
//! [`crate::openhuman::inference::provider::openhuman_backend::OpenHumanBackendProvider`].

use std::path::PathBuf;

use async_trait::async_trait;

use super::openai::OpenAiEmbedding;
use super::EmbeddingProvider;
use crate::api::config::effective_api_url;
use crate::openhuman::credentials::{AuthService, APP_SESSION_PROVIDER};

/// Default cloud embedding model — backed by `voyage-3.5` (1024 dims) on the
/// OpenHuman backend. See `tinyhumansai/backend#746`.
pub const DEFAULT_CLOUD_EMBEDDING_MODEL: &str = "embedding-v1";

/// Default output dimensionality for [`DEFAULT_CLOUD_EMBEDDING_MODEL`].
pub const DEFAULT_CLOUD_EMBEDDING_DIMENSIONS: usize = 1024;

/// OpenHuman-backend-backed embedding provider.
pub struct OpenHumanCloudEmbedding {
    api_url: Option<String>,
    openhuman_dir: Option<PathBuf>,
    secrets_encrypt: bool,
    model: String,
    dims: usize,
}

impl OpenHumanCloudEmbedding {
    /// Construct a cloud embedder. `api_url` and `openhuman_dir` are looked up
    /// per request; pass `None` to fall back to the runtime defaults
    /// ([`effective_api_url`] / `~/.openhuman`).
    pub fn new(
        api_url: Option<String>,
        openhuman_dir: Option<PathBuf>,
        secrets_encrypt: bool,
        model: impl Into<String>,
        dims: usize,
    ) -> Self {
        Self {
            api_url: api_url
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty()),
            openhuman_dir,
            secrets_encrypt,
            model: model.into(),
            dims,
        }
    }

    fn state_dir(&self) -> PathBuf {
        self.openhuman_dir.clone().unwrap_or_else(|| {
            // Honor OPENHUMAN_WORKSPACE (where auth-profiles.json lives) before
            // falling back to ~/.openhuman, so the cloud embedder resolves the
            // session JWT from the same directory the chat provider does. Without
            // this, any non-default workspace (OPENHUMAN_WORKSPACE set, e.g. tests
            // / multi-instance) silently has no session for embeddings —
            // resolve_bearer() bails, embed() errors, and vectors are dropped.
            if let Some(ws) = std::env::var_os("OPENHUMAN_WORKSPACE")
                .filter(|s| !s.is_empty())
                .map(PathBuf::from)
            {
                return ws;
            }
            directories::UserDirs::new()
                .map(|d| d.home_dir().join(".openhuman"))
                .unwrap_or_else(|| PathBuf::from(".openhuman"))
        })
    }

    fn resolve_bearer(&self) -> anyhow::Result<String> {
        let auth = AuthService::new(&self.state_dir(), self.secrets_encrypt);
        if let Some(t) = auth
            .get_provider_bearer_token(APP_SESSION_PROVIDER, None)?
            .filter(|s| !s.trim().is_empty())
        {
            return Ok(t);
        }
        anyhow::bail!(
            "No backend session for cloud embeddings: log in to OpenHuman, or set \
             memory.embedding_provider to \"ollama\" / \"none\" in config.toml"
        )
    }

    fn base_url(&self) -> String {
        let u = effective_api_url(&self.api_url);
        format!("{}/openai/v1", u.trim_end_matches('/'))
    }
}

#[async_trait]
impl EmbeddingProvider for OpenHumanCloudEmbedding {
    fn name(&self) -> &str {
        "cloud"
    }

    fn model_id(&self) -> &str {
        &self.model
    }

    fn dimensions(&self) -> usize {
        self.dims
    }

    async fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
        if texts.is_empty() {
            return Ok(Vec::new());
        }
        // Mirror the OpenAI provider's empty/whitespace guard *here* so we
        // never resolve the bearer or build the URL for an input the backend
        // will reject as `"input must be a non-empty string …"` (#13021).
        // Cheaper, and keeps unauthenticated test contexts off the auth path.
        if let Some(idx) = texts.iter().position(|t| t.trim().is_empty()) {
            tracing::warn!(
                target: "cloud::embed",
                "[cloud] refusing embed: input[{idx}] is empty/whitespace \
                 (count={}, model={}). Caller must filter empty strings.",
                texts.len(),
                self.model,
            );
            anyhow::bail!(
                "cloud embed: refusing empty/whitespace input at index {idx} of {} (model={})",
                texts.len(),
                self.model,
            );
        }
        let token = self.resolve_bearer()?;
        let inner = OpenAiEmbedding::new(&self.base_url(), &token, &self.model, self.dims);
        inner.embed(texts).await
    }
}

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

    #[test]
    fn name_and_dimensions() {
        let p = OpenHumanCloudEmbedding::new(
            None,
            None,
            true,
            DEFAULT_CLOUD_EMBEDDING_MODEL,
            DEFAULT_CLOUD_EMBEDDING_DIMENSIONS,
        );
        assert_eq!(p.name(), "cloud");
        assert_eq!(p.model_id(), DEFAULT_CLOUD_EMBEDDING_MODEL);
        assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS);
        assert_eq!(p.signature(), "provider=cloud;model=embedding-v1;dims=1024");
    }

    #[test]
    fn base_url_appends_openai_v1() {
        let p = OpenHumanCloudEmbedding::new(
            Some("https://api.openhuman.example/".into()),
            None,
            true,
            DEFAULT_CLOUD_EMBEDDING_MODEL,
            DEFAULT_CLOUD_EMBEDDING_DIMENSIONS,
        );
        assert_eq!(p.base_url(), "https://api.openhuman.example/openai/v1");
    }

    #[tokio::test]
    async fn embed_empty_returns_empty_without_auth() {
        // Empty input should short-circuit *before* hitting the AuthService —
        // otherwise the no-op path would spuriously fail in unauthenticated
        // contexts (e.g. ingestion of an empty chunk batch).
        let p = OpenHumanCloudEmbedding::new(
            None,
            None,
            false,
            DEFAULT_CLOUD_EMBEDDING_MODEL,
            DEFAULT_CLOUD_EMBEDDING_DIMENSIONS,
        );
        assert!(p.embed(&[]).await.unwrap().is_empty());
    }

    #[tokio::test]
    async fn embed_refuses_empty_string_without_auth() {
        // The whitespace pre-flight (#13021) MUST fire before `resolve_bearer`
        // — `secrets_encrypt = false` and no session means the AuthService
        // would otherwise bail with `"No backend session for cloud
        // embeddings…"` and mask the real defect. Asserting the bail wording
        // here pins the order of the two checks.
        let p = OpenHumanCloudEmbedding::new(
            None,
            None,
            false,
            DEFAULT_CLOUD_EMBEDDING_MODEL,
            DEFAULT_CLOUD_EMBEDDING_DIMENSIONS,
        );
        let err = p.embed(&[""]).await.unwrap_err().to_string();
        assert!(
            err.contains("refusing empty/whitespace input at index 0"),
            "expected pre-flight refusal, got: {err}"
        );
        assert!(
            !err.contains("No backend session"),
            "guard must run before resolve_bearer, got: {err}"
        );
    }
}
