//! Google Gemini model_provider with support for:
//! - Direct API key (`GEMINI_API_KEY` env var or config)
//! - Gemini CLI OAuth tokens (reuse existing ~/.gemini/ authentication)
//! - ZeroClaw auth-profiles OAuth tokens

use crate::auth::AuthService;
use crate::traits::{
    ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
    ModelProvider, TokenUsage, ToolsPayload,
};
use async_trait::async_trait;
use base64::Engine;
use directories::UserDirs;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;

/// Gemini model_provider supporting multiple authentication methods.
pub struct GeminiModelProvider {
    /// `[providers.models.gemini.<alias>]` config-key alias.
    alias: String,
    auth: Option<GeminiAuth>,
    oauth_project: Arc<tokio::sync::Mutex<Option<String>>>,
    oauth_project_seed: Option<String>,
    oauth_cred_paths: Vec<PathBuf>,
    oauth_index: Arc<tokio::sync::Mutex<usize>>,
    /// AuthService for managed profiles (auth-profiles.json).
    auth_service: Option<AuthService>,
    /// Override profile name for managed auth.
    auth_profile_override: Option<String>,
    /// Per-alias OAuth app credentials carried at construction time so
    /// runtime token refreshes via `AuthService::get_valid_gemini_access_token`
    /// can mint new access tokens without dipping back into Config. Set
    /// from `GeminiModelProviderConfig.oauth_client_id` / `oauth_client_secret`.
    oauth_client_id: Option<String>,
    oauth_client_secret: Option<String>,
}

/// Mutable OAuth token state — supports runtime refresh for long-lived processes.
struct OAuthTokenState {
    access_token: String,
    refresh_token: Option<String>,
    client_id: Option<String>,
    client_secret: Option<String>,
    /// Expiry as unix millis. `None` means unknown (treat as potentially expired).
    expiry_millis: Option<i64>,
}

/// Resolved credential — the variant determines both the HTTP auth method
/// and the diagnostic label returned by `auth_source()`.
enum GeminiAuth {
    /// Explicit API key from config: sent as `?key=` query parameter.
    ExplicitKey(String),
    /// OAuth access token from Gemini CLI: sent as `Authorization: Bearer`.
    /// Wrapped in a Mutex to allow runtime token refresh.
    OAuthToken(Arc<tokio::sync::Mutex<OAuthTokenState>>),
    /// OAuth token managed by AuthService (auth-profiles.json).
    /// Token refresh is handled by AuthService, not here.
    ManagedOAuth,
}

impl GeminiAuth {
    /// Whether this credential is an API key (sent as `?key=` query param).
    fn is_api_key(&self) -> bool {
        matches!(self, GeminiAuth::ExplicitKey(_))
    }

    /// Whether this credential is an OAuth token (CLI or managed).
    fn is_oauth(&self) -> bool {
        matches!(self, GeminiAuth::OAuthToken(_) | GeminiAuth::ManagedOAuth)
    }

    /// The raw credential string (for API key variants only).
    fn api_key_credential(&self) -> &str {
        match self {
            GeminiAuth::ExplicitKey(s) => s,
            GeminiAuth::OAuthToken(_) | GeminiAuth::ManagedOAuth => "",
        }
    }
}

// ══════════════════════════════════════════════════════════════════════════════
// API REQUEST/RESPONSE TYPES
// ══════════════════════════════════════════════════════════════════════════════

#[derive(Debug, Serialize, Clone)]
struct GenerateContentRequest {
    contents: Vec<Content>,
    #[serde(rename = "systemInstruction", skip_serializing_if = "Option::is_none")]
    system_instruction: Option<Content>,
    #[serde(rename = "generationConfig")]
    generation_config: GenerationConfig,
}

#[derive(Debug, Serialize)]
struct InternalGenerateContentEnvelope {
    model: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    project: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    user_prompt_id: Option<String>,
    request: InternalGenerateContentRequest,
}

/// Nested request payload for cloudcode-pa's code assist APIs.
#[derive(Debug, Serialize)]
struct InternalGenerateContentRequest {
    contents: Vec<Content>,
    #[serde(rename = "systemInstruction", skip_serializing_if = "Option::is_none")]
    system_instruction: Option<Content>,
    #[serde(rename = "generationConfig", skip_serializing_if = "Option::is_none")]
    generation_config: Option<GenerationConfig>,
}

#[derive(Debug, Serialize, Clone)]
struct Content {
    #[serde(skip_serializing_if = "Option::is_none")]
    role: Option<String>,
    parts: Vec<Part>,
}

#[derive(Debug, Serialize, Clone)]
#[serde(untagged)]
enum Part {
    Text { text: String },
    Inline { inline_data: InlineData },
}

impl Part {
    fn text(s: impl Into<String>) -> Self {
        Part::Text { text: s.into() }
    }
}

#[derive(Debug, Serialize, Clone)]
struct InlineData {
    mime_type: String,
    data: String,
}

/// Build Gemini Parts from a message content string.
/// If the content contains [IMAGE:data:...] markers (already normalized by the
/// multimodal pipeline), they are extracted as inline_data parts. The remaining
/// text becomes a text part. Falls back to a single text part if no markers.
fn build_parts(content: &str) -> Vec<Part> {
    let (text, image_refs) = crate::multimodal::parse_image_markers(content);
    let mut parts = Vec::new();
    let trimmed = text.trim();
    if !trimmed.is_empty() {
        parts.push(Part::text(trimmed));
    }
    for uri in &image_refs {
        if let Some(rest) = uri.strip_prefix("data:")
            && let Some(semi_pos) = rest.find(';')
        {
            let mime = &rest[..semi_pos];
            if let Some(b64) = rest[semi_pos + 1..].strip_prefix("base64,") {
                parts.push(Part::Inline {
                    inline_data: InlineData {
                        mime_type: mime.to_string(),
                        data: b64.to_string(),
                    },
                });
            }
        }
    }
    if parts.is_empty() {
        parts.push(Part::text(content));
    }
    parts
}

#[derive(Debug, Serialize, Clone)]
struct GenerationConfig {
    #[serde(skip_serializing_if = "Option::is_none")]
    temperature: Option<f64>,
    #[serde(rename = "maxOutputTokens")]
    max_output_tokens: u32,
}

#[derive(Debug, Deserialize)]
struct GenerateContentResponse {
    candidates: Option<Vec<Candidate>>,
    error: Option<ApiError>,
    #[serde(default)]
    response: Option<Box<GenerateContentResponse>>,
    #[serde(default, rename = "usageMetadata")]
    usage_metadata: Option<GeminiUsageMetadata>,
}

#[derive(Debug, Deserialize)]
struct GeminiUsageMetadata {
    #[serde(default, rename = "promptTokenCount")]
    prompt_token_count: Option<u64>,
    #[serde(default, rename = "candidatesTokenCount")]
    candidates_token_count: Option<u64>,
}

#[derive(Debug, Deserialize)]
struct Candidate {
    #[serde(default)]
    content: Option<CandidateContent>,
}

#[derive(Debug, Deserialize)]
struct CandidateContent {
    parts: Vec<ResponsePart>,
}

#[derive(Debug, Deserialize)]
struct ResponsePart {
    #[serde(default)]
    text: Option<String>,
    /// Thinking models (e.g. gemini-3-pro-preview) mark reasoning parts with `thought: true`.
    #[serde(default)]
    thought: bool,
}

impl CandidateContent {
    fn effective_text(self) -> Option<String> {
        let mut answer_parts: Vec<String> = Vec::new();
        let mut first_thinking: Option<String> = None;

        for part in self.parts {
            if let Some(text) = part.text {
                if text.is_empty() {
                    continue;
                }
                if !part.thought {
                    answer_parts.push(text);
                } else if first_thinking.is_none() {
                    first_thinking = Some(text);
                }
            }
        }

        if answer_parts.is_empty() {
            first_thinking
        } else {
            Some(answer_parts.join(""))
        }
    }
}

#[derive(Debug, Deserialize)]
struct ApiError {
    message: String,
}

impl GenerateContentResponse {
    /// cloudcode-pa wraps the actual response under `response`.
    fn into_effective_response(self) -> Self {
        match self {
            Self {
                response: Some(mut inner),
                usage_metadata,
                ..
            } => {
                if inner.usage_metadata.is_none() {
                    inner.usage_metadata = usage_metadata;
                }
                *inner
            }
            other => other,
        }
    }
}

// ══════════════════════════════════════════════════════════════════════════════
// GEMINI CLI TOKEN STRUCTURES
// ══════════════════════════════════════════════════════════════════════════════

/// OAuth token stored by Gemini CLI in `~/.gemini/oauth_creds.json`
#[derive(Debug, Deserialize)]
struct GeminiCliOAuthCreds {
    access_token: Option<String>,
    #[serde(alias = "idToken")]
    id_token: Option<String>,
    refresh_token: Option<String>,
    #[serde(alias = "clientId")]
    client_id: Option<String>,
    #[serde(alias = "clientSecret")]
    client_secret: Option<String>,
    /// Unix milliseconds expiry (used by newer Gemini CLI versions).
    #[serde(alias = "expiryDate")]
    expiry_date: Option<i64>,
    /// RFC 3339 expiry string (used by older Gemini CLI versions).
    expiry: Option<String>,
}

// ══════════════════════════════════════════════════════════════════════════════
// GEMINI CLI OAUTH CONSTANTS
// ══════════════════════════════════════════════════════════════════════════════

/// Google OAuth token endpoint.
const GOOGLE_TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";

/// Internal API endpoint used by Gemini CLI for OAuth users.
/// See: <https://github.com/google-gemini/gemini-cli/issues/19200>
const CLOUDCODE_PA_ENDPOINT: &str = "https://cloudcode-pa.googleapis.com/v1internal";

/// loadCodeAssist endpoint for resolving the project ID.
const LOAD_CODE_ASSIST_ENDPOINT: &str =
    "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist";

/// A `loadCodeAssist` project reference: a bare id string, or an object
/// carrying `id` / `projectId`. Google returns either shape depending on
/// whether the account already has an onboarded project.
#[derive(Deserialize)]
#[serde(untagged)]
enum ProjectRef {
    Id(String),
    Object {
        #[serde(alias = "projectId")]
        id: Option<String>,
    },
}

impl ProjectRef {
    fn into_id(self) -> Option<String> {
        match self {
            Self::Id(id) => Some(id),
            Self::Object { id } => id,
        }
        .filter(|p| !p.trim().is_empty())
    }
}

#[derive(Deserialize)]
struct LoadCodeAssistResponse {
    #[serde(rename = "cloudaicompanionProject")]
    cloudaicompanion_project: Option<ProjectRef>,
    #[serde(rename = "currentCloudaicompanionProject")]
    current_cloudaicompanion_project: Option<ProjectRef>,
}

impl LoadCodeAssistResponse {
    fn resolve_project_id(self) -> Option<String> {
        self.cloudaicompanion_project
            .and_then(ProjectRef::into_id)
            .or_else(|| {
                self.current_cloudaicompanion_project
                    .and_then(ProjectRef::into_id)
            })
    }
}

/// Google AI Studio's Gemini endpoint.
pub(crate) const BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";

// ══════════════════════════════════════════════════════════════════════════════
// TOKEN REFRESH
// ══════════════════════════════════════════════════════════════════════════════

/// Result of a successful token refresh.
struct RefreshedToken {
    access_token: String,
    /// Expiry as unix millis (computed from `expires_in` seconds in the response).
    expiry_millis: Option<i64>,
}

fn refresh_gemini_cli_token(
    refresh_token: &str,
    client_id: Option<&str>,
    client_secret: Option<&str>,
) -> anyhow::Result<RefreshedToken> {
    let client = reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(15))
        .connect_timeout(std::time::Duration::from_secs(5))
        .build()
        .unwrap_or_else(|_| reqwest::blocking::Client::new());

    let form = build_oauth_refresh_form(refresh_token, client_id, client_secret);

    let response = client
        .post(GOOGLE_TOKEN_ENDPOINT)
        .header("Content-Type", "application/x-www-form-urlencoded")
        .header("Accept", "application/json")
        .form(&form)
        .send()
        .map_err(|error| {
            ::zeroclaw_log::record!(
                ERROR,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({
                        "oauth_provider": "gemini_cli",
                        "phase": "refresh_request",
                        "error": format!("{}", error),
                    })),
                "gemini: CLI OAuth refresh request failed"
            );
            anyhow::Error::msg(format!("Gemini CLI OAuth refresh request failed: {error}"))
        })?;

    let status = response.status();
    let body = response
        .text()
        .unwrap_or_else(|_| "<failed to read response body>".to_string());

    if !status.is_success() {
        anyhow::bail!("Gemini CLI OAuth refresh failed (HTTP {status}): {body}");
    }

    #[derive(Deserialize)]
    struct TokenResponse {
        access_token: Option<String>,
        expires_in: Option<i64>,
    }

    let parsed: TokenResponse = serde_json::from_str(&body).map_err(|_| {
        ::zeroclaw_log::record!(
            ERROR,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                .with_attrs(::serde_json::json!({"oauth_provider": "gemini_cli"})),
            "gemini: CLI OAuth refresh response is not valid JSON"
        );
        anyhow::Error::msg("Gemini CLI OAuth refresh response is not valid JSON")
    })?;

    let access_token = parsed
        .access_token
        .filter(|t| !t.trim().is_empty())
        .ok_or_else(|| {
            ::zeroclaw_log::record!(
                ERROR,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({
                        "oauth_provider": "gemini_cli",
                        "missing": "access_token",
                    })),
                "gemini: CLI OAuth refresh missing access_token"
            );
            anyhow::Error::msg("Gemini CLI OAuth refresh response missing access_token")
        })?;

    let expiry_millis = parsed.expires_in.and_then(|secs| {
        let now_millis = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .ok()
            .and_then(|d| i64::try_from(d.as_millis()).ok())?;
        now_millis.checked_add(secs.checked_mul(1000)?)
    });

    Ok(RefreshedToken {
        access_token,
        expiry_millis,
    })
}

fn build_oauth_refresh_form(
    refresh_token: &str,
    client_id: Option<&str>,
    client_secret: Option<&str>,
) -> Vec<(&'static str, String)> {
    let mut form = vec![
        ("grant_type", "refresh_token".to_string()),
        ("refresh_token", refresh_token.to_string()),
    ];
    if let Some(id) = client_id.and_then(GeminiModelProvider::normalize_non_empty) {
        form.push(("client_id", id));
    }
    if let Some(secret) = client_secret.and_then(GeminiModelProvider::normalize_non_empty) {
        form.push(("client_secret", secret));
    }
    form
}

fn extract_client_id_from_id_token(id_token: &str) -> Option<String> {
    let payload = id_token.split('.').nth(1)?;
    let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(payload)
        .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload))
        .ok()?;

    #[derive(Deserialize)]
    struct IdTokenClaims {
        aud: Option<String>,
        azp: Option<String>,
    }

    let claims: IdTokenClaims = serde_json::from_slice(&decoded).ok()?;
    claims
        .aud
        .as_deref()
        .and_then(GeminiModelProvider::normalize_non_empty)
        .or_else(|| {
            claims
                .azp
                .as_deref()
                .and_then(GeminiModelProvider::normalize_non_empty)
        })
}

/// Async version of token refresh for use during runtime (inside tokio context).
async fn refresh_gemini_cli_token_async(
    refresh_token: &str,
    client_id: Option<&str>,
    client_secret: Option<&str>,
) -> anyhow::Result<RefreshedToken> {
    let refresh_token = refresh_token.to_string();
    let client_id = client_id.map(str::to_string);
    let client_secret = client_secret.map(str::to_string);
    tokio::task::spawn_blocking(move || {
        refresh_gemini_cli_token(
            &refresh_token,
            client_id.as_deref(),
            client_secret.as_deref(),
        )
    })
    .await
    .map_err(|e| {
        ::zeroclaw_log::record!(
            ERROR,
            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                .with_attrs(::serde_json::json!({
                    "oauth_provider": "gemini_cli",
                    "phase": "task_join",
                    "error": format!("{}", e),
                })),
            "gemini: token refresh task panicked"
        );
        anyhow::Error::msg(format!("Token refresh task panicked: {e}"))
    })?
}

/// Typed builder for [`GeminiModelProvider`].
///
/// Only `alias` is required; every credential input is optional and layered
/// at [`Self::build`] time in this order:
///   1. Explicit API key from [`Self::api_key`]
///   2. Managed OAuth via [`Self::managed_auth`] (when wired and the
///      profile exists)
///   3. CLI OAuth tokens from `~/.gemini/oauth_creds.json`
#[must_use]
pub struct GeminiBuilder {
    alias: String,
    api_key: Option<String>,
    auth_service: Option<AuthService>,
    profile_override: Option<String>,
    oauth_project_seed: Option<String>,
    oauth_client_id: Option<String>,
    oauth_client_secret: Option<String>,
}

impl GeminiBuilder {
    /// Explicit API key (from `[providers.models.gemini.<alias>] api_key`).
    /// When set (and non-empty after trimming), takes precedence over every
    /// OAuth path.
    pub fn api_key(mut self, key: Option<&str>) -> Self {
        self.api_key = key.map(str::to_string);
        self
    }

    /// Wire up managed OAuth via the shared [`AuthService`], with an
    /// optional profile override. When set and no explicit API key is
    /// provided, the builder probes the service at build time; if a
    /// managed profile exists, the resulting provider uses managed OAuth
    /// instead of falling through to the CLI creds.
    pub fn managed_auth(
        mut self,
        auth_service: AuthService,
        profile_override: Option<String>,
    ) -> Self {
        self.auth_service = Some(auth_service);
        self.profile_override = profile_override;
        self
    }

    /// Seed value for the OAuth project resolution cache.
    pub fn oauth_project_seed(mut self, seed: Option<String>) -> Self {
        self.oauth_project_seed = seed;
        self
    }

    /// Override the OAuth client credentials (defaults to the Gemini CLI
    /// public client when unset).
    pub fn oauth_client(
        mut self,
        client_id: Option<String>,
        client_secret: Option<String>,
    ) -> Self {
        self.oauth_client_id = client_id;
        self.oauth_client_secret = client_secret;
        self
    }

    /// Resolve the credential layers and finalize the provider.
    pub fn build(self) -> GeminiModelProvider {
        let oauth_cred_paths = GeminiModelProvider::discover_oauth_cred_paths();

        // Layer 1 — explicit API key.
        let explicit_key = self
            .api_key
            .as_deref()
            .and_then(GeminiModelProvider::normalize_non_empty)
            .map(GeminiAuth::ExplicitKey);

        // Layer 3 — CLI OAuth fallback. Used both when no managed service is
        // wired at all, and when a managed service was wired but no profile
        // was found on disk.
        let load_cli_oauth = || {
            GeminiModelProvider::try_load_gemini_cli_token(oauth_cred_paths.first())
                .map(|state| GeminiAuth::OAuthToken(Arc::new(tokio::sync::Mutex::new(state))))
        };

        // Layer 2 — managed OAuth (only probed when an AuthService is wired
        // and no explicit key beat it).
        let (auth, use_managed) = match (explicit_key, self.auth_service.as_ref()) {
            (Some(a), _) => (Some(a), false),
            (None, Some(service)) => {
                let profile = self.profile_override.clone();
                let has_managed = std::thread::scope(|s| {
                    let service = service.clone();
                    s.spawn(move || {
                        let rt = tokio::runtime::Builder::new_current_thread()
                            .enable_all()
                            .build()
                            .ok()?;
                        rt.block_on(async {
                            service
                                .get_gemini_profile(profile.as_deref())
                                .await
                                .ok()
                                .flatten()
                        })
                    })
                    .join()
                    .ok()
                    .flatten()
                    .is_some()
                });
                if has_managed {
                    (Some(GeminiAuth::ManagedOAuth), true)
                } else {
                    (load_cli_oauth(), false)
                }
            }
            (None, None) => (load_cli_oauth(), false),
        };

        GeminiModelProvider {
            alias: self.alias,
            auth,
            oauth_project: Arc::new(tokio::sync::Mutex::new(None)),
            oauth_project_seed: self.oauth_project_seed,
            oauth_cred_paths,
            oauth_index: Arc::new(tokio::sync::Mutex::new(0)),
            auth_service: if use_managed { self.auth_service } else { None },
            auth_profile_override: self.profile_override,
            oauth_client_id: self.oauth_client_id,
            oauth_client_secret: self.oauth_client_secret,
        }
    }
}

impl GeminiModelProvider {
    /// Entry point for constructing a Gemini provider. Only `alias` is
    /// required; layer optional credential inputs onto the returned
    /// [`GeminiBuilder`].
    ///
    /// Authentication priority (evaluated at build time):
    /// 1. Explicit API key ([`GeminiBuilder::api_key`], from
    ///    `[providers.models.gemini.<alias>] api_key`)
    /// 2. Managed OAuth via [`GeminiBuilder::managed_auth`], when wired
    ///    and a profile exists
    /// 3. Gemini CLI OAuth tokens (`~/.gemini/oauth_creds.json`)
    pub fn builder(alias: &str) -> GeminiBuilder {
        GeminiBuilder {
            alias: alias.to_string(),
            api_key: None,
            auth_service: None,
            profile_override: None,
            oauth_project_seed: None,
            oauth_client_id: None,
            oauth_client_secret: None,
        }
    }

    fn normalize_non_empty(value: &str) -> Option<String> {
        let trimmed = value.trim();
        if trimmed.is_empty() {
            None
        } else {
            Some(trimmed.to_string())
        }
    }

    fn load_gemini_cli_creds(creds_path: &PathBuf) -> Option<GeminiCliOAuthCreds> {
        if !creds_path.exists() {
            return None;
        }
        let content = std::fs::read_to_string(creds_path).ok()?;
        serde_json::from_str(&content).ok()
    }

    fn discover_oauth_cred_paths() -> Vec<PathBuf> {
        let home = match UserDirs::new() {
            Some(u) => u.home_dir().to_path_buf(),
            None => return Vec::new(),
        };

        let mut paths = Vec::new();

        let primary = home.join(".gemini").join("oauth_creds.json");
        if primary.exists() {
            paths.push(primary);
        }

        if let Ok(entries) = std::fs::read_dir(&home) {
            let mut extras: Vec<PathBuf> = entries
                .filter_map(|e| e.ok())
                .filter_map(|e| {
                    let name = e.file_name().to_string_lossy().to_string();
                    if name.starts_with(".gemini-") && name.ends_with("-home") {
                        let path = e.path().join(".gemini").join("oauth_creds.json");
                        if path.exists() {
                            return Some(path);
                        }
                    }
                    None
                })
                .collect();
            extras.sort();
            paths.extend(extras);
        }

        paths
    }

    /// Try to load OAuth credentials from Gemini CLI's cached credentials.
    /// Location: `~/.gemini/oauth_creds.json`
    /// Returns the full `OAuthTokenState` so the model_provider can refresh at runtime.
    fn try_load_gemini_cli_token(path: Option<&PathBuf>) -> Option<OAuthTokenState> {
        let creds = Self::load_gemini_cli_creds(path?)?;

        // Determine expiry in millis: prefer expiry_date over expiry (RFC 3339)
        let expiry_millis = creds.expiry_date.or_else(|| {
            creds.expiry.as_deref().and_then(|expiry| {
                chrono::DateTime::parse_from_rfc3339(expiry)
                    .ok()
                    .map(|dt| dt.timestamp_millis())
            })
        });

        let access_token = creds
            .access_token
            .and_then(|token| Self::normalize_non_empty(&token))?;

        let id_token_client_id = creds
            .id_token
            .as_deref()
            .and_then(extract_client_id_from_id_token);

        let client_id = creds
            .client_id
            .as_deref()
            .and_then(Self::normalize_non_empty)
            .or(id_token_client_id);
        let client_secret = creds
            .client_secret
            .as_deref()
            .and_then(Self::normalize_non_empty);

        Some(OAuthTokenState {
            access_token,
            refresh_token: creds.refresh_token,
            client_id,
            client_secret,
            expiry_millis,
        })
    }

    /// Check if Gemini CLI is configured and has valid credentials
    pub fn has_cli_credentials() -> bool {
        Self::discover_oauth_cred_paths().iter().any(|path| {
            Self::load_gemini_cli_creds(path)
                .and_then(|creds| {
                    creds
                        .access_token
                        .as_deref()
                        .and_then(Self::normalize_non_empty)
                })
                .is_some()
        })
    }

    /// Check if any Gemini authentication is available via the Gemini CLI
    /// OAuth credential cache. Per-alias config-supplied keys are tracked
    /// separately on the constructed provider, so this helper no longer
    /// reads process env.
    pub fn has_any_auth() -> bool {
        Self::has_cli_credentials()
    }

    /// Get authentication source description for diagnostics.
    /// Uses the stored enum variant — no env var re-reading at call time.
    pub fn auth_source(&self) -> &'static str {
        match self.auth.as_ref() {
            Some(GeminiAuth::ExplicitKey(_)) => "config",
            Some(GeminiAuth::OAuthToken(_)) => "Gemini CLI OAuth",
            Some(GeminiAuth::ManagedOAuth) => "auth-profiles",
            None => "none",
        }
    }

    /// Get a valid OAuth access token, refreshing if expired.
    /// Adds a 60-second buffer before actual expiry to avoid edge-case failures.
    async fn get_valid_oauth_token(
        state: &Arc<tokio::sync::Mutex<OAuthTokenState>>,
    ) -> anyhow::Result<String> {
        let mut guard = state.lock().await;

        let now_millis = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .ok()
            .and_then(|d| i64::try_from(d.as_millis()).ok())
            .unwrap_or(i64::MAX);

        // Refresh if expiry is unknown, already expired, or within 60s of expiry.
        let needs_refresh = guard
            .expiry_millis
            .is_none_or(|exp| exp <= now_millis.saturating_add(60_000));

        if needs_refresh {
            if let Some(ref refresh_token) = guard.refresh_token {
                let refreshed = refresh_gemini_cli_token_async(
                    refresh_token,
                    guard.client_id.as_deref(),
                    guard.client_secret.as_deref(),
                )
                .await?;
                ::zeroclaw_log::record!(
                    INFO,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
                    "Gemini CLI OAuth token refreshed successfully (runtime)"
                );
                guard.access_token = refreshed.access_token;
                guard.expiry_millis = refreshed.expiry_millis;
            } else {
                anyhow::bail!(
                    "Gemini CLI OAuth token expired and no refresh_token available — re-run `gemini` to authenticate"
                );
            }
        }

        Ok(guard.access_token.clone())
    }

    /// Rotate to the next available OAuth credentials file and swap state.
    /// Returns `true` when rotation succeeded.
    async fn rotate_oauth_credential(
        &self,
        state: &Arc<tokio::sync::Mutex<OAuthTokenState>>,
    ) -> bool {
        if self.oauth_cred_paths.len() <= 1 {
            return false;
        }

        let mut idx = self.oauth_index.lock().await;
        let start = *idx;

        loop {
            let next = (*idx + 1) % self.oauth_cred_paths.len();
            *idx = next;

            if next == start {
                return false;
            }

            if let Some(next_state) =
                Self::try_load_gemini_cli_token(self.oauth_cred_paths.get(next))
            {
                {
                    let mut guard = state.lock().await;
                    *guard = next_state;
                }
                {
                    let mut cached_project = self.oauth_project.lock().await;
                    *cached_project = None;
                }
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                    &format!(
                        "Gemini OAuth: rotated credential to {}",
                        self.oauth_cred_paths[next].display().to_string()
                    )
                );
                return true;
            }
        }
    }

    fn format_model_name(model: &str) -> String {
        if model.starts_with("models/") {
            model.to_string()
        } else {
            format!("models/{model}")
        }
    }

    fn format_internal_model_name(model: &str) -> String {
        model.strip_prefix("models/").unwrap_or(model).to_string()
    }

    fn build_generate_content_url(model: &str, auth: &GeminiAuth) -> String {
        match auth {
            GeminiAuth::OAuthToken(_) | GeminiAuth::ManagedOAuth => {
                // OAuth tokens are scoped for the internal Code Assist API.
                // The model is passed in the request body, not the URL path.
                format!("{CLOUDCODE_PA_ENDPOINT}:generateContent")
            }
            _ => {
                let model_name = Self::format_model_name(model);
                let base_url = format!("{BASE_URL}/{model_name}:generateContent");

                if auth.is_api_key() {
                    format!("{base_url}?key={}", auth.api_key_credential())
                } else {
                    base_url
                }
            }
        }
    }

    fn http_client(&self) -> Client {
        zeroclaw_config::schema::build_runtime_proxy_client_with_timeouts(
            "model_provider.gemini",
            120,
            10,
        )
    }

    /// Resolve the GCP project ID for OAuth by calling the loadCodeAssist endpoint.
    /// Caches the result for subsequent calls.
    async fn resolve_oauth_project(&self, token: &str) -> anyhow::Result<String> {
        let project_seed = self.oauth_project_seed.clone();
        let project_seed_for_request = project_seed.clone();
        let duet_project_for_request = project_seed.clone();

        // Check cache first
        {
            let cached = self.oauth_project.lock().await;
            if let Some(ref project) = *cached {
                return Ok(project.clone());
            }
        }

        // Call loadCodeAssist
        let client = self.http_client();
        let response = client
            .post(LOAD_CODE_ASSIST_ENDPOINT)
            .bearer_auth(token)
            .json(&serde_json::json!({
                "cloudaicompanionProject": project_seed_for_request,
                "metadata": {
                    "ideType": "GEMINI_CLI",
                    "platform": "PLATFORM_UNSPECIFIED",
                    "pluginType": "GEMINI",
                    "duetProject": duet_project_for_request,
                }
            }))
            .send()
            .await?;

        if !response.status().is_success() {
            let status = response.status();
            let body = response.text().await.unwrap_or_default();
            if let Some(seed) = project_seed {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                        .with_attrs(::serde_json::json!({"status": status.to_string()})),
                    "loadCodeAssist failed (HTTP ); using oauth_project seed fallback"
                );
                return Ok(seed);
            }
            anyhow::bail!("loadCodeAssist failed (HTTP {status}): {body}");
        }

        let result: LoadCodeAssistResponse = response.json().await?;
        let project = result
            .resolve_project_id()
            .or(project_seed)
            .ok_or_else(|| {
                ::zeroclaw_log::record!(
                    ERROR,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_attrs(::serde_json::json!({
                            "missing": "cloudaicompanionProject",
                        })),
                    "gemini: loadCodeAssist missing project context"
                );
                anyhow::Error::msg("loadCodeAssist response missing project context")
            })?;

        // Cache for future calls
        {
            let mut cached = self.oauth_project.lock().await;
            *cached = Some(project.clone());
        }

        Ok(project)
    }

    /// Build the HTTP request for generateContent.
    /// For OAuth, pass the resolved `oauth_token` and `project`.
    /// For API key, both are `None`.
    fn build_generate_content_request(
        &self,
        auth: &GeminiAuth,
        url: &str,
        request: &GenerateContentRequest,
        model: &str,
        include_generation_config: bool,
        project: Option<&str>,
        oauth_token: Option<&str>,
    ) -> reqwest::RequestBuilder {
        let req = self.http_client().post(url).json(request);
        match auth {
            GeminiAuth::OAuthToken(_) | GeminiAuth::ManagedOAuth => {
                let token = oauth_token.unwrap_or_default();
                // Internal Code Assist API uses a wrapped payload shape:
                // { model, project?, user_prompt_id?, request: { contents, systemInstruction?, generationConfig } }
                let internal_request = InternalGenerateContentEnvelope {
                    model: Self::format_internal_model_name(model),
                    project: project.map(|value| value.to_string()),
                    user_prompt_id: Some(uuid::Uuid::new_v4().to_string()),
                    request: InternalGenerateContentRequest {
                        contents: request.contents.clone(),
                        system_instruction: request.system_instruction.clone(),
                        generation_config: if include_generation_config {
                            Some(request.generation_config.clone())
                        } else {
                            None
                        },
                    },
                };
                self.http_client()
                    .post(url)
                    .json(&internal_request)
                    .bearer_auth(token)
            }
            _ => req,
        }
    }

    fn should_retry_oauth_without_generation_config(
        status: reqwest::StatusCode,
        error_text: &str,
    ) -> bool {
        if status != reqwest::StatusCode::BAD_REQUEST {
            return false;
        }

        error_text.contains("Unknown name \"generationConfig\"")
            || error_text.contains("Unknown name 'generationConfig'")
            || error_text.contains(r#"Unknown name \"generationConfig\""#)
    }

    fn should_rotate_oauth_on_error(status: reqwest::StatusCode, error_text: &str) -> bool {
        status == reqwest::StatusCode::TOO_MANY_REQUESTS
            || status == reqwest::StatusCode::SERVICE_UNAVAILABLE
            || status.is_server_error()
            || error_text.contains("RESOURCE_EXHAUSTED")
    }
}

impl GeminiModelProvider {
    fn build_chat_contents(
        messages: &[ChatMessage],
        tool_instructions: Option<&str>,
    ) -> (Vec<Content>, Option<Content>) {
        let mut system_parts: Vec<&str> = Vec::new();
        let mut contents: Vec<Content> = Vec::new();
        for msg in messages {
            match msg.role.as_str() {
                "system" => system_parts.push(&msg.content),
                "user" => contents.push(Content {
                    role: Some("user".to_string()),
                    parts: build_parts(&msg.content),
                }),
                "assistant" => contents.push(Content {
                    role: Some("model".to_string()),
                    parts: vec![Part::text(&msg.content)],
                }),
                _ => {}
            }
        }
        if let Some(instructions) = tool_instructions {
            system_parts.push(instructions);
        }
        let system_instruction = if system_parts.is_empty() {
            None
        } else {
            Some(Content {
                role: None,
                parts: vec![Part::text(system_parts.join("\n\n"))],
            })
        };
        (contents, system_instruction)
    }

    async fn chat_with_history_full(
        &self,
        messages: &[ChatMessage],
        model: &str,
        temperature: Option<f64>,
    ) -> anyhow::Result<(String, Option<TokenUsage>)> {
        let (contents, system_instruction) = Self::build_chat_contents(messages, None);
        self.send_generate_content(contents, system_instruction, model, temperature)
            .await
    }

    fn token_usage_from_metadata(usage: GeminiUsageMetadata) -> Option<TokenUsage> {
        if usage.prompt_token_count.is_none() && usage.candidates_token_count.is_none() {
            return None;
        }
        Some(TokenUsage {
            input_tokens: usage.prompt_token_count,
            output_tokens: usage.candidates_token_count,
            cached_input_tokens: None,
        })
    }

    async fn send_generate_content(
        &self,
        contents: Vec<Content>,
        system_instruction: Option<Content>,
        model: &str,
        temperature: Option<f64>,
    ) -> anyhow::Result<(String, Option<TokenUsage>)> {
        let auth = self.auth.as_ref().ok_or_else(|| {
            ::zeroclaw_log::record!(
                ERROR,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({"missing": "auth"})),
                "gemini: no auth configured"
            );
            anyhow::Error::msg(
                "Gemini API key not found. Options:\n\
                 1. Set GEMINI_API_KEY env var\n\
                 2. Run `gemini` CLI to authenticate (tokens will be reused)\n\
                 3. Run `zeroclaw auth login --model-provider gemini`\n\
                 4. Get an API key from https://aistudio.google.com/app/apikey\n\
                 5. Run `zeroclaw quickstart --model-provider gemini --api-key <key>` to configure",
            )
        })?;

        let oauth_state = match auth {
            GeminiAuth::OAuthToken(state) => Some(state.clone()),
            _ => None,
        };

        // For OAuth: get a valid (potentially refreshed) token and resolve project
        let (mut oauth_token, mut project) = match auth {
            GeminiAuth::OAuthToken(state) => {
                let token = Self::get_valid_oauth_token(state).await?;
                let proj = self.resolve_oauth_project(&token).await?;
                (Some(token), Some(proj))
            }
            GeminiAuth::ManagedOAuth => {
                let auth_service = self.auth_service.as_ref().ok_or_else(|| {
                    ::zeroclaw_log::record!(
                        ERROR,
                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                            .with_attrs(::serde_json::json!({"missing": "auth_service"})),
                        "gemini: ManagedOAuth requires auth_service"
                    );
                    anyhow::Error::msg("ManagedOAuth requires auth_service")
                })?;
                let token = auth_service
                    .get_valid_gemini_access_token(
                        self.auth_profile_override.as_deref(),
                        self.oauth_client_id.as_deref().unwrap_or(""),
                        self.oauth_client_secret.as_deref().unwrap_or(""),
                    )
                    .await?
                    .ok_or_else(|| {
                        ::zeroclaw_log::record!(
                            ERROR,
                            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                                .with_attrs(::serde_json::json!({"oauth_provider": "gemini"})),
                            "gemini: auth profile not found"
                        );
                        anyhow::Error::msg(
                            "Gemini auth profile not found. Run `zeroclaw auth login --model-provider gemini`.",
                        )
                    })?;
                let proj = self.resolve_oauth_project(&token).await?;
                (Some(token), Some(proj))
            }
            _ => (None, None),
        };

        let request = GenerateContentRequest {
            contents,
            system_instruction,
            generation_config: GenerationConfig {
                temperature,
                max_output_tokens: 8192,
            },
        };

        let url = Self::build_generate_content_url(model, auth);

        let mut response = self
            .build_generate_content_request(
                auth,
                &url,
                &request,
                model,
                true,
                project.as_deref(),
                oauth_token.as_deref(),
            )
            .send()
            .await?;

        if !response.status().is_success() {
            let status = response.status();
            let error_text = response.text().await.unwrap_or_default();

            if auth.is_oauth() && Self::should_rotate_oauth_on_error(status, &error_text) {
                // For CLI OAuth: rotate credentials
                // For ManagedOAuth: AuthService handles refresh, just retry
                let can_retry = match auth {
                    GeminiAuth::OAuthToken(_) => {
                        if let Some(state) = oauth_state.as_ref() {
                            self.rotate_oauth_credential(state).await
                        } else {
                            false
                        }
                    }
                    GeminiAuth::ManagedOAuth => true, // AuthService refreshes automatically
                    _ => false,
                };

                if can_retry {
                    // Re-fetch token (may be refreshed)
                    let (new_token, new_project) = match auth {
                        GeminiAuth::OAuthToken(state) => {
                            let token = Self::get_valid_oauth_token(state).await?;
                            let proj = self.resolve_oauth_project(&token).await?;
                            (token, proj)
                        }
                        GeminiAuth::ManagedOAuth => {
                            let auth_service = self.auth_service.as_ref().unwrap();
                            let token = auth_service
                                .get_valid_gemini_access_token(
                                    self.auth_profile_override.as_deref(),
                                    self.oauth_client_id.as_deref().unwrap_or(""),
                                    self.oauth_client_secret.as_deref().unwrap_or(""),
                                )
                                .await?
                                .ok_or_else(|| {
                                    ::zeroclaw_log::record!(
                                        ERROR,
                                        ::zeroclaw_log::Event::new(
                                            module_path!(),
                                            ::zeroclaw_log::Action::Reject
                                        )
                                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                                        .with_attrs(
                                            ::serde_json::json!({"oauth_provider": "gemini"})
                                        ),
                                        "gemini: auth profile not found"
                                    );
                                    anyhow::Error::msg("Gemini auth profile not found")
                                })?;
                            let proj = self.resolve_oauth_project(&token).await?;
                            (token, proj)
                        }
                        _ => unreachable!(),
                    };
                    oauth_token = Some(new_token);
                    project = Some(new_project);
                    response = self
                        .build_generate_content_request(
                            auth,
                            &url,
                            &request,
                            model,
                            true,
                            project.as_deref(),
                            oauth_token.as_deref(),
                        )
                        .send()
                        .await?;
                } else {
                    anyhow::bail!("Gemini API error ({status}): {error_text}");
                }
            } else if auth.is_oauth()
                && Self::should_retry_oauth_without_generation_config(status, &error_text)
            {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                    "Gemini OAuth internal endpoint rejected generationConfig; retrying without generationConfig"
                );
                response = self
                    .build_generate_content_request(
                        auth,
                        &url,
                        &request,
                        model,
                        false,
                        project.as_deref(),
                        oauth_token.as_deref(),
                    )
                    .send()
                    .await?;
            } else {
                anyhow::bail!("Gemini API error ({status}): {error_text}");
            }
        }

        if !response.status().is_success() {
            let status = response.status();
            let error_text = response.text().await.unwrap_or_default();
            if auth.is_oauth()
                && Self::should_retry_oauth_without_generation_config(status, &error_text)
            {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                    "Gemini OAuth internal endpoint rejected generationConfig; retrying without generationConfig"
                );
                response = self
                    .build_generate_content_request(
                        auth,
                        &url,
                        &request,
                        model,
                        false,
                        project.as_deref(),
                        oauth_token.as_deref(),
                    )
                    .send()
                    .await?;
            } else {
                anyhow::bail!("Gemini API error ({status}): {error_text}");
            }
        }

        if !response.status().is_success() {
            let status = response.status();
            let error_text = response.text().await.unwrap_or_default();
            anyhow::bail!("Gemini API error ({status}): {error_text}");
        }

        let result: GenerateContentResponse = response.json().await?;
        if let Some(err) = &result.error {
            anyhow::bail!("Gemini API error: {}", err.message);
        }
        let result = result.into_effective_response();
        if let Some(err) = result.error {
            anyhow::bail!("Gemini API error: {}", err.message);
        }

        let usage = result
            .usage_metadata
            .and_then(Self::token_usage_from_metadata);

        let text = result
            .candidates
            .and_then(|c| c.into_iter().next())
            .and_then(|c| c.content)
            .and_then(|c| c.effective_text())
            .ok_or_else(|| {
                ::zeroclaw_log::record!(
                    ERROR,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
                    "gemini: empty response text"
                );
                anyhow::Error::msg("No response from Gemini")
            })?;

        Ok((text, usage))
    }
}

#[async_trait]
impl ModelProvider for GeminiModelProvider {
    // ── ModelProvider-family defaults ──
    fn default_base_url(&self) -> Option<&str> {
        Some(BASE_URL)
    }

    fn capabilities(&self) -> zeroclaw_api::model_provider::ProviderCapabilities {
        zeroclaw_api::model_provider::ProviderCapabilities {
            vision: true,
            native_tool_calling: false,
            prompt_caching: false,
            extended_thinking: false,
        }
    }

    async fn chat_with_system(
        &self,
        system_prompt: Option<&str>,
        message: &str,
        model: &str,
        temperature: Option<f64>,
    ) -> anyhow::Result<String> {
        let system_instruction = system_prompt.map(|sys| Content {
            role: None,
            parts: vec![Part::text(sys)],
        });

        let contents = vec![Content {
            role: Some("user".to_string()),
            parts: build_parts(message),
        }];

        let (text, _usage) = self
            .send_generate_content(contents, system_instruction, model, temperature)
            .await?;
        Ok(text)
    }

    async fn chat_with_history(
        &self,
        messages: &[ChatMessage],
        model: &str,
        temperature: Option<f64>,
    ) -> anyhow::Result<String> {
        let (text, _usage) = self
            .chat_with_history_full(messages, model, temperature)
            .await?;
        Ok(text)
    }

    async fn chat(
        &self,
        request: ProviderChatRequest<'_>,
        model: &str,
        temperature: Option<f64>,
    ) -> anyhow::Result<ProviderChatResponse> {
        let tool_instructions = if let Some(tools) = request.tools
            && !tools.is_empty()
            && !self.supports_native_tools()
        {
            Some(match self.convert_tools(tools) {
                ToolsPayload::PromptGuided { instructions } => instructions,
                payload => {
                    anyhow::bail!(
                        "Provider returned non-prompt-guided tools payload ({payload:?}) while supports_native_tools() is false"
                    )
                }
            })
        } else {
            None
        };
        let (contents, system_instruction) =
            Self::build_chat_contents(request.messages, tool_instructions.as_deref());
        let (text, usage) = self
            .send_generate_content(contents, system_instruction, model, temperature)
            .await?;
        Ok(ProviderChatResponse {
            text: Some(text),
            tool_calls: Vec::new(),
            usage,
            reasoning_content: None,
        })
    }

    async fn warmup(&self) -> anyhow::Result<()> {
        if let Some(auth) = self.auth.as_ref() {
            match auth {
                GeminiAuth::ManagedOAuth => {
                    // For ManagedOAuth, verify and refresh the token if needed.
                    // This ensures fallback works even if tokens expired during daemon uptime.
                    let auth_service = self.auth_service.as_ref().ok_or_else(|| {
                        ::zeroclaw_log::record!(
                            ERROR,
                            ::zeroclaw_log::Event::new(
                                module_path!(),
                                ::zeroclaw_log::Action::Reject
                            )
                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                            .with_attrs(::serde_json::json!({"missing": "auth_service"})),
                            "gemini: ManagedOAuth requires auth_service"
                        );
                        anyhow::Error::msg("ManagedOAuth requires auth_service")
                    })?;

                    let _token = auth_service
                        .get_valid_gemini_access_token(
                            self.auth_profile_override.as_deref(),
                            self.oauth_client_id.as_deref().unwrap_or(""),
                            self.oauth_client_secret.as_deref().unwrap_or(""),
                        )
                        .await?
                        .ok_or_else(|| {
                            ::zeroclaw_log::record!(
                                ERROR,
                                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                                    .with_attrs(::serde_json::json!({"oauth_provider": "gemini"})),
                                "gemini: auth profile not found or expired"
                            );
                            anyhow::Error::msg(
                                "Gemini auth profile not found or expired. Run: zeroclaw auth login --model-provider gemini",
                            )
                        })?;

                    // Token refresh happens in get_valid_gemini_access_token().
                    // We don't call resolve_oauth_project() here to keep warmup fast.
                    // OAuth project will be resolved lazily on first real request.
                }
                GeminiAuth::OAuthToken(_) => {
                    // CLI OAuth — cloudcode-pa does not expose a lightweight model-list probe.
                    // Token will be validated on first real request.
                }
                _ => {
                    // API key path — verify with public API models endpoint.
                    let url = if auth.is_api_key() {
                        format!(
                            "https://generativelanguage.googleapis.com/v1beta/models?key={}",
                            auth.api_key_credential()
                        )
                    } else {
                        "https://generativelanguage.googleapis.com/v1beta/models".to_string()
                    };

                    self.http_client()
                        .get(&url)
                        .send()
                        .await?
                        .error_for_status()?;
                }
            }
        }
        Ok(())
    }

    async fn list_models(&self) -> anyhow::Result<Vec<String>> {
        // Gemini's /v1beta/models requires ?key=<api_key>. Onboard pulls the
        // catalog from models.dev before the user has entered a key.
        crate::models_dev::list_models_for("google").await
    }
}

impl ::zeroclaw_api::attribution::Attributable for GeminiModelProvider {
    fn role(&self) -> ::zeroclaw_api::attribution::Role {
        ::zeroclaw_api::attribution::Role::Provider(
            ::zeroclaw_api::attribution::ProviderKind::Model(
                ::zeroclaw_api::attribution::ModelProviderKind::Gemini,
            ),
        )
    }
    fn alias(&self) -> &str {
        &self.alias
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use reqwest::{StatusCode, header::AUTHORIZATION};

    fn test_oauth_auth(token: &str) -> GeminiAuth {
        GeminiAuth::OAuthToken(Arc::new(tokio::sync::Mutex::new(OAuthTokenState {
            access_token: token.to_string(),
            refresh_token: None,
            client_id: None,
            client_secret: None,
            expiry_millis: None,
        })))
    }

    fn test_model_provider(auth: Option<GeminiAuth>) -> GeminiModelProvider {
        GeminiModelProvider {
            alias: "test".to_string(),
            auth,
            oauth_project: Arc::new(tokio::sync::Mutex::new(None)),
            oauth_project_seed: None,
            oauth_cred_paths: Vec::new(),
            oauth_index: Arc::new(tokio::sync::Mutex::new(0)),
            auth_service: None,
            auth_profile_override: None,
            oauth_client_id: None,
            oauth_client_secret: None,
        }
    }

    #[test]
    fn normalize_non_empty_trims_and_filters() {
        assert_eq!(
            GeminiModelProvider::normalize_non_empty(" value "),
            Some("value".into())
        );
        assert_eq!(GeminiModelProvider::normalize_non_empty(""), None);
        assert_eq!(GeminiModelProvider::normalize_non_empty(" \t\n"), None);
    }

    fn resolve(body: &str) -> Option<String> {
        serde_json::from_str::<LoadCodeAssistResponse>(body)
            .unwrap()
            .resolve_project_id()
    }

    #[test]
    fn load_code_assist_reads_bare_string_project() {
        assert_eq!(
            resolve(r#"{"cloudaicompanionProject":"my-proj"}"#),
            Some("my-proj".into())
        );
    }

    #[test]
    fn load_code_assist_reads_object_id_and_project_id() {
        assert_eq!(
            resolve(r#"{"cloudaicompanionProject":{"id":"obj-proj"}}"#),
            Some("obj-proj".into())
        );
        assert_eq!(
            resolve(r#"{"cloudaicompanionProject":{"projectId":"alias-proj"}}"#),
            Some("alias-proj".into())
        );
    }

    #[test]
    fn load_code_assist_falls_back_to_current_project() {
        assert_eq!(
            resolve(r#"{"currentCloudaicompanionProject":"current-proj"}"#),
            Some("current-proj".into())
        );
        assert_eq!(
            resolve(r#"{"currentCloudaicompanionProject":{"id":"current-obj"}}"#),
            Some("current-obj".into())
        );
        assert_eq!(
            resolve(r#"{"currentCloudaicompanionProject":{"projectId":"current-alias"}}"#),
            Some("current-alias".into())
        );
    }

    #[test]
    fn load_code_assist_prefers_primary_over_current() {
        assert_eq!(
            resolve(
                r#"{"cloudaicompanionProject":"primary","currentCloudaicompanionProject":"current"}"#
            ),
            Some("primary".into())
        );
    }

    #[test]
    fn load_code_assist_skips_blank_primary_for_current() {
        assert_eq!(
            resolve(
                r#"{"cloudaicompanionProject":"  ","currentCloudaicompanionProject":"current"}"#
            ),
            Some("current".into())
        );
    }

    #[test]
    fn load_code_assist_none_when_no_project_context() {
        assert_eq!(resolve(r#"{}"#), None);
        assert_eq!(resolve(r#"{"cloudaicompanionProject":{"id":null}}"#), None);
        assert_eq!(resolve(r#"{"cloudaicompanionProject":""}"#), None);
    }

    #[test]
    fn oauth_refresh_form_uses_provided_client_credentials() {
        let form = build_oauth_refresh_form("refresh-token", Some("client-id"), Some("secret"));
        let map: std::collections::HashMap<_, _> = form.into_iter().collect();
        assert_eq!(map.get("grant_type"), Some(&"refresh_token".to_string()));
        assert_eq!(map.get("refresh_token"), Some(&"refresh-token".to_string()));
        assert_eq!(map.get("client_id"), Some(&"client-id".to_string()));
        assert_eq!(map.get("client_secret"), Some(&"secret".to_string()));
    }

    #[test]
    fn oauth_refresh_form_omits_client_credentials_when_missing() {
        let form = build_oauth_refresh_form("refresh-token", None, None);
        let map: std::collections::HashMap<_, _> = form.into_iter().collect();
        assert!(!map.contains_key("client_id"));
        assert!(!map.contains_key("client_secret"));
    }

    #[test]
    fn extract_client_id_from_id_token_prefers_aud_claim() {
        let payload = serde_json::json!({
            "aud": "aud-client-id",
            "azp": "azp-client-id"
        });
        let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
            .encode(serde_json::to_vec(&payload).unwrap());
        let token = format!("header.{payload_b64}.sig");

        assert_eq!(
            extract_client_id_from_id_token(&token),
            Some("aud-client-id".to_string())
        );
    }

    #[test]
    fn extract_client_id_from_id_token_uses_azp_when_aud_missing() {
        let payload = serde_json::json!({
            "azp": "azp-client-id"
        });
        let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
            .encode(serde_json::to_vec(&payload).unwrap());
        let token = format!("header.{payload_b64}.sig");

        assert_eq!(
            extract_client_id_from_id_token(&token),
            Some("azp-client-id".to_string())
        );
    }

    #[test]
    fn extract_client_id_from_id_token_returns_none_for_invalid_tokens() {
        assert_eq!(extract_client_id_from_id_token("invalid"), None);
        assert_eq!(extract_client_id_from_id_token("a.b.c"), None);
    }

    #[test]
    fn try_load_cli_token_derives_client_id_from_id_token_when_missing() {
        let payload = serde_json::json!({ "aud": "derived-client-id" });
        let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
            .encode(serde_json::to_vec(&payload).unwrap());
        let id_token = format!("header.{payload_b64}.sig");

        let file = tempfile::NamedTempFile::new().unwrap();
        let json = format!(
            r#"{{
                "access_token": "ya29.test-access",
                "refresh_token": "1//test-refresh",
                "id_token": "{id_token}"
            }}"#
        );
        std::fs::write(file.path(), json).unwrap();

        let path = file.path().to_path_buf();
        let state = GeminiModelProvider::try_load_gemini_cli_token(Some(&path)).unwrap();
        assert_eq!(state.client_id.as_deref(), Some("derived-client-id"));
        assert_eq!(state.client_secret, None);
    }

    #[test]
    fn provider_creates_without_key() {
        let model_provider = GeminiModelProvider::builder("test").build();
        // May pick up env vars; just verify it doesn't panic
        let _ = model_provider.auth_source();
    }

    #[test]
    fn provider_creates_with_key() {
        let model_provider = GeminiModelProvider::builder("test")
            .api_key(Some("test-api-key"))
            .build();
        assert!(matches!(
            model_provider.auth,
            Some(GeminiAuth::ExplicitKey(ref key)) if key == "test-api-key"
        ));
    }

    #[test]
    fn provider_rejects_empty_key() {
        let model_provider = GeminiModelProvider::builder("test")
            .api_key(Some(""))
            .build();
        assert!(!matches!(
            model_provider.auth,
            Some(GeminiAuth::ExplicitKey(_))
        ));
    }

    #[test]
    fn auth_source_explicit_key() {
        let model_provider = test_model_provider(Some(GeminiAuth::ExplicitKey("key".into())));
        assert_eq!(model_provider.auth_source(), "config");
    }

    #[test]
    fn auth_source_none_without_credentials() {
        let model_provider = test_model_provider(None);
        assert_eq!(model_provider.auth_source(), "none");
    }

    #[test]
    fn auth_source_oauth() {
        let model_provider = test_model_provider(Some(test_oauth_auth("ya29.mock")));
        assert_eq!(model_provider.auth_source(), "Gemini CLI OAuth");
    }

    #[test]
    fn model_name_formatting() {
        assert_eq!(
            GeminiModelProvider::format_model_name("gemini-2.0-flash"),
            "models/gemini-2.0-flash"
        );
        assert_eq!(
            GeminiModelProvider::format_model_name("models/gemini-1.5-pro"),
            "models/gemini-1.5-pro"
        );
        assert_eq!(
            GeminiModelProvider::format_internal_model_name("models/gemini-2.5-flash"),
            "gemini-2.5-flash"
        );
        assert_eq!(
            GeminiModelProvider::format_internal_model_name("gemini-2.5-flash"),
            "gemini-2.5-flash"
        );
    }

    #[test]
    fn api_key_url_includes_key_query_param() {
        let auth = GeminiAuth::ExplicitKey("api-key-123".into());
        let url = GeminiModelProvider::build_generate_content_url("gemini-2.0-flash", &auth);
        assert!(url.contains(":generateContent?key=api-key-123"));
    }

    #[test]
    fn oauth_url_uses_internal_endpoint() {
        let auth = test_oauth_auth("ya29.test-token");
        let url = GeminiModelProvider::build_generate_content_url("gemini-2.0-flash", &auth);
        assert!(url.starts_with("https://cloudcode-pa.googleapis.com/v1internal"));
        assert!(url.ends_with(":generateContent"));
        assert!(!url.contains("generativelanguage.googleapis.com"));
        assert!(!url.contains("?key="));
    }

    #[test]
    fn api_key_url_uses_public_endpoint() {
        let auth = GeminiAuth::ExplicitKey("api-key-123".into());
        let url = GeminiModelProvider::build_generate_content_url("gemini-2.0-flash", &auth);
        assert!(url.contains("generativelanguage.googleapis.com/v1beta"));
        assert!(url.contains("models/gemini-2.0-flash"));
    }

    #[test]
    fn oauth_request_uses_bearer_auth_header() {
        let model_provider = test_model_provider(Some(test_oauth_auth("ya29.mock-token")));
        let auth = test_oauth_auth("ya29.mock-token");
        let url = GeminiModelProvider::build_generate_content_url("gemini-2.0-flash", &auth);
        let body = GenerateContentRequest {
            contents: vec![Content {
                role: Some("user".into()),
                parts: vec![Part::text("hello")],
            }],
            system_instruction: None,
            generation_config: GenerationConfig {
                temperature: Some(0.7),
                max_output_tokens: 8192,
            },
        };

        let request = model_provider
            .build_generate_content_request(
                &auth,
                &url,
                &body,
                "gemini-2.0-flash",
                true,
                Some("test-project"),
                Some("ya29.mock-token"),
            )
            .build()
            .unwrap();

        assert_eq!(
            request
                .headers()
                .get(AUTHORIZATION)
                .and_then(|h| h.to_str().ok()),
            Some("Bearer ya29.mock-token")
        );
    }

    #[test]
    fn oauth_request_wraps_payload_in_request_envelope() {
        let model_provider = test_model_provider(Some(test_oauth_auth("ya29.mock-token")));
        let auth = test_oauth_auth("ya29.mock-token");
        let url = GeminiModelProvider::build_generate_content_url("gemini-2.0-flash", &auth);
        let body = GenerateContentRequest {
            contents: vec![Content {
                role: Some("user".into()),
                parts: vec![Part::text("hello")],
            }],
            system_instruction: None,
            generation_config: GenerationConfig {
                temperature: Some(0.7),
                max_output_tokens: 8192,
            },
        };

        let request = model_provider
            .build_generate_content_request(
                &auth,
                &url,
                &body,
                "models/gemini-2.0-flash",
                true,
                Some("test-project"),
                Some("ya29.mock-token"),
            )
            .build()
            .unwrap();

        let payload = request
            .body()
            .and_then(|b| b.as_bytes())
            .expect("json request body should be bytes");
        let json: serde_json::Value = serde_json::from_slice(payload).unwrap();

        assert_eq!(json["model"], "gemini-2.0-flash");
        assert!(json.get("generationConfig").is_none());
        assert!(json.get("request").is_some());
        assert!(json["request"].get("generationConfig").is_some());
    }

    #[test]
    fn api_key_request_does_not_set_bearer_header() {
        let model_provider =
            test_model_provider(Some(GeminiAuth::ExplicitKey("api-key-123".into())));
        let auth = GeminiAuth::ExplicitKey("api-key-123".into());
        let url = GeminiModelProvider::build_generate_content_url("gemini-2.0-flash", &auth);
        let body = GenerateContentRequest {
            contents: vec![Content {
                role: Some("user".into()),
                parts: vec![Part::text("hello")],
            }],
            system_instruction: None,
            generation_config: GenerationConfig {
                temperature: Some(0.7),
                max_output_tokens: 8192,
            },
        };

        let request = model_provider
            .build_generate_content_request(
                &auth,
                &url,
                &body,
                "gemini-2.0-flash",
                true,
                None,
                None,
            )
            .build()
            .unwrap();

        assert!(request.headers().get(AUTHORIZATION).is_none());
    }

    #[test]
    fn request_serialization() {
        let request = GenerateContentRequest {
            contents: vec![Content {
                role: Some("user".to_string()),
                parts: vec![Part::text("Hello")],
            }],
            system_instruction: Some(Content {
                role: None,
                parts: vec![Part::text("You are helpful")],
            }),
            generation_config: GenerationConfig {
                temperature: Some(0.7),
                max_output_tokens: 8192,
            },
        };

        let json = serde_json::to_string(&request).unwrap();
        assert!(json.contains("\"role\":\"user\""));
        assert!(json.contains("\"text\":\"Hello\""));
        assert!(json.contains("\"systemInstruction\""));
        assert!(!json.contains("\"system_instruction\""));
        assert!(json.contains("\"temperature\":0.7"));
        assert!(json.contains("\"maxOutputTokens\":8192"));
    }

    #[test]
    fn internal_request_includes_model() {
        let request = InternalGenerateContentEnvelope {
            model: "gemini-3-pro-preview".to_string(),
            project: Some("test-project".to_string()),
            user_prompt_id: Some("prompt-123".to_string()),
            request: InternalGenerateContentRequest {
                contents: vec![Content {
                    role: Some("user".to_string()),
                    parts: vec![Part::text("Hello")],
                }],
                system_instruction: None,
                generation_config: Some(GenerationConfig {
                    temperature: Some(0.7),
                    max_output_tokens: 8192,
                }),
            },
        };

        let json = serde_json::to_string(&request).unwrap();
        assert!(json.contains("\"model\":\"gemini-3-pro-preview\""));
        assert!(json.contains("\"request\""));
        assert!(json.contains("\"generationConfig\""));
        assert!(json.contains("\"maxOutputTokens\":8192"));
        assert!(json.contains("\"user_prompt_id\":\"prompt-123\""));
        assert!(json.contains("\"project\":\"test-project\""));
        assert!(json.contains("\"role\":\"user\""));
        assert!(json.contains("\"temperature\":0.7"));
    }

    #[test]
    fn internal_request_omits_generation_config_when_none() {
        let request = InternalGenerateContentEnvelope {
            model: "gemini-3-pro-preview".to_string(),
            project: Some("test-project".to_string()),
            user_prompt_id: None,
            request: InternalGenerateContentRequest {
                contents: vec![Content {
                    role: Some("user".to_string()),
                    parts: vec![Part::text("Hello")],
                }],
                system_instruction: None,
                generation_config: None,
            },
        };

        let json = serde_json::to_string(&request).unwrap();
        assert!(!json.contains("generationConfig"));
        assert!(json.contains("\"model\":\"gemini-3-pro-preview\""));
    }

    #[test]
    fn internal_request_includes_project() {
        let request = InternalGenerateContentEnvelope {
            model: "gemini-2.5-flash".to_string(),
            project: Some("my-gcp-project-id".to_string()),
            user_prompt_id: None,
            request: InternalGenerateContentRequest {
                contents: vec![Content {
                    role: Some("user".to_string()),
                    parts: vec![Part::text("Hello")],
                }],
                system_instruction: None,
                generation_config: None,
            },
        };

        let json = serde_json::to_string(&request).unwrap();
        assert!(json.contains("\"project\":\"my-gcp-project-id\""));
    }

    #[test]
    fn creds_deserialize_with_expiry_date() {
        let json = r#"{
            "access_token": "ya29.test-token",
            "refresh_token": "1//test-refresh",
            "expiry_date": 4102444800000
        }"#;

        let creds: GeminiCliOAuthCreds = serde_json::from_str(json).unwrap();
        assert_eq!(creds.access_token.as_deref(), Some("ya29.test-token"));
        assert_eq!(creds.refresh_token.as_deref(), Some("1//test-refresh"));
        assert_eq!(creds.expiry_date, Some(4_102_444_800_000));
        assert!(creds.expiry.is_none());
    }

    #[test]
    fn creds_deserialize_accepts_camel_case_fields() {
        let json = r#"{
            "access_token": "ya29.test-token",
            "idToken": "header.payload.sig",
            "refresh_token": "1//test-refresh",
            "clientId": "test-client-id",
            "clientSecret": "test-client-secret",
            "expiryDate": 4102444800000
        }"#;

        let creds: GeminiCliOAuthCreds = serde_json::from_str(json).unwrap();
        assert_eq!(creds.id_token.as_deref(), Some("header.payload.sig"));
        assert_eq!(creds.client_id.as_deref(), Some("test-client-id"));
        assert_eq!(creds.client_secret.as_deref(), Some("test-client-secret"));
        assert_eq!(creds.expiry_date, Some(4_102_444_800_000));
    }

    #[test]
    fn oauth_retry_detection_for_generation_config_rejection() {
        // Bare quotes (e.g. pre-parsed error string)
        let err =
            "Invalid JSON payload received. Unknown name \"generationConfig\": Cannot find field.";
        assert!(
            GeminiModelProvider::should_retry_oauth_without_generation_config(
                StatusCode::BAD_REQUEST,
                err
            )
        );
        // JSON-escaped quotes (raw response body from Google API)
        let err_json = r#"Invalid JSON payload received. Unknown name \"generationConfig\": Cannot find field."#;
        assert!(
            GeminiModelProvider::should_retry_oauth_without_generation_config(
                StatusCode::BAD_REQUEST,
                err_json
            )
        );
        assert!(
            !GeminiModelProvider::should_retry_oauth_without_generation_config(
                StatusCode::UNAUTHORIZED,
                err
            )
        );
        assert!(
            !GeminiModelProvider::should_retry_oauth_without_generation_config(
                StatusCode::BAD_REQUEST,
                "something else"
            )
        );
    }

    #[test]
    fn response_deserialization() {
        let json = r#"{
            "candidates": [{
                "content": {
                    "parts": [{"text": "Hello there!"}]
                }
            }]
        }"#;

        let response: GenerateContentResponse = serde_json::from_str(json).unwrap();
        assert!(response.candidates.is_some());
        let text = response
            .candidates
            .unwrap()
            .into_iter()
            .next()
            .unwrap()
            .content
            .unwrap()
            .parts
            .into_iter()
            .next()
            .unwrap()
            .text;
        assert_eq!(text, Some("Hello there!".to_string()));
    }

    #[test]
    fn error_response_deserialization() {
        let json = r#"{
            "error": {
                "message": "Invalid API key"
            }
        }"#;

        let response: GenerateContentResponse = serde_json::from_str(json).unwrap();
        assert!(response.error.is_some());
        assert_eq!(response.error.unwrap().message, "Invalid API key");
    }

    #[test]
    fn internal_response_deserialization() {
        let json = r#"{
            "response": {
                "candidates": [{
                    "content": {
                        "parts": [{"text": "Hello from internal"}]
                    }
                }]
            }
        }"#;

        let response: GenerateContentResponse = serde_json::from_str(json).unwrap();
        let text = response
            .into_effective_response()
            .candidates
            .unwrap()
            .into_iter()
            .next()
            .unwrap()
            .content
            .unwrap()
            .parts
            .into_iter()
            .next()
            .unwrap()
            .text;
        assert_eq!(text, Some("Hello from internal".to_string()));
    }

    // ── Thinking model response tests ──────────────────────────────────────

    #[test]
    fn thinking_response_extracts_non_thinking_text() {
        let json = r#"{
            "candidates": [{
                "content": {
                    "parts": [
                        {"thought": true, "text": "Let me think about this..."},
                        {"text": "The answer is 42."},
                        {"thoughtSignature": "c2lnbmF0dXJl"}
                    ]
                }
            }]
        }"#;

        let response: GenerateContentResponse = serde_json::from_str(json).unwrap();
        let candidate = response.candidates.unwrap().into_iter().next().unwrap();
        let text = candidate.content.unwrap().effective_text();
        assert_eq!(text, Some("The answer is 42.".to_string()));
    }

    #[test]
    fn non_thinking_response_unaffected() {
        let json = r#"{
            "candidates": [{
                "content": {
                    "parts": [{"text": "Hello there!"}]
                }
            }]
        }"#;

        let response: GenerateContentResponse = serde_json::from_str(json).unwrap();
        let candidate = response.candidates.unwrap().into_iter().next().unwrap();
        let text = candidate.content.unwrap().effective_text();
        assert_eq!(text, Some("Hello there!".to_string()));
    }

    #[test]
    fn thinking_only_response_falls_back_to_thinking_text() {
        let json = r#"{
            "candidates": [{
                "content": {
                    "parts": [
                        {"thought": true, "text": "I need more context..."},
                        {"thoughtSignature": "c2lnbmF0dXJl"}
                    ]
                }
            }]
        }"#;

        let response: GenerateContentResponse = serde_json::from_str(json).unwrap();
        let candidate = response.candidates.unwrap().into_iter().next().unwrap();
        let text = candidate.content.unwrap().effective_text();
        assert_eq!(text, Some("I need more context...".to_string()));
    }

    #[test]
    fn empty_parts_returns_none() {
        let json = r#"{
            "candidates": [{
                "content": {
                    "parts": []
                }
            }]
        }"#;

        let response: GenerateContentResponse = serde_json::from_str(json).unwrap();
        let candidate = response.candidates.unwrap().into_iter().next().unwrap();
        let text = candidate.content.unwrap().effective_text();
        assert_eq!(text, None);
    }

    #[test]
    fn multiple_text_parts_concatenated() {
        let json = r#"{
            "candidates": [{
                "content": {
                    "parts": [
                        {"text": "Part one. "},
                        {"text": "Part two."}
                    ]
                }
            }]
        }"#;

        let response: GenerateContentResponse = serde_json::from_str(json).unwrap();
        let candidate = response.candidates.unwrap().into_iter().next().unwrap();
        let text = candidate.content.unwrap().effective_text();
        assert_eq!(text, Some("Part one. Part two.".to_string()));
    }

    #[test]
    fn thought_signature_only_parts_skipped() {
        let json = r#"{
            "candidates": [{
                "content": {
                    "parts": [
                        {"thoughtSignature": "c2lnbmF0dXJl"}
                    ]
                }
            }]
        }"#;

        let response: GenerateContentResponse = serde_json::from_str(json).unwrap();
        let candidate = response.candidates.unwrap().into_iter().next().unwrap();
        let text = candidate.content.unwrap().effective_text();
        assert_eq!(text, None);
    }

    #[test]
    fn internal_response_thinking_model() {
        let json = r#"{
            "response": {
                "candidates": [{
                    "content": {
                        "parts": [
                            {"thought": true, "text": "reasoning..."},
                            {"text": "final answer"}
                        ]
                    }
                }]
            }
        }"#;

        let response: GenerateContentResponse = serde_json::from_str(json).unwrap();
        let effective = response.into_effective_response();
        let candidate = effective.candidates.unwrap().into_iter().next().unwrap();
        let text = candidate.content.unwrap().effective_text();
        assert_eq!(text, Some("final answer".to_string()));
    }

    #[tokio::test]
    async fn warmup_without_key_is_noop() {
        let model_provider = test_model_provider(None);
        let result = model_provider.warmup().await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn warmup_oauth_is_noop() {
        let model_provider = test_model_provider(Some(test_oauth_auth("ya29.mock-token")));
        let result = model_provider.warmup().await;
        assert!(result.is_ok());
    }

    #[test]
    fn discover_oauth_cred_paths_does_not_panic() {
        let _paths = GeminiModelProvider::discover_oauth_cred_paths();
    }

    #[tokio::test]
    async fn rotate_oauth_without_alternatives_returns_false() {
        let state = Arc::new(tokio::sync::Mutex::new(OAuthTokenState {
            access_token: "ya29.mock".to_string(),
            refresh_token: None,
            client_id: None,
            client_secret: None,
            expiry_millis: None,
        }));
        let model_provider = test_model_provider(Some(GeminiAuth::OAuthToken(state.clone())));
        assert!(!model_provider.rotate_oauth_credential(&state).await);
    }

    #[test]
    fn response_parses_usage_metadata() {
        let json = r#"{
            "candidates": [{"content": {"parts": [{"text": "Hello"}]}}],
            "usageMetadata": {"promptTokenCount": 120, "candidatesTokenCount": 40}
        }"#;
        let resp: GenerateContentResponse = serde_json::from_str(json).unwrap();
        let usage = resp.usage_metadata.unwrap();
        assert_eq!(usage.prompt_token_count, Some(120));
        assert_eq!(usage.candidates_token_count, Some(40));
    }

    #[test]
    fn response_usage_metadata_maps_to_token_usage() {
        let usage = GeminiUsageMetadata {
            prompt_token_count: Some(120),
            candidates_token_count: Some(40),
        };

        let token_usage =
            GeminiModelProvider::token_usage_from_metadata(usage).expect("usage counts should map");

        assert_eq!(token_usage.input_tokens, Some(120));
        assert_eq!(token_usage.output_tokens, Some(40));
        assert_eq!(token_usage.cached_input_tokens, None);
    }

    #[test]
    fn empty_usage_metadata_maps_to_none() {
        let usage = GeminiUsageMetadata {
            prompt_token_count: None,
            candidates_token_count: None,
        };

        assert!(GeminiModelProvider::token_usage_from_metadata(usage).is_none());
    }

    #[test]
    fn wrapped_response_preserves_outer_usage_metadata() {
        let json = r#"{
            "usageMetadata": {"promptTokenCount": 120, "candidatesTokenCount": 40},
            "response": {
                "candidates": [{"content": {"parts": [{"text": "Hello"}]}}]
            }
        }"#;

        let resp: GenerateContentResponse = serde_json::from_str(json).unwrap();
        let effective = resp.into_effective_response();
        let usage = effective.usage_metadata.unwrap();

        assert_eq!(usage.prompt_token_count, Some(120));
        assert_eq!(usage.candidates_token_count, Some(40));
    }

    #[test]
    fn wrapped_response_prefers_inner_usage_metadata() {
        let json = r#"{
            "usageMetadata": {"promptTokenCount": 120, "candidatesTokenCount": 40},
            "response": {
                "candidates": [{"content": {"parts": [{"text": "Hello"}]}}],
                "usageMetadata": {"promptTokenCount": 5, "candidatesTokenCount": 2}
            }
        }"#;

        let resp: GenerateContentResponse = serde_json::from_str(json).unwrap();
        let effective = resp.into_effective_response();
        let usage = effective.usage_metadata.unwrap();

        assert_eq!(usage.prompt_token_count, Some(5));
        assert_eq!(usage.candidates_token_count, Some(2));
    }

    #[test]
    fn response_parses_without_usage_metadata() {
        let json = r#"{"candidates": [{"content": {"parts": [{"text": "Hello"}]}}]}"#;
        let resp: GenerateContentResponse = serde_json::from_str(json).unwrap();
        assert!(resp.usage_metadata.is_none());
    }

    #[tokio::test]
    async fn warmup_managed_oauth_requires_auth_service() {
        let model_provider = GeminiModelProvider {
            alias: "test".to_string(),
            auth: Some(GeminiAuth::ManagedOAuth),
            oauth_project: Arc::new(tokio::sync::Mutex::new(None)),
            oauth_project_seed: None,
            oauth_cred_paths: Vec::new(),
            oauth_index: Arc::new(tokio::sync::Mutex::new(0)),
            auth_service: None, // Missing auth_service
            auth_profile_override: None,
            oauth_client_id: None,
            oauth_client_secret: None,
        };

        let result = model_provider.warmup().await;
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("ManagedOAuth requires auth_service")
        );
    }

    #[tokio::test]
    async fn warmup_cli_oauth_skips_validation() {
        let model_provider = test_model_provider(Some(test_oauth_auth("fake_token")));
        let result = model_provider.warmup().await;
        // Should succeed without making HTTP requests
        assert!(result.is_ok());
    }

    // ── Part enum serialization tests ────────────────────────────────────

    #[test]
    fn part_text_serializes_as_text_object() {
        let part = Part::text("hello");
        let json = serde_json::to_value(&part).unwrap();
        assert_eq!(json, serde_json::json!({"text": "hello"}));
    }

    #[test]
    fn part_inline_serializes_as_inline_data_object() {
        let part = Part::Inline {
            inline_data: InlineData {
                mime_type: "image/png".to_string(),
                data: "iVBOR...".to_string(),
            },
        };
        let json = serde_json::to_value(&part).unwrap();
        assert_eq!(
            json,
            serde_json::json!({"inline_data": {"mime_type": "image/png", "data": "iVBOR..."}})
        );
    }

    #[test]
    fn part_text_constructor_accepts_string_and_str() {
        let from_str = Part::text("hello");
        let from_string = Part::text(String::from("hello"));
        // Both should serialize identically
        assert_eq!(
            serde_json::to_value(&from_str).unwrap(),
            serde_json::to_value(&from_string).unwrap(),
        );
    }

    #[test]
    fn content_with_mixed_parts_serializes_correctly() {
        let content = Content {
            role: Some("user".to_string()),
            parts: vec![
                Part::text("Describe this image:"),
                Part::Inline {
                    inline_data: InlineData {
                        mime_type: "image/jpeg".to_string(),
                        data: "/9j/4AAQ...".to_string(),
                    },
                },
            ],
        };
        let json = serde_json::to_value(&content).unwrap();
        let parts = json["parts"].as_array().unwrap();
        assert_eq!(parts.len(), 2);
        assert!(parts[0].get("text").is_some());
        assert!(parts[1].get("inline_data").is_some());
    }

    // ── build_parts tests ────────────────────────────────────────────────

    #[test]
    fn build_parts_plain_text_returns_single_text_part() {
        let parts = build_parts("Hello, world!");
        assert_eq!(parts.len(), 1);
        assert_eq!(
            serde_json::to_value(&parts[0]).unwrap(),
            serde_json::json!({"text": "Hello, world!"})
        );
    }

    #[test]
    fn build_parts_empty_string_returns_single_text_part() {
        let parts = build_parts("");
        assert_eq!(parts.len(), 1);
        // Falls back to original content when no markers and trimmed is empty
        assert_eq!(
            serde_json::to_value(&parts[0]).unwrap(),
            serde_json::json!({"text": ""})
        );
    }

    #[test]
    fn build_parts_extracts_data_uri_as_inline_part() {
        let content = "Check this [IMAGE:data:image/png;base64,iVBORw0KGgo=]";
        let parts = build_parts(content);
        assert_eq!(parts.len(), 2);
        // First part is text
        assert_eq!(
            serde_json::to_value(&parts[0]).unwrap(),
            serde_json::json!({"text": "Check this"})
        );
        // Second part is inline image
        assert_eq!(
            serde_json::to_value(&parts[1]).unwrap(),
            serde_json::json!({"inline_data": {"mime_type": "image/png", "data": "iVBORw0KGgo="}})
        );
    }

    #[test]
    fn build_parts_multiple_images() {
        let content = "Image A: [IMAGE:data:image/png;base64,AAAA] Image B: [IMAGE:data:image/jpeg;base64,BBBB]";
        let parts = build_parts(content);
        assert_eq!(parts.len(), 3); // text + 2 images
        // Verify both inline parts
        let inline_parts: Vec<_> = parts
            .iter()
            .filter(|p| matches!(p, Part::Inline { .. }))
            .collect();
        assert_eq!(inline_parts.len(), 2);
    }

    #[test]
    fn build_parts_ignores_non_data_uri_markers() {
        // File paths and URLs are not data URIs — build_parts should only
        // extract data: URIs, leaving non-data markers as stripped text.
        let content = "Look [IMAGE:/tmp/photo.png]";
        let parts = build_parts(content);
        // parse_image_markers extracts the marker, but build_parts only
        // converts data: URIs to inline parts. The text remains.
        for part in &parts {
            assert!(matches!(part, Part::Text { .. }));
        }
    }

    #[test]
    fn build_parts_image_only_still_produces_inline_part() {
        let content = "[IMAGE:data:image/gif;base64,R0lGODlh]";
        let parts = build_parts(content);
        // Should have just the inline part (text is empty after marker removal)
        assert_eq!(parts.len(), 1);
        assert!(matches!(&parts[0], Part::Inline { .. }));
    }

    // ── chat_with_history uses build_parts for user messages ─────────────

    #[test]
    fn chat_with_history_maps_roles_correctly() {
        let messages = vec![
            ChatMessage::system("You are helpful"),
            ChatMessage::user("Hello [IMAGE:data:image/png;base64,AA==]"),
            ChatMessage::assistant("I see the image"),
        ];

        let (contents, system_instruction) =
            GeminiModelProvider::build_chat_contents(&messages, None);

        let system_instruction = system_instruction.expect("system prompt should be separated");
        assert_eq!(system_instruction.role, None);
        assert!(
            matches!(&system_instruction.parts[0], Part::Text { text } if text == "You are helpful")
        );

        assert_eq!(contents.len(), 2);
        assert_eq!(contents[0].role.as_deref(), Some("user"));
        assert!(
            contents[0]
                .parts
                .iter()
                .any(|p| matches!(p, Part::Inline { .. }))
        );
        assert_eq!(contents[1].role.as_deref(), Some("model"));
        assert!(matches!(&contents[1].parts[0], Part::Text { text } if text == "I see the image"));
    }

    #[test]
    fn chat_contents_append_tool_instructions_to_system_prompt() {
        let messages = vec![
            ChatMessage::system("You are helpful"),
            ChatMessage::user("Hello"),
        ];

        let (_contents, system_instruction) =
            GeminiModelProvider::build_chat_contents(&messages, Some("Use tools carefully"));

        let system_instruction = system_instruction.expect("system prompt should include tools");
        assert!(
            matches!(&system_instruction.parts[0], Part::Text { text } if text == "You are helpful\n\nUse tools carefully")
        );
    }

    #[test]
    fn chat_contents_create_system_prompt_from_tool_instructions() {
        let messages = vec![ChatMessage::user("Hello")];

        let (_contents, system_instruction) =
            GeminiModelProvider::build_chat_contents(&messages, Some("Use tools carefully"));

        let system_instruction =
            system_instruction.expect("tool instructions should be system prompt");
        assert!(
            matches!(&system_instruction.parts[0], Part::Text { text } if text == "Use tools carefully")
        );
    }
}
