//! Google Gemini API provider.
//!
//! Streams completions from the Gemini `v1beta/models/{model}:streamGenerateContent`
//! endpoint using Server-Sent Events (SSE).

use async_trait::async_trait;
use futures_util::StreamExt;
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use tokio_util::sync::CancellationToken;

use crate::agent::provider::*;
use crate::agent::AgentError;
use crate::auth::profiles::{refresh_token, OAuthProviderConfig, ProfileStore};

const TOKEN_REFRESH_MARGIN_MS: u64 = 60_000;

enum GeminiAuth {
    ApiKey(String),
    OAuthProfile {
        profile_store: Arc<ProfileStore>,
        profile_id: String,
        provider_config: OAuthProviderConfig,
        refresh_lock: Arc<Mutex<()>>,
    },
}

impl std::fmt::Debug for GeminiAuth {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::ApiKey(_) => f.debug_tuple("ApiKey").field(&"<redacted>").finish(),
            Self::OAuthProfile { profile_id, .. } => f
                .debug_struct("OAuthProfile")
                .field("profile_id", profile_id)
                .finish_non_exhaustive(),
        }
    }
}

/// Google Gemini API provider.
pub struct GeminiProvider {
    client: reqwest::Client,
    auth: GeminiAuth,
    base_url: String,
}

impl std::fmt::Debug for GeminiProvider {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GeminiProvider")
            .field("auth", &self.auth)
            .field("base_url", &self.base_url)
            .finish()
    }
}

impl GeminiProvider {
    pub fn new(api_key: String) -> Result<Self, AgentError> {
        if api_key.trim().is_empty() {
            return Err(AgentError::InvalidApiKey(
                "API key must not be empty".to_string(),
            ));
        }
        let client = reqwest::Client::builder()
            .connect_timeout(std::time::Duration::from_secs(10))
            .timeout(std::time::Duration::from_secs(300))
            .build()
            .map_err(|e| AgentError::Provider(format!("failed to build HTTP client: {e}")))?;
        Ok(Self {
            client,
            auth: GeminiAuth::ApiKey(api_key),
            base_url: "https://generativelanguage.googleapis.com".to_string(),
        })
    }

    pub fn with_oauth_profile(
        profile_store: Arc<ProfileStore>,
        profile_id: String,
        provider_config: OAuthProviderConfig,
    ) -> Result<Self, AgentError> {
        if profile_id.trim().is_empty() {
            return Err(AgentError::Provider(
                "Gemini auth profile ID must not be empty".to_string(),
            ));
        }
        let client = reqwest::Client::builder()
            .connect_timeout(std::time::Duration::from_secs(10))
            .timeout(std::time::Duration::from_secs(300))
            .build()
            .map_err(|e| AgentError::Provider(format!("failed to build HTTP client: {e}")))?;
        Ok(Self {
            client,
            auth: GeminiAuth::OAuthProfile {
                profile_store,
                profile_id,
                provider_config,
                refresh_lock: Arc::new(Mutex::new(())),
            },
            base_url: "https://generativelanguage.googleapis.com".to_string(),
        })
    }

    pub fn with_base_url(mut self, url: String) -> Result<Self, AgentError> {
        let parsed = url::Url::parse(&url)
            .map_err(|e| AgentError::InvalidBaseUrl(format!("invalid URL \"{url}\": {e}")))?;
        if parsed.scheme() != "https" {
            return Err(AgentError::InvalidBaseUrl(format!(
                "base URL must use https scheme, got \"{}\"",
                parsed.scheme()
            )));
        }
        // Strip trailing slash for consistent path joining
        self.base_url = url.trim_end_matches('/').to_string();
        Ok(self)
    }

    /// Build the JSON body for the Gemini streamGenerateContent API.
    pub(crate) fn build_body(&self, request: &CompletionRequest) -> Value {
        let mut body = json!({});

        // System instruction
        if let Some(ref system) = request.system {
            body["system_instruction"] = json!({
                "parts": [{ "text": system }]
            });
        }

        // Convert LlmMessages to Gemini contents format
        body["contents"] = json!(build_gemini_contents(&request.messages));

        // Tools (function declarations)
        if !request.tools.is_empty() {
            let function_declarations: Vec<Value> = request
                .tools
                .iter()
                .map(|t| {
                    json!({
                        "name": t.name,
                        "description": t.description,
                        "parameters": t.input_schema,
                    })
                })
                .collect();
            body["tools"] = json!([{
                "function_declarations": function_declarations,
            }]);
        }

        // Generation config
        let mut generation_config = json!({
            "maxOutputTokens": request.max_tokens,
        });
        if let Some(temp) = request.temperature {
            generation_config["temperature"] = json!(temp);
        }
        body["generationConfig"] = generation_config;

        body
    }

    async fn auth_headers(&self) -> Result<Vec<(&'static str, String)>, AgentError> {
        match &self.auth {
            GeminiAuth::ApiKey(api_key) => Ok(vec![("x-goog-api-key", api_key.clone())]),
            GeminiAuth::OAuthProfile {
                profile_store,
                profile_id,
                provider_config,
                refresh_lock,
            } => {
                let mut profile = profile_store.get(profile_id).ok_or_else(|| {
                    AgentError::Provider(format!(
                        "configured Gemini auth profile \"{profile_id}\" was not found"
                    ))
                })?;
                ensure_oauth_profile_kind(&profile, profile_id, "Gemini")?;
                let now_ms = current_time_ms();
                if profile
                    .oauth_tokens()
                    .and_then(|tokens| tokens.expires_at_ms)
                    .is_some_and(|expires_at| expires_at <= now_ms + TOKEN_REFRESH_MARGIN_MS)
                {
                    let _refresh_guard = refresh_lock.lock().await;
                    let refreshed_profile = profile_store.get(profile_id).ok_or_else(|| {
                        AgentError::Provider(format!(
                            "configured Gemini auth profile \"{profile_id}\" was not found"
                        ))
                    })?;
                    ensure_oauth_profile_kind(&refreshed_profile, profile_id, "Gemini")?;
                    let now_ms_after_lock = current_time_ms();
                    if refreshed_profile
                        .oauth_tokens()
                        .and_then(|tokens| tokens.expires_at_ms)
                        .is_none_or(|expires_at| {
                            expires_at > now_ms_after_lock + TOKEN_REFRESH_MARGIN_MS
                        })
                    {
                        profile = refreshed_profile;
                    } else {
                        let refresh_token_value = refreshed_profile
                            .oauth_tokens()
                            .and_then(|tokens| tokens.refresh_token.clone())
                            .filter(|token| !token.trim().is_empty())
                            .ok_or_else(|| {
                                AgentError::Provider(format!(
                                    "Gemini auth profile \"{profile_id}\" is expired and cannot be refreshed"
                                ))
                            })?;
                        let refreshed = refresh_token(provider_config, &refresh_token_value)
                            .await
                            .map_err(|err| {
                                AgentError::Provider(format!(
                                    "failed to refresh Gemini auth profile \"{profile_id}\": {err}"
                                ))
                            })?;
                        profile_store
                            .update_tokens(profile_id, refreshed.clone())
                            .map_err(|err| {
                                AgentError::Provider(format!(
                                    "failed to persist refreshed Gemini auth profile \"{profile_id}\": {err}"
                                ))
                            })?;
                        profile = refreshed_profile;
                        profile.tokens = Some(refreshed);
                    }
                }

                let access_token = profile
                    .oauth_tokens()
                    .map(|tokens| tokens.access_token.trim())
                    .unwrap_or("");
                if access_token.is_empty() {
                    return Err(AgentError::Provider(format!(
                        "Gemini auth profile \"{profile_id}\" has no usable access token"
                    )));
                }
                profile_store.update_last_used(profile_id);
                Ok(vec![("authorization", format!("Bearer {access_token}"))])
            }
        }
    }
}

pub(crate) fn build_gemini_contents(messages: &[LlmMessage]) -> Vec<Value> {
    let mut contents: Vec<Value> = Vec::new();
    let mut current_role: Option<&'static str> = None;
    let mut current_parts: Vec<Value> = Vec::new();

    for msg in messages {
        let role = match msg.role {
            LlmRole::User => "user",
            LlmRole::Assistant => "model",
        };

        let mut parts: Vec<Value> = Vec::new();

        for block in &msg.content {
            match block {
                ContentBlock::Text { text, metadata } => {
                    let mut part = json!({ "text": text });
                    apply_gemini_thought_signature(&mut part, metadata);
                    parts.push(part);
                }
                ContentBlock::ToolUse {
                    id: _,
                    name,
                    input,
                    metadata,
                } => {
                    let mut part = json!({
                        "functionCall": {
                            "name": name,
                            "args": input,
                        }
                    });
                    apply_gemini_thought_signature(&mut part, metadata);
                    parts.push(part);
                }
                ContentBlock::ToolResult {
                    tool_use_id: _,
                    content,
                    is_error: _,
                } => {
                    // For tool results, we need to look up the tool name.
                    // Gemini uses functionResponse with name and response.
                    // Search prior messages to find the matching tool use name.
                    let tool_name = find_tool_name_for_result(messages, block);
                    let part = json!({
                        "functionResponse": {
                            "name": tool_name,
                            "response": {
                                "result": content,
                            }
                        }
                    });
                    parts.push(part);
                }
            }
        }

        if !parts.is_empty() {
            if Some(role) == current_role {
                current_parts.extend(parts);
            } else {
                if let Some(r) = current_role {
                    contents.push(json!({
                        "role": r,
                        "parts": std::mem::take(&mut current_parts),
                    }));
                }
                current_role = Some(role);
                current_parts = parts;
            }
        }
    }

    if let Some(r) = current_role {
        contents.push(json!({
            "role": r,
            "parts": current_parts,
        }));
    }

    contents
}

/// Find the tool name that corresponds to a ToolResult block by searching
/// prior messages for a matching ToolUse with the same ID.
fn find_tool_name_for_result(messages: &[LlmMessage], block: &ContentBlock) -> String {
    let target_id = match block {
        ContentBlock::ToolResult { tool_use_id, .. } => tool_use_id,
        _ => return "unknown".to_string(),
    };

    for msg in messages.iter().rev() {
        for b in &msg.content {
            if let ContentBlock::ToolUse { id, name, .. } = b {
                if id == target_id {
                    return name.clone();
                }
            }
        }
    }

    "unknown".to_string()
}

#[async_trait]
impl LlmProvider for GeminiProvider {
    async fn complete(
        &self,
        request: CompletionRequest,
        cancel_token: CancellationToken,
    ) -> Result<mpsc::Receiver<StreamEvent>, AgentError> {
        if cancel_token.is_cancelled() {
            return Err(AgentError::Cancelled);
        }
        let body = self.build_body(&request);

        let model_name = strip_gemini_prefix(&request.model);
        let url = format!(
            "{}/v1beta/models/{}:streamGenerateContent?alt=sse",
            self.base_url, model_name
        );

        let response = tokio::select! {
            _ = cancel_token.cancelled() => {
                return Err(AgentError::Cancelled);
            }
            response = async {
                let mut request = self
                    .client
                    .post(&url)
                    .header("content-type", "application/json")
                    .header("accept", "text/event-stream");
                for (name, value) in self.auth_headers().await? {
                    request = request.header(name, value);
                }
                request
                    .json(&body)
                    .send()
                    .await
                    .map_err(|e| AgentError::Provider(format!("HTTP request failed: {}", e.without_url())))
            } => {
                response?
            }
        };

        if !response.status().is_success() {
            let status = response.status();
            let body = crate::net_util::read_response_body_text_capped(
                response,
                crate::net_util::MAX_RESPONSE_BODY_BYTES,
            )
            .await
            .unwrap_or_else(|_| "<unreadable>".to_string());
            return Err(AgentError::Provider(format!(
                "API returned {status}: {}",
                summarize_http_failure_body(&body)
            )));
        }

        let (tx, rx) = mpsc::channel(64);

        // Spawn a task to read the SSE stream and forward events
        let stream = response.bytes_stream();
        let cancel = cancel_token.clone();
        tokio::spawn(async move {
            if let Err(e) = process_gemini_sse_stream(stream, &tx, &cancel).await {
                let _ = tx
                    .send(StreamEvent::Error {
                        message: e.to_string(),
                        usage: None,
                    })
                    .await;
            }
        });

        Ok(rx)
    }
}

fn current_time_ms() -> u64 {
    crate::time::unix_now_ms_u64()
}

/// Maximum SSE line buffer size (1 MB). If a single SSE line exceeds this,
/// the stream is treated as corrupted to prevent unbounded memory growth.
const MAX_SSE_BUFFER_BYTES: usize = 1_048_576;

/// Process a Gemini SSE byte stream into StreamEvents.
async fn process_gemini_sse_stream<S>(
    mut stream: S,
    tx: &mpsc::Sender<StreamEvent>,
    cancel_token: &CancellationToken,
) -> Result<(), String>
where
    S: futures_util::Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
{
    let mut buffer = String::new();
    let mut accumulated_usage = TokenUsage::default();
    let mut last_finish_reason: Option<String> = None;
    let mut seen_tool_use = false;

    loop {
        let chunk = tokio::select! {
            _ = cancel_token.cancelled() => return Ok(()),
            chunk = stream.next() => chunk,
        };
        let Some(chunk) = chunk else {
            break;
        };
        let chunk = chunk.map_err(|e| format!("stream read error: {}", e.without_url()))?;
        buffer.push_str(&String::from_utf8_lossy(&chunk));

        if buffer.len() > MAX_SSE_BUFFER_BYTES {
            return Err(format!(
                "SSE buffer exceeded {} bytes, aborting stream",
                MAX_SSE_BUFFER_BYTES
            ));
        }

        // Process complete lines
        let mut consumed = 0;
        while let Some(rel_pos) = buffer[consumed..].find('\n') {
            let newline_pos = consumed + rel_pos;
            let line = buffer[consumed..newline_pos]
                .trim_end_matches('\r')
                .to_string();
            consumed = newline_pos + 1;

            if let Some(data) = line.strip_prefix("data: ") {
                if let Some(events) =
                    parse_gemini_sse_data(data, &mut accumulated_usage, &mut last_finish_reason)
                {
                    for mut event in events {
                        if event.is_valid_tool_use() {
                            seen_tool_use = true;
                        }
                        let terminal_error = if let StreamEvent::Stop { reason, .. } = &mut event {
                            match resolve_google_finish_reason(
                                last_finish_reason.as_deref(),
                                seen_tool_use,
                            ) {
                                GoogleFinishReasonResolution::Stop(resolved) => {
                                    *reason = resolved;
                                    None
                                }
                                GoogleFinishReasonResolution::Error(message) => Some(message),
                            }
                        } else {
                            None
                        };
                        if let Some(message) = terminal_error {
                            let _ = tx
                                .send(StreamEvent::Error {
                                    message: message.to_string(),
                                    usage: Some(accumulated_usage),
                                })
                                .await;
                            return Ok(());
                        }
                        let is_stop = matches!(&event, StreamEvent::Stop { .. });
                        let is_error = matches!(&event, StreamEvent::Error { .. });
                        if tx.send(event).await.is_err() {
                            return Ok(()); // Receiver dropped
                        }
                        if is_stop || is_error {
                            return Ok(());
                        }
                    }
                }
            }
            // Ignore empty lines, comments, and event: lines
        }
        // Remove consumed bytes in one operation
        if consumed > 0 {
            buffer.drain(..consumed);
        }
    }

    // Gemini streams don't use a [DONE] sentinel; the stream simply ends.
    // If we haven't sent a terminal event yet, send one now (Stop or Error).
    match resolve_google_finish_reason(last_finish_reason.as_deref(), seen_tool_use) {
        GoogleFinishReasonResolution::Stop(reason) => {
            let _ = tx
                .send(StreamEvent::Stop {
                    reason,
                    usage: accumulated_usage,
                })
                .await;
        }
        GoogleFinishReasonResolution::Error(message) => {
            let _ = tx
                .send(StreamEvent::Error {
                    message: message.to_string(),
                    usage: Some(accumulated_usage),
                })
                .await;
        }
    }

    Ok(())
}

/// Parse a single SSE data payload from Gemini's streaming format.
///
/// Gemini streaming chunks look like:
/// ```json
/// {"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}
/// ```
///
/// Returns a Vec of StreamEvents since a single chunk can contain both text
/// and function calls in the parts array.
fn parse_gemini_sse_data(
    data: &str,
    accumulated_usage: &mut TokenUsage,
    last_finish_reason: &mut Option<String>,
) -> Option<Vec<StreamEvent>> {
    let parsed: Value = serde_json::from_str(data).ok()?;

    // Check for error response
    if let Some(error) = parsed.get("error") {
        let message = error
            .get("message")
            .and_then(|v| v.as_str())
            .unwrap_or("unknown API error")
            .to_string();
        return Some(vec![StreamEvent::Error {
            message,
            usage: Some(*accumulated_usage),
        }]);
    }

    // Extract usage metadata if present
    extract_gemini_usage(&parsed, accumulated_usage);

    // Process candidates
    let candidates = parsed.get("candidates")?.as_array()?;
    if candidates.is_empty() {
        return None;
    }

    let candidate = &candidates[0];

    // Check for finish reason
    if let Some(finish_reason) = candidate.get("finishReason").and_then(|v| v.as_str()) {
        *last_finish_reason = Some(finish_reason.to_string());
        return Some(parse_gemini_finish_chunk(
            candidate,
            finish_reason,
            accumulated_usage,
        ));
    }

    // No finish reason — process content parts
    let parts = candidate
        .get("content")
        .and_then(|c| c.get("parts"))
        .and_then(|p| p.as_array())?;

    let events = collect_gemini_part_events(parts);
    if events.is_empty() {
        None
    } else {
        Some(events)
    }
}

/// Extract usage metadata from a Gemini SSE chunk.
fn extract_gemini_usage(parsed: &Value, accumulated_usage: &mut TokenUsage) {
    if let Some(usage_meta) = parsed.get("usageMetadata") {
        if let Some(prompt_tokens) = usage_meta.get("promptTokenCount").and_then(|v| v.as_u64()) {
            accumulated_usage.input_tokens = prompt_tokens;
        }
        if let Some(candidates_tokens) = usage_meta
            .get("candidatesTokenCount")
            .and_then(|v| v.as_u64())
        {
            accumulated_usage.output_tokens = candidates_tokens;
        }
    }
}

/// Parse a Gemini chunk that includes a finish reason.
fn parse_gemini_finish_chunk(
    candidate: &Value,
    finish_reason: &str,
    accumulated_usage: &TokenUsage,
) -> Vec<StreamEvent> {
    let parts = candidate
        .get("content")
        .and_then(|c| c.get("parts"))
        .and_then(|p| p.as_array());

    let mut events = Vec::new();

    if let Some(parts) = parts {
        events.extend(collect_gemini_part_events(parts));
        if events
            .iter()
            .any(|event| matches!(event, StreamEvent::Error { .. }))
        {
            return events;
        }

        match resolve_google_finish_reason(
            Some(finish_reason),
            events.iter().any(StreamEvent::is_valid_tool_use),
        ) {
            GoogleFinishReasonResolution::Stop(reason) => events.push(StreamEvent::Stop {
                reason,
                usage: *accumulated_usage,
            }),
            GoogleFinishReasonResolution::Error(message) => events.push(StreamEvent::Error {
                message: message.to_string(),
                usage: Some(*accumulated_usage),
            }),
        }
    } else {
        match resolve_google_finish_reason(Some(finish_reason), false) {
            GoogleFinishReasonResolution::Stop(reason) => events.push(StreamEvent::Stop {
                reason,
                usage: *accumulated_usage,
            }),
            GoogleFinishReasonResolution::Error(message) => events.push(StreamEvent::Error {
                message: message.to_string(),
                usage: Some(*accumulated_usage),
            }),
        }
    }

    events
}

/// Collect stream events from Gemini content parts (text deltas and function calls).
fn collect_gemini_part_events(parts: &[Value]) -> Vec<StreamEvent> {
    let mut events = Vec::new();
    for part in parts {
        let metadata = gemini_part_metadata(part);
        if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
            if !text.is_empty() || metadata.is_some() {
                events.push(StreamEvent::TextDelta {
                    text: text.to_string(),
                    metadata: metadata.clone(),
                });
            }
        }
        if let Some(fc) = part.get("functionCall") {
            let Some(name) = fc
                .get("name")
                .and_then(|v| v.as_str())
                .map(str::trim)
                .filter(|name| !name.is_empty())
            else {
                events.push(StreamEvent::Error {
                    message: "Gemini stream returned functionCall without a name".to_string(),
                    usage: None,
                });
                continue;
            };
            let args = fc.get("args").cloned().unwrap_or(json!({}));
            let id = uuid::Uuid::new_v4().to_string();
            events.push(StreamEvent::ToolUse {
                id,
                name: name.to_string(),
                input: args,
                metadata,
            });
        }
    }
    events
}

/// Determine whether a model identifier should route to the Gemini provider.
///
/// Requires the canonical `gemini:` prefix (e.g. `gemini:gemini-2.5-flash`).
pub fn is_gemini_model(model: &str) -> bool {
    model.len() > 7
        && model.as_bytes()[..6].eq_ignore_ascii_case(b"gemini")
        && model.as_bytes()[6] == b':'
}

/// Strip the `gemini:` prefix from a model name.
///
/// Returns the bare model name suitable for passing to the Gemini API
/// (e.g. `gemini-2.5-flash`).
pub fn strip_gemini_prefix(model: &str) -> &str {
    if is_gemini_model(model) {
        &model[7..]
    } else {
        model
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::auth::profiles::{
        AuthProfile, AuthProfileCredentialKind, OAuthProvider, OAuthTokens, ProfileStore,
    };
    use std::sync::Arc;
    use std::time::{SystemTime, UNIX_EPOCH};

    // ==================== Provider construction tests ====================

    #[test]
    fn test_new_rejects_empty_api_key() {
        let result = GeminiProvider::new("".to_string());
        assert!(result.is_err(), "expected empty API key to fail");
        let err = result.unwrap_err().to_string();
        assert!(
            err.contains("empty"),
            "expected 'empty' in error, got redacted"
        );
    }

    #[test]
    fn test_new_rejects_whitespace_api_key() {
        let result = GeminiProvider::new("   ".to_string());
        assert!(result.is_err(), "expected whitespace API key to fail");
    }

    #[test]
    fn test_new_accepts_valid_api_key() {
        let result = GeminiProvider::new("AIzaSyA-valid-key-1234567890".to_string());
        assert!(result.is_ok(), "expected valid API key to pass");
    }

    #[tokio::test]
    async fn test_with_oauth_profile_uses_stored_access_token() {
        let temp = tempfile::tempdir().expect("tempdir");
        let store = Arc::new(ProfileStore::new(temp.path().to_path_buf()));
        let now_ms = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0);
        store
            .add(AuthProfile {
                id: "google-test".to_string(),
                name: "Gemini (Example User)".to_string(),
                provider: OAuthProvider::Google,
                user_id: Some("user-123".to_string()),
                email: Some("user@example.com".to_string()),
                display_name: Some("Example User".to_string()),
                avatar_url: None,
                created_at_ms: now_ms,
                last_used_ms: None,
                credential_kind: AuthProfileCredentialKind::OAuth,
                tokens: Some(OAuthTokens {
                    access_token: "access-token-123".to_string(),
                    refresh_token: None,
                    token_type: "Bearer".to_string(),
                    expires_at_ms: Some(now_ms + 3_600_000),
                    scope: Some("openid email profile".to_string()),
                }),
                token: None,
                oauth_provider_config: None,
            })
            .expect("store profile");

        let provider = GeminiProvider::with_oauth_profile(
            store.clone(),
            "google-test".to_string(),
            OAuthProvider::Google
                .default_config(
                    "client-id",
                    "client-secret",
                    "http://127.0.0.1:3000/auth/callback",
                )
                .unwrap(),
        )
        .expect("oauth-backed provider");

        let headers = provider.auth_headers().await.expect("auth headers");
        assert_eq!(
            headers,
            vec![("authorization", "Bearer access-token-123".to_string())]
        );
        assert!(
            store
                .get("google-test")
                .expect("stored profile")
                .last_used_ms
                .is_some(),
            "last_used_ms should update after successful Gemini auth resolution"
        );
    }

    #[tokio::test]
    async fn test_with_oauth_profile_errors_when_profile_missing() {
        let temp = tempfile::tempdir().expect("tempdir");
        let store = ProfileStore::new(temp.path().to_path_buf());
        let provider = GeminiProvider::with_oauth_profile(
            Arc::new(store),
            "missing-profile".to_string(),
            OAuthProvider::Google
                .default_config(
                    "client-id",
                    "client-secret",
                    "http://127.0.0.1:3000/auth/callback",
                )
                .unwrap(),
        )
        .expect("oauth-backed provider");

        let err = provider
            .auth_headers()
            .await
            .expect_err("missing profile should fail");
        assert!(err
            .to_string()
            .contains("configured Gemini auth profile \"missing-profile\" was not found"));
    }

    #[tokio::test]
    async fn test_with_oauth_profile_errors_when_access_token_empty() {
        let temp = tempfile::tempdir().expect("tempdir");
        let store = ProfileStore::new(temp.path().to_path_buf());
        let now_ms = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0);
        store
            .add(AuthProfile {
                id: "google-empty-token".to_string(),
                name: "Gemini (Empty Token)".to_string(),
                provider: OAuthProvider::Google,
                user_id: Some("user-123".to_string()),
                email: Some("user@example.com".to_string()),
                display_name: Some("Example User".to_string()),
                avatar_url: None,
                created_at_ms: now_ms,
                last_used_ms: None,
                credential_kind: AuthProfileCredentialKind::OAuth,
                tokens: Some(OAuthTokens {
                    access_token: "   ".to_string(),
                    refresh_token: None,
                    token_type: "Bearer".to_string(),
                    expires_at_ms: Some(now_ms + 3_600_000),
                    scope: Some("openid email profile".to_string()),
                }),
                token: None,
                oauth_provider_config: None,
            })
            .expect("store profile");

        let provider = GeminiProvider::with_oauth_profile(
            Arc::new(store),
            "google-empty-token".to_string(),
            OAuthProvider::Google
                .default_config(
                    "client-id",
                    "client-secret",
                    "http://127.0.0.1:3000/auth/callback",
                )
                .unwrap(),
        )
        .expect("oauth-backed provider");

        let err = provider
            .auth_headers()
            .await
            .expect_err("empty access token should fail");
        assert!(err
            .to_string()
            .contains("Gemini auth profile \"google-empty-token\" has no usable access token"));
    }

    #[tokio::test]
    async fn test_with_oauth_profile_errors_when_credential_kind_is_token() {
        let temp = tempfile::tempdir().expect("tempdir");
        let store = ProfileStore::new(temp.path().to_path_buf());
        let now_ms = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_millis() as u64)
            .unwrap_or(0);
        store
            .add(AuthProfile {
                id: "google-token-kind".to_string(),
                name: "Gemini (Token Kind)".to_string(),
                provider: OAuthProvider::Google,
                user_id: Some("user-123".to_string()),
                email: Some("user@example.com".to_string()),
                display_name: Some("Example User".to_string()),
                avatar_url: None,
                created_at_ms: now_ms,
                last_used_ms: None,
                credential_kind: AuthProfileCredentialKind::Token,
                tokens: None,
                token: Some("sk-ant-oat01-token".to_string()),
                oauth_provider_config: None,
            })
            .expect("store profile");

        let provider = GeminiProvider::with_oauth_profile(
            Arc::new(store),
            "google-token-kind".to_string(),
            OAuthProvider::Google
                .default_config(
                    "client-id",
                    "client-secret",
                    "http://127.0.0.1:3000/auth/callback",
                )
                .unwrap(),
        )
        .expect("oauth-backed provider");

        let err = provider
            .auth_headers()
            .await
            .expect_err("wrong credential kind should fail");
        assert!(err.to_string().contains(
            "Gemini auth profile \"google-token-kind\" uses token credentials, not oauth"
        ));
    }

    #[test]
    fn test_default_base_url() {
        let provider = GeminiProvider::new("test-key".to_string()).unwrap();
        assert_eq!(
            provider.base_url,
            "https://generativelanguage.googleapis.com"
        );
    }

    #[test]
    fn test_custom_base_url_accepted() {
        let provider = GeminiProvider::new("test-key".to_string())
            .unwrap()
            .with_base_url("https://proxy.example.com".to_string())
            .unwrap();
        assert_eq!(provider.base_url, "https://proxy.example.com");
    }

    #[test]
    fn test_custom_base_url_trailing_slash_stripped() {
        let provider = GeminiProvider::new("test-key".to_string())
            .unwrap()
            .with_base_url("https://proxy.example.com/".to_string())
            .unwrap();
        assert_eq!(provider.base_url, "https://proxy.example.com");
    }

    #[test]
    fn test_base_url_rejects_http() {
        let result = GeminiProvider::new("test-key".to_string())
            .unwrap()
            .with_base_url("http://insecure.example.com".to_string());
        assert!(result.is_err(), "expected http base URL to fail");
        let err = result.unwrap_err().to_string();
        assert!(err.contains("https"), "got: {err}");
    }

    #[test]
    fn test_base_url_rejects_invalid_url() {
        let result = GeminiProvider::new("test-key".to_string())
            .unwrap()
            .with_base_url("not-a-url".to_string());
        assert!(result.is_err(), "expected malformed base URL to fail");
    }

    // ==================== build_body tests ====================

    #[test]
    fn test_build_body_basic() {
        let provider = GeminiProvider::new("test-key".to_string()).unwrap();
        let request = CompletionRequest {
            model: "gemini-2.5-flash".to_string(),
            messages: vec![LlmMessage {
                role: LlmRole::User,
                content: vec![ContentBlock::Text {
                    text: "Hello".to_string(),
                    metadata: None,
                }],
            }],
            system: Some("You are helpful.".to_string()),
            tools: vec![],
            max_tokens: 1024,
            temperature: Some(0.7),
            extra: None,
        };
        let body = provider.build_body(&request);

        // System instruction
        assert_eq!(
            body["system_instruction"]["parts"][0]["text"],
            "You are helpful."
        );

        // Contents
        let contents = body["contents"].as_array().unwrap();
        assert_eq!(contents.len(), 1);
        assert_eq!(contents[0]["role"], "user");
        assert_eq!(contents[0]["parts"][0]["text"], "Hello");

        // Generation config
        assert_eq!(body["generationConfig"]["maxOutputTokens"], 1024);
        assert_eq!(body["generationConfig"]["temperature"], 0.7);

        // No tools key when tools is empty
        assert!(body.get("tools").is_none());
    }

    #[test]
    fn test_build_body_no_system() {
        let provider = GeminiProvider::new("test-key".to_string()).unwrap();
        let request = CompletionRequest {
            model: "gemini-2.5-flash".to_string(),
            messages: vec![LlmMessage {
                role: LlmRole::User,
                content: vec![ContentBlock::Text {
                    text: "Hi".to_string(),
                    metadata: None,
                }],
            }],
            system: None,
            tools: vec![],
            max_tokens: 1024,
            temperature: None,
            extra: None,
        };
        let body = provider.build_body(&request);

        // No system_instruction
        assert!(body.get("system_instruction").is_none());

        // Contents
        let contents = body["contents"].as_array().unwrap();
        assert_eq!(contents.len(), 1);
        assert_eq!(contents[0]["role"], "user");

        // No temperature in generation config
        assert!(body["generationConfig"].get("temperature").is_none());
    }

    #[test]
    fn test_build_body_with_tools() {
        let provider = GeminiProvider::new("test-key".to_string()).unwrap();
        let request = CompletionRequest {
            model: "gemini-2.5-flash".to_string(),
            messages: vec![],
            system: None,
            tools: vec![ToolDefinition {
                name: "get_weather".to_string(),
                description: "Get weather for a city".to_string(),
                input_schema: json!({
                    "type": "object",
                    "properties": {
                        "city": { "type": "string" }
                    }
                }),
            }],
            max_tokens: 4096,
            temperature: None,
            extra: None,
        };
        let body = provider.build_body(&request);

        assert!(body["tools"].is_array());
        let tools = body["tools"].as_array().unwrap();
        assert_eq!(tools.len(), 1);
        let func_decls = tools[0]["function_declarations"].as_array().unwrap();
        assert_eq!(func_decls.len(), 1);
        assert_eq!(func_decls[0]["name"], "get_weather");
        assert_eq!(func_decls[0]["description"], "Get weather for a city");
        assert!(func_decls[0]["parameters"]["properties"]["city"].is_object());
    }

    #[test]
    fn test_build_body_with_tool_results() {
        let provider = GeminiProvider::new("test-key".to_string()).unwrap();
        let request = CompletionRequest {
            model: "gemini-2.5-flash".to_string(),
            messages: vec![
                LlmMessage {
                    role: LlmRole::User,
                    content: vec![ContentBlock::Text {
                        text: "What's the weather?".to_string(),
                        metadata: None,
                    }],
                },
                LlmMessage {
                    role: LlmRole::Assistant,
                    content: vec![ContentBlock::ToolUse {
                        id: "call_abc123".to_string(),
                        name: "get_weather".to_string(),
                        input: json!({"city": "SF"}),
                        metadata: None,
                    }],
                },
                LlmMessage {
                    role: LlmRole::User,
                    content: vec![ContentBlock::ToolResult {
                        tool_use_id: "call_abc123".to_string(),
                        content: "72F and sunny".to_string(),
                        is_error: false,
                    }],
                },
            ],
            system: None,
            tools: vec![],
            max_tokens: 1024,
            temperature: None,
            extra: None,
        };
        let body = provider.build_body(&request);
        let contents = body["contents"].as_array().unwrap();
        assert_eq!(contents.len(), 3);

        // User message
        assert_eq!(contents[0]["role"], "user");
        assert_eq!(contents[0]["parts"][0]["text"], "What's the weather?");

        // Model message with functionCall
        assert_eq!(contents[1]["role"], "model");
        assert_eq!(
            contents[1]["parts"][0]["functionCall"]["name"],
            "get_weather"
        );
        assert_eq!(
            contents[1]["parts"][0]["functionCall"]["args"]["city"],
            "SF"
        );

        // User message with functionResponse
        assert_eq!(contents[2]["role"], "user");
        assert_eq!(
            contents[2]["parts"][0]["functionResponse"]["name"],
            "get_weather"
        );
        assert_eq!(
            contents[2]["parts"][0]["functionResponse"]["response"]["result"],
            "72F and sunny"
        );
    }

    #[test]
    fn test_build_body_multi_turn() {
        let provider = GeminiProvider::new("test-key".to_string()).unwrap();
        let request = CompletionRequest {
            model: "gemini-2.5-flash".to_string(),
            messages: vec![
                LlmMessage {
                    role: LlmRole::User,
                    content: vec![ContentBlock::Text {
                        text: "Hello".to_string(),
                        metadata: None,
                    }],
                },
                LlmMessage {
                    role: LlmRole::Assistant,
                    content: vec![ContentBlock::Text {
                        text: "Hi there!".to_string(),
                        metadata: None,
                    }],
                },
                LlmMessage {
                    role: LlmRole::User,
                    content: vec![ContentBlock::Text {
                        text: "How are you?".to_string(),
                        metadata: None,
                    }],
                },
            ],
            system: None,
            tools: vec![],
            max_tokens: 1024,
            temperature: None,
            extra: None,
        };
        let body = provider.build_body(&request);
        let contents = body["contents"].as_array().unwrap();
        assert_eq!(contents.len(), 3);
        assert_eq!(contents[0]["role"], "user");
        assert_eq!(contents[0]["parts"][0]["text"], "Hello");
        assert_eq!(contents[1]["role"], "model");
        assert_eq!(contents[1]["parts"][0]["text"], "Hi there!");
        assert_eq!(contents[2]["role"], "user");
        assert_eq!(contents[2]["parts"][0]["text"], "How are you?");
    }

    #[test]
    fn test_build_body_assistant_role_mapped_to_model() {
        let provider = GeminiProvider::new("test-key".to_string()).unwrap();
        let request = CompletionRequest {
            model: "gemini-2.5-flash".to_string(),
            messages: vec![LlmMessage {
                role: LlmRole::Assistant,
                content: vec![ContentBlock::Text {
                    text: "I am a model response.".to_string(),
                    metadata: None,
                }],
            }],
            system: None,
            tools: vec![],
            max_tokens: 1024,
            temperature: None,
            extra: None,
        };
        let body = provider.build_body(&request);
        let contents = body["contents"].as_array().unwrap();
        assert_eq!(contents[0]["role"], "model");
    }

    #[test]
    fn test_build_body_preserves_text_thought_signature() {
        let provider = GeminiProvider::new("test-key".to_string()).unwrap();
        let request = CompletionRequest {
            model: "gemini-2.5-flash".to_string(),
            messages: vec![LlmMessage {
                role: LlmRole::Assistant,
                content: vec![ContentBlock::Text {
                    text: "Hidden reasoning".to_string(),
                    metadata: ContentBlockMetadata::with_gemini_thought_signature(Some(
                        "sig-text".to_string(),
                    )),
                }],
            }],
            system: None,
            tools: vec![],
            max_tokens: 1024,
            temperature: None,
            extra: None,
        };

        let body = provider.build_body(&request);
        assert_eq!(body["contents"][0]["parts"][0]["text"], "Hidden reasoning");
        assert_eq!(
            body["contents"][0]["parts"][0]["thoughtSignature"],
            "sig-text"
        );
    }

    #[test]
    fn test_build_body_preserves_tool_call_thought_signature() {
        let provider = GeminiProvider::new("test-key".to_string()).unwrap();
        let request = CompletionRequest {
            model: "gemini-2.5-flash".to_string(),
            messages: vec![
                LlmMessage {
                    role: LlmRole::Assistant,
                    content: vec![ContentBlock::ToolUse {
                        id: "call_abc123".to_string(),
                        name: "get_weather".to_string(),
                        input: json!({"city": "SF"}),
                        metadata: ContentBlockMetadata::with_gemini_thought_signature(Some(
                            "sig-tool".to_string(),
                        )),
                    }],
                },
                LlmMessage {
                    role: LlmRole::User,
                    content: vec![ContentBlock::ToolResult {
                        tool_use_id: "call_abc123".to_string(),
                        content: "72F and sunny".to_string(),
                        is_error: false,
                    }],
                },
            ],
            system: None,
            tools: vec![],
            max_tokens: 1024,
            temperature: None,
            extra: None,
        };

        let body = provider.build_body(&request);
        assert_eq!(
            body["contents"][0]["parts"][0]["functionCall"]["name"],
            "get_weather"
        );
        assert_eq!(
            body["contents"][0]["parts"][0]["thoughtSignature"],
            "sig-tool"
        );
    }

    // ==================== SSE parsing tests ====================

    #[test]
    fn test_parse_text_delta() {
        let mut usage = TokenUsage::default();
        let mut finish_reason = None;
        let events = parse_gemini_sse_data(
            r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"}}]}"#,
            &mut usage,
            &mut finish_reason,
        );
        let events = events.unwrap();
        assert_eq!(events.len(), 1);
        match &events[0] {
            StreamEvent::TextDelta { text, .. } => assert_eq!(text, "Hello"),
            other => panic!("expected TextDelta, got {other:?}"),
        }
    }

    #[test]
    fn test_parse_text_delta_with_thought_signature() {
        let mut usage = TokenUsage::default();
        let mut finish_reason = None;
        let events = parse_gemini_sse_data(
            r#"{"candidates":[{"content":{"parts":[{"text":"Hello","thoughtSignature":"sig-text"}],"role":"model"}}]}"#,
            &mut usage,
            &mut finish_reason,
        )
        .expect("gemini response should parse");

        match &events[0] {
            StreamEvent::TextDelta { text, metadata } => {
                assert_eq!(text, "Hello");
                assert_eq!(
                    metadata
                        .as_ref()
                        .and_then(ContentBlockMetadata::gemini_thought_signature),
                    Some("sig-text")
                );
            }
            other => panic!("expected TextDelta, got {other:?}"),
        }
    }

    #[test]
    fn test_parse_empty_text_part_with_thought_signature() {
        let mut usage = TokenUsage::default();
        let mut finish_reason = None;
        let events = parse_gemini_sse_data(
            r#"{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"sig-empty"}],"role":"model"}}]}"#,
            &mut usage,
            &mut finish_reason,
        )
        .expect("gemini response should parse");

        match &events[0] {
            StreamEvent::TextDelta { text, metadata } => {
                assert_eq!(text, "");
                assert_eq!(
                    metadata
                        .as_ref()
                        .and_then(ContentBlockMetadata::gemini_thought_signature),
                    Some("sig-empty")
                );
            }
            other => panic!("expected TextDelta, got {other:?}"),
        }
    }

    #[test]
    fn test_parse_tool_call() {
        let mut usage = TokenUsage::default();
        let mut finish_reason = None;
        let events = parse_gemini_sse_data(
            r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"city":"SF"}}}],"role":"model"}}]}"#,
            &mut usage,
            &mut finish_reason,
        );
        let events = events.unwrap();
        assert_eq!(events.len(), 1);
        match &events[0] {
            StreamEvent::ToolUse {
                name, input, id, ..
            } => {
                assert_eq!(name, "get_weather");
                assert_eq!(input["city"], "SF");
                // ID should be a valid UUID
                assert!(!id.is_empty());
                assert!(uuid::Uuid::parse_str(id).is_ok());
            }
            other => panic!("expected ToolUse, got {other:?}"),
        }
    }

    #[test]
    fn test_parse_tool_call_with_thought_signature() {
        let mut usage = TokenUsage::default();
        let mut finish_reason = None;
        let events = parse_gemini_sse_data(
            r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"city":"SF"}},"thoughtSignature":"sig-tool"}],"role":"model"}}]}"#,
            &mut usage,
            &mut finish_reason,
        )
        .expect("gemini response should parse");

        match &events[0] {
            StreamEvent::ToolUse {
                name,
                input,
                metadata,
                ..
            } => {
                assert_eq!(name, "get_weather");
                assert_eq!(input["city"], "SF");
                assert_eq!(
                    metadata
                        .as_ref()
                        .and_then(ContentBlockMetadata::gemini_thought_signature),
                    Some("sig-tool")
                );
            }
            other => panic!("expected ToolUse, got {other:?}"),
        }
    }

    #[test]
    fn test_parse_stop_reason_end_turn() {
        let mut usage = TokenUsage {
            input_tokens: 100,
            output_tokens: 42,
        };
        let mut finish_reason = None;
        let events = parse_gemini_sse_data(
            r#"{"candidates":[{"content":{"parts":[{"text":"Done."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":42}}"#,
            &mut usage,
            &mut finish_reason,
        );
        let events = events.unwrap();
        // Should have TextDelta + Stop
        assert!(events.len() >= 2);
        match &events[events.len() - 1] {
            StreamEvent::Stop { reason, usage } => {
                assert_eq!(*reason, StopReason::EndTurn);
                assert_eq!(usage.input_tokens, 100);
                assert_eq!(usage.output_tokens, 42);
            }
            other => panic!("expected Stop, got {other:?}"),
        }
    }

    #[test]
    fn test_parse_stop_reason_max_tokens() {
        let mut usage = TokenUsage::default();
        let mut finish_reason = None;
        let events = parse_gemini_sse_data(
            r#"{"candidates":[{"content":{"parts":[{"text":"truncated"}],"role":"model"},"finishReason":"MAX_TOKENS"}]}"#,
            &mut usage,
            &mut finish_reason,
        );
        let events = events.unwrap();
        let stop = events
            .iter()
            .find(|e| matches!(e, StreamEvent::Stop { .. }));
        match stop {
            Some(StreamEvent::Stop { reason, .. }) => {
                assert_eq!(*reason, StopReason::MaxTokens);
            }
            other => panic!("expected Stop with MaxTokens, got {other:?}"),
        }
    }

    #[test]
    fn test_parse_stop_reason_safety() {
        let mut usage = TokenUsage::default();
        let mut finish_reason = None;
        let events = parse_gemini_sse_data(
            r#"{"candidates":[{"finishReason":"SAFETY"}]}"#,
            &mut usage,
            &mut finish_reason,
        );
        let events = events.unwrap();
        match &events[0] {
            StreamEvent::Stop { reason, .. } => {
                assert_eq!(*reason, StopReason::EndTurn);
            }
            other => panic!("expected Stop, got {other:?}"),
        }
    }

    #[test]
    fn test_parse_usage_extraction() {
        let mut usage = TokenUsage::default();
        let mut finish_reason = None;
        parse_gemini_sse_data(
            r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":42,"totalTokenCount":192}}"#,
            &mut usage,
            &mut finish_reason,
        );
        assert_eq!(usage.input_tokens, 150);
        assert_eq!(usage.output_tokens, 42);
    }

    #[test]
    fn test_parse_error_response() {
        let mut usage = TokenUsage::default();
        let mut finish_reason = None;
        let events = parse_gemini_sse_data(
            r#"{"error":{"message":"API key not valid","status":"INVALID_ARGUMENT","code":400}}"#,
            &mut usage,
            &mut finish_reason,
        );
        let events = events.unwrap();
        assert_eq!(events.len(), 1);
        match &events[0] {
            StreamEvent::Error { message, .. } => {
                assert_eq!(message, "API key not valid");
            }
            other => panic!("expected Error, got {other:?}"),
        }
    }

    #[test]
    fn test_parse_invalid_json_returns_none() {
        let mut usage = TokenUsage::default();
        let mut finish_reason = None;
        let events = parse_gemini_sse_data("not valid json", &mut usage, &mut finish_reason);
        assert!(events.is_none());
    }

    #[test]
    fn test_parse_empty_candidates_returns_none() {
        let mut usage = TokenUsage::default();
        let mut finish_reason = None;
        let events = parse_gemini_sse_data(r#"{"candidates":[]}"#, &mut usage, &mut finish_reason);
        assert!(events.is_none());
    }

    #[test]
    fn test_parse_function_call_with_finish_reason() {
        let mut usage = TokenUsage::default();
        let mut finish_reason = None;
        let events = parse_gemini_sse_data(
            r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"search","args":{"q":"test"}}}],"role":"model"},"finishReason":"STOP"}]}"#,
            &mut usage,
            &mut finish_reason,
        );
        let events = events.unwrap();
        // Should have ToolUse + Stop (with ToolUse reason since there are function calls)
        assert!(events.len() >= 2);
        match &events[0] {
            StreamEvent::ToolUse { name, input, .. } => {
                assert_eq!(name, "search");
                assert_eq!(input["q"], "test");
            }
            other => panic!("expected ToolUse, got {other:?}"),
        }
        match &events[events.len() - 1] {
            StreamEvent::Stop { reason, .. } => {
                assert_eq!(*reason, StopReason::ToolUse);
            }
            other => panic!("expected Stop with ToolUse reason, got {other:?}"),
        }
    }

    // ==================== is_gemini_model tests ====================

    #[test]
    fn test_is_gemini_model() {
        assert!(is_gemini_model("gemini:gemini-2.5-flash"));
        assert!(is_gemini_model("gemini:gemini-1.5-pro"));
        assert!(is_gemini_model("Gemini:gemini-2.5-flash")); // case insensitive
        assert!(is_gemini_model("GEMINI:gemini-2.5-flash")); // case insensitive

        assert!(!is_gemini_model("gemini-2.5-flash")); // bare name no longer matches
        assert!(!is_gemini_model("gemini/gemini-2.5-flash")); // slash no longer accepted
        assert!(!is_gemini_model("models/gemini-2.5-flash")); // models/ no longer accepted
        assert!(!is_gemini_model("gpt-5.5"));
        assert!(!is_gemini_model("claude-sonnet-4-6"));
        assert!(!is_gemini_model("ollama:llama3.2"));
    }

    // ==================== strip_gemini_prefix tests ====================

    #[test]
    fn test_strip_gemini_colon_prefix() {
        assert_eq!(
            strip_gemini_prefix("gemini:gemini-2.5-flash"),
            "gemini-2.5-flash"
        );
    }

    #[test]
    fn test_strip_no_prefix_returns_unchanged() {
        assert_eq!(strip_gemini_prefix("gemini-2.5-flash"), "gemini-2.5-flash");
    }

    #[test]
    fn test_strip_case_variant() {
        assert_eq!(
            strip_gemini_prefix("Gemini:gemini-2.5-flash"),
            "gemini-2.5-flash"
        );
    }

    // ==================== Integration-style stream processing tests ====================

    /// Helper: build a mock byte stream from raw SSE text chunks.
    fn mock_sse_stream(
        chunks: Vec<&str>,
    ) -> futures_util::stream::Iter<std::vec::IntoIter<Result<bytes::Bytes, reqwest::Error>>> {
        let items: Vec<Result<bytes::Bytes, reqwest::Error>> = chunks
            .into_iter()
            .map(|s| Ok(bytes::Bytes::from(s.to_owned())))
            .collect();
        futures_util::stream::iter(items)
    }

    async fn collect_gemini_stream_events(chunks: Vec<&str>) -> Vec<StreamEvent> {
        let stream = mock_sse_stream(chunks);
        let (tx, mut rx) = mpsc::channel(64);

        let result = process_gemini_sse_stream(stream, &tx, &CancellationToken::new()).await;
        assert!(result.is_ok(), "expected Ok, got: {:?}", result);

        let mut events = Vec::new();
        while let Ok(event) = rx.try_recv() {
            events.push(event);
        }
        events
    }

    #[tokio::test]
    async fn test_complete_text_stream() {
        let sse_data = concat!(
            "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello\"}],\"role\":\"model\"}}]}\n\n",
            "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\" world\"}],\"role\":\"model\"}}]}\n\n",
            "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"!\"}],\"role\":\"model\"},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":10,\"candidatesTokenCount\":5}}\n\n",
        );

        let stream = mock_sse_stream(vec![sse_data]);
        let (tx, mut rx) = mpsc::channel(64);

        let result = process_gemini_sse_stream(stream, &tx, &CancellationToken::new()).await;
        assert!(result.is_ok(), "expected Ok, got: {:?}", result);

        let mut events = Vec::new();
        while let Ok(event) = rx.try_recv() {
            events.push(event);
        }

        // Should have TextDelta events and Stop
        assert!(
            events
                .iter()
                .any(|e| matches!(e, StreamEvent::TextDelta { text, .. } if text == "Hello")),
            "expected TextDelta 'Hello', got: {:?}",
            events,
        );
        assert!(
            events
                .iter()
                .any(|e| matches!(e, StreamEvent::TextDelta { text, .. } if text == " world")),
            "expected TextDelta ' world', got: {:?}",
            events,
        );
        assert!(
            events.iter().any(|e| matches!(
                e,
                StreamEvent::Stop {
                    reason: StopReason::EndTurn,
                    ..
                }
            )),
            "expected Stop with EndTurn, got: {:?}",
            events,
        );
    }

    #[tokio::test]
    async fn test_complete_tool_call_stream() {
        let sse_data = "data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":20,\"candidatesTokenCount\":10}}\n\n";

        let stream = mock_sse_stream(vec![sse_data]);
        let (tx, mut rx) = mpsc::channel(64);

        let result = process_gemini_sse_stream(stream, &tx, &CancellationToken::new()).await;
        assert!(result.is_ok(), "expected Ok, got: {:?}", result);

        let mut events = Vec::new();
        while let Ok(event) = rx.try_recv() {
            events.push(event);
        }

        // Should have ToolUse and Stop
        assert!(
            events.iter().any(|e| matches!(
                e,
                StreamEvent::ToolUse { name, .. }
                if name == "get_weather"
            )),
            "expected ToolUse for get_weather, got: {:?}",
            events,
        );
        assert!(
            events.iter().any(|e| matches!(
                e,
                StreamEvent::Stop {
                    reason: StopReason::ToolUse,
                    ..
                }
            )),
            "expected Stop with ToolUse, got: {:?}",
            events,
        );
    }

    #[tokio::test]
    async fn test_split_tool_call_stream() {
        // Stream where tool call arrives in the first chunk, and finish reason (STOP) in a subsequent chunk without any parts
        let chunk1 = "data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"}}]}\n\n";
        let chunk2 = "data: {\"candidates\":[{\"content\":{\"parts\":[]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":20,\"candidatesTokenCount\":10}}\n\n";

        let stream = mock_sse_stream(vec![chunk1, chunk2]);
        let (tx, mut rx) = mpsc::channel(64);

        let result = process_gemini_sse_stream(stream, &tx, &CancellationToken::new()).await;
        assert!(result.is_ok(), "expected Ok, got: {:?}", result);

        let mut events = Vec::new();
        while let Ok(event) = rx.try_recv() {
            events.push(event);
        }

        // Should have ToolUse and Stop
        assert!(
            events.iter().any(|e| matches!(
                e,
                StreamEvent::ToolUse { name, .. }
                if name == "get_weather"
            )),
            "expected ToolUse for get_weather, got: {:?}",
            events,
        );
        assert!(
            events.iter().any(|e| matches!(
                e,
                StreamEvent::Stop {
                    reason: StopReason::ToolUse,
                    ..
                }
            )),
            "expected Stop with ToolUse, got: {:?}",
            events,
        );
    }

    #[tokio::test]
    async fn test_split_tool_call_with_safety_finish_reason_does_not_execute_tool() {
        let chunk1 = "data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"}}]}\n\n";
        let chunk2 = "data: {\"candidates\":[{\"content\":{\"parts\":[]},\"finishReason\":\"SAFETY\"}],\"usageMetadata\":{\"promptTokenCount\":20,\"candidatesTokenCount\":10}}\n\n";

        let events = collect_gemini_stream_events(vec![chunk1, chunk2]).await;

        assert!(
            events.iter().any(|e| matches!(
                e,
                StreamEvent::Stop {
                    reason: StopReason::EndTurn,
                    ..
                }
            )),
            "expected safety finish to remain EndTurn, got: {:?}",
            events,
        );
        assert!(
            !events.iter().any(|e| matches!(
                e,
                StreamEvent::Stop {
                    reason: StopReason::ToolUse,
                    ..
                }
            )),
            "safety finish must not dispatch tools: {:?}",
            events,
        );
    }

    #[tokio::test]
    async fn test_split_tool_call_with_malformed_finish_reason_errors() {
        let chunk1 = "data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"}}]}\n\n";
        let chunk2 = "data: {\"candidates\":[{\"content\":{\"parts\":[]},\"finishReason\":\"MALFORMED_FUNCTION_CALL\"}],\"usageMetadata\":{\"promptTokenCount\":20,\"candidatesTokenCount\":10}}\n\n";

        let events = collect_gemini_stream_events(vec![chunk1, chunk2]).await;

        assert!(
            events
                .iter()
                .any(|e| matches!(e, StreamEvent::Error { message, .. } if message.contains("malformed function call"))),
            "expected malformed function call error, got: {:?}",
            events,
        );
        assert!(
            !events.iter().any(|e| matches!(e, StreamEvent::Stop { .. })),
            "malformed function call must not emit Stop: {:?}",
            events,
        );
    }

    #[tokio::test]
    async fn test_split_tool_call_with_max_tokens_keeps_max_tokens() {
        let chunk1 = "data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"}}]}\n\n";
        let chunk2 = "data: {\"candidates\":[{\"content\":{\"parts\":[]},\"finishReason\":\"MAX_TOKENS\"}],\"usageMetadata\":{\"promptTokenCount\":20,\"candidatesTokenCount\":10}}\n\n";

        let events = collect_gemini_stream_events(vec![chunk1, chunk2]).await;

        assert!(
            events.iter().any(|e| matches!(
                e,
                StreamEvent::Stop {
                    reason: StopReason::MaxTokens,
                    ..
                }
            )),
            "expected MaxTokens to win over tool use, got: {:?}",
            events,
        );
        assert!(
            !events.iter().any(|e| matches!(
                e,
                StreamEvent::Stop {
                    reason: StopReason::ToolUse,
                    ..
                }
            )),
            "MAX_TOKENS must not promote partial tool call to ToolUse: {:?}",
            events,
        );
    }

    #[tokio::test]
    async fn test_stream_ends_after_tool_call_sends_stop_tool_use() {
        let sse_data = "data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"}}]}\n\n";

        let events = collect_gemini_stream_events(vec![sse_data]).await;

        assert!(
            events.iter().any(|e| matches!(
                e,
                StreamEvent::Stop {
                    reason: StopReason::ToolUse,
                    ..
                }
            )),
            "expected EOF after valid tool call to synthesize ToolUse stop, got: {:?}",
            events,
        );
    }

    #[tokio::test]
    async fn test_empty_name_function_call_errors_without_tool_use() {
        let sse_data = "data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"args\":{\"city\":\"SF\"}}}],\"role\":\"model\"}}]}\n\n";

        let events = collect_gemini_stream_events(vec![sse_data]).await;

        assert!(
            events
                .iter()
                .any(|e| matches!(e, StreamEvent::Error { message, .. } if message.contains("without a name"))),
            "expected missing function name error, got: {:?}",
            events,
        );
        assert!(
            !events.iter().any(StreamEvent::is_valid_tool_use),
            "empty-name function call must not become executable tool use: {:?}",
            events,
        );
        assert!(
            !events.iter().any(|e| matches!(
                e,
                StreamEvent::Stop {
                    reason: StopReason::ToolUse,
                    ..
                }
            )),
            "empty-name function call must not synthesize a ToolUse stop: {:?}",
            events,
        );
    }

    #[tokio::test]
    async fn test_stream_ends_without_finish_reason_sends_stop() {
        // Stream ends without a finishReason in any chunk — should still emit Stop
        let sse_data = "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hello\"}],\"role\":\"model\"}}]}\n\n";

        let stream = mock_sse_stream(vec![sse_data]);
        let (tx, mut rx) = mpsc::channel(64);

        let result = process_gemini_sse_stream(stream, &tx, &CancellationToken::new()).await;
        assert!(result.is_ok(), "expected Ok, got: {:?}", result);

        let mut events = Vec::new();
        while let Ok(event) = rx.try_recv() {
            events.push(event);
        }

        // Should have TextDelta and a Stop event added by the stream end handler
        assert!(
            events
                .iter()
                .any(|e| matches!(e, StreamEvent::TextDelta { text, .. } if text == "Hello")),
            "expected TextDelta 'Hello', got: {:?}",
            events,
        );
        assert!(
            events.iter().any(|e| matches!(e, StreamEvent::Stop { .. })),
            "expected Stop event, got: {:?}",
            events,
        );
    }

    #[tokio::test]
    async fn test_error_in_stream_sends_error_event() {
        let sse_data = "data: {\"error\":{\"message\":\"Quota exceeded\",\"status\":\"RESOURCE_EXHAUSTED\",\"code\":429}}\n\n";

        let stream = mock_sse_stream(vec![sse_data]);
        let (tx, mut rx) = mpsc::channel(64);

        let result = process_gemini_sse_stream(stream, &tx, &CancellationToken::new()).await;
        assert!(result.is_ok());

        let mut events = Vec::new();
        while let Ok(event) = rx.try_recv() {
            events.push(event);
        }

        assert!(
            events.iter().any(|e| matches!(
                e,
                StreamEvent::Error { message, .. }
                if message == "Quota exceeded"
            )),
            "expected Error event, got: {:?}",
            events,
        );
    }
}
