use crate::config::paths::Paths;
use crate::providers::api_client::{ApiClient, AuthMethod};
use crate::providers::oauth_device_flow::{run_device_flow, DeviceFlowConfig, RequestEncoding};
use crate::providers::openai_compatible::{
    handle_status, stream_openai_compat, stream_responses_compat,
};
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use axum::http;
use chrono::{DateTime, Utc};
use reqwest::{Client, Response};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;

// Task-local so complete() and stream() can't race on the same provider instance.
tokio::task_local! {
    static IS_AGENT_CALL: bool;
}

use super::base::{
    collect_stream, Provider, ProviderDef, ProviderMetadata, ProviderUsage, Usage,
    DEFAULT_PROVIDER_TIMEOUT_SECS,
};
use super::errors::ProviderError;
use super::formats::openai::{create_request, get_usage, response_to_message};
use super::formats::openai_responses::create_responses_request;
use super::openai_compatible::handle_response_openai_compat;
use super::retry::ProviderRetry;
use super::utils::{get_model, is_openai_responses_model, ImageFormat, RequestLog};

use crate::config::{Config, ConfigError};
use crate::conversation::message::{Message, MessageContent};

use crate::model::ModelConfig;
use crate::providers::base::{ConfigKey, MessageStream};
use futures::future::BoxFuture;
use rmcp::model::{RawContent, Tool};
use std::ops::Deref;

const GITHUB_COPILOT_PROVIDER_NAME: &str = "github_copilot";
pub const GITHUB_COPILOT_DEFAULT_MODEL: &str = "gpt-4.1";
pub const GITHUB_COPILOT_KNOWN_MODELS: &[&str] = &[
    "claude-haiku-4.5",
    "claude-opus-4.5",
    "claude-opus-4.6",
    "claude-opus-4.7",
    "claude-sonnet-4",
    "claude-sonnet-4.5",
    "claude-sonnet-4.6",
    "gemini-2.5-pro",
    "gemini-3-flash-preview",
    "gemini-3.1-pro-preview",
    "gpt-4.1",
    "gpt-4o",
    "grok-code-fast-1",
    "gpt-5-mini",
    "gpt-5.2",
    "gpt-5.2-codex",
    "gpt-5.3-codex",
    "gpt-5.4",
    "gpt-5.4-mini",
    "gpt-5.5",
];

// Models that support streaming on the /chat/completions path.
// Models routed to /responses always stream and don't need to be listed here.
pub const GITHUB_COPILOT_STREAM_MODELS: &[&str] = &[
    "gpt-4.1",
    "gpt-4o",
    "grok-code-fast-1",
    "gemini-2.5-pro",
    "gemini-3-flash-preview",
    "gemini-3.1-pro-preview",
];

const GITHUB_COPILOT_DOC_URL: &str =
    "https://docs.github.com/en/copilot/using-github-copilot/ai-models";
const DEFAULT_GITHUB_HOST: &str = "github.com";
const DEFAULT_GITHUB_COPILOT_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";

fn normalize_host(host: &str) -> String {
    let host = host.trim_end_matches('/');
    let host = host.strip_prefix("https://").unwrap_or(host);
    host.to_string()
}

#[derive(Debug, Clone)]
struct GithubCopilotUrls {
    device_code_url: String,
    access_token_url: String,
    copilot_token_url: String,
}

impl GithubCopilotUrls {
    fn new(host: &str, copilot_token_url: Option<&str>) -> Self {
        if host == "github.com" {
            Self {
                device_code_url: "https://github.com/login/device/code".to_string(),
                access_token_url: "https://github.com/login/oauth/access_token".to_string(),
                copilot_token_url: "https://api.github.com/copilot_internal/v2/token".to_string(),
            }
        } else {
            let base = format!("https://{}", host);
            let copilot_token_url = copilot_token_url
                .map(|u| u.trim_end_matches('/').to_string())
                .unwrap_or_else(|| format!("https://api.{}/copilot_internal/v2/token", host));
            Self {
                device_code_url: format!("{}/login/device/code", base),
                access_token_url: format!("{}/login/oauth/access_token", base),
                copilot_token_url,
            }
        }
    }
}

#[derive(Debug, Serialize, Deserialize, Clone)]
struct CopilotTokenEndpoints {
    api: String,
    #[serde(flatten)]
    _extra: HashMap<String, Value>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
#[allow(dead_code)] // useful for debugging
struct CopilotTokenInfo {
    token: String,
    expires_at: i64,
    refresh_in: i64,
    endpoints: CopilotTokenEndpoints,
    #[serde(flatten)]
    _extra: HashMap<String, Value>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
struct CopilotState {
    expires_at: DateTime<Utc>,
    info: CopilotTokenInfo,
}

#[derive(Debug)]
struct DiskCache {
    cache_path: PathBuf,
}

impl DiskCache {
    fn new(host: &str) -> Self {
        let cache_path = if host == DEFAULT_GITHUB_HOST {
            Paths::in_config_dir("githubcopilot/info.json")
        } else {
            let safe_host = host.replace(['/', ':', '.'], "_");
            Paths::in_config_dir(&format!("githubcopilot/{}/info.json", safe_host))
        };
        Self { cache_path }
    }

    async fn load(&self) -> Option<CopilotState> {
        if let Ok(contents) = tokio::fs::read_to_string(&self.cache_path).await {
            if let Ok(info) = serde_json::from_str::<CopilotState>(&contents) {
                return Some(info);
            }
        }
        None
    }

    async fn save(&self, info: &CopilotState) -> Result<()> {
        if let Some(parent) = self.cache_path.parent() {
            tokio::fs::create_dir_all(parent).await?;
        }
        let contents = serde_json::to_string(info)?;
        tokio::fs::write(&self.cache_path, contents).await?;
        Ok(())
    }

    async fn clear(&self) -> Result<()> {
        match tokio::fs::remove_file(&self.cache_path).await {
            Ok(()) => Ok(()),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(e) => Err(e.into()),
        }
    }
}

#[derive(Debug, serde::Serialize)]
pub struct GithubCopilotProvider {
    #[serde(skip)]
    client: Client,
    #[serde(skip)]
    cache: DiskCache,
    #[serde(skip)]
    mu: tokio::sync::Mutex<RefCell<Option<CopilotState>>>,
    model: ModelConfig,
    #[serde(skip)]
    urls: GithubCopilotUrls,
    #[serde(skip)]
    client_id: String,
    #[serde(skip)]
    name: String,
}

impl GithubCopilotProvider {
    pub async fn cleanup() -> Result<()> {
        let config = Config::global();
        let host = normalize_host(
            &config
                .get_param::<String>("GITHUB_COPILOT_HOST")
                .unwrap_or_else(|_| DEFAULT_GITHUB_HOST.to_string()),
        );
        DiskCache::new(&host).clear().await
    }

    fn messages_contain_image(messages: &[Message]) -> bool {
        messages.iter().any(|m| {
            m.content.iter().any(|c| match c {
                MessageContent::Image(_) => true,
                MessageContent::ToolResponse(resp) => resp.tool_result.as_ref().is_ok_and(|r| {
                    r.content
                        .iter()
                        .any(|item| matches!(item.deref(), RawContent::Image(_)))
                }),
                _ => false,
            })
        })
    }

    pub async fn from_env(model: ModelConfig) -> Result<Self> {
        let config = Config::global();
        let host = normalize_host(
            &config
                .get_param::<String>("GITHUB_COPILOT_HOST")
                .unwrap_or_else(|_| DEFAULT_GITHUB_HOST.to_string()),
        );
        let client_id: String = config
            .get_param("GITHUB_COPILOT_CLIENT_ID")
            .unwrap_or_else(|_| DEFAULT_GITHUB_COPILOT_CLIENT_ID.to_string());
        let copilot_token_url: Option<String> = config.get_param("GITHUB_COPILOT_TOKEN_URL").ok();
        let urls = GithubCopilotUrls::new(&host, copilot_token_url.as_deref());
        let client = Client::builder()
            .timeout(Duration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS))
            .build()?;
        let cache = DiskCache::new(&host);
        let mu = tokio::sync::Mutex::new(RefCell::new(None));
        Ok(Self {
            client,
            cache,
            mu,
            model,
            urls,
            client_id,
            name: GITHUB_COPILOT_PROVIDER_NAME.to_string(),
        })
    }

    async fn post(
        &self,
        session_id: Option<&str>,
        path: &str,
        is_user_initiated: bool,
        payload: &mut Value,
        has_images: bool,
    ) -> Result<Response, ProviderError> {
        let (endpoint, token) = self.get_api_info().await?;
        let auth = AuthMethod::BearerToken(token);
        let mut headers = self.get_github_headers();
        if has_images {
            headers.insert("Copilot-Vision-Request", "true".parse().unwrap());
        }
        let initiator = if is_user_initiated { "user" } else { "agent" };
        headers.insert("X-Initiator", initiator.parse().unwrap());
        let api_client = ApiClient::new(endpoint.clone(), auth)?.with_headers(headers)?;

        api_client
            .response_post(session_id, path, payload)
            .await
            .map_err(|e| e.into())
    }

    async fn get_api_info(&self) -> Result<(String, String)> {
        let guard = self.mu.lock().await;

        if let Some(state) = guard.borrow().as_ref() {
            if state.expires_at > Utc::now() {
                return Ok((state.info.endpoints.api.clone(), state.info.token.clone()));
            }
        }

        if let Some(state) = self.cache.load().await {
            if guard.borrow().is_none() {
                guard.replace(Some(state.clone()));
            }
            if state.expires_at > Utc::now() {
                return Ok((state.info.endpoints.api, state.info.token));
            }
        }

        const MAX_ATTEMPTS: i32 = 3;
        for attempt in 0..MAX_ATTEMPTS {
            tracing::trace!("attempt {} to refresh api info", attempt + 1);
            let info = match self.refresh_api_info().await {
                Ok(data) => data,
                Err(err) => {
                    tracing::warn!("failed to refresh api info: {}", err);
                    continue;
                }
            };
            let expires_at = Utc::now() + chrono::Duration::seconds(info.refresh_in);
            let new_state = CopilotState { info, expires_at };
            self.cache.save(&new_state).await?;
            guard.replace(Some(new_state.clone()));
            return Ok((new_state.info.endpoints.api, new_state.info.token));
        }
        Err(anyhow!("failed to get api info after 3 attempts"))
    }

    async fn refresh_api_info(&self) -> Result<CopilotTokenInfo> {
        let config = Config::global();
        let token = match config.get_secret::<String>("GITHUB_COPILOT_TOKEN") {
            Ok(token) => token,
            Err(err) => match err {
                ConfigError::NotFound(_) => {
                    let token = self
                        .get_access_token()
                        .await
                        .context("unable to login into github")?;
                    config.set_secret("GITHUB_COPILOT_TOKEN", &token)?;
                    token
                }
                _ => return Err(err.into()),
            },
        };
        let resp = self
            .client
            .get(&self.urls.copilot_token_url)
            .headers(self.get_github_headers())
            .header(http::header::AUTHORIZATION, format!("bearer {}", &token))
            .send()
            .await?
            .error_for_status()?
            .text()
            .await?;
        tracing::trace!("copilot token response: {}", resp);
        let info: CopilotTokenInfo = serde_json::from_str(&resp)?;
        Ok(info)
    }

    async fn get_access_token(&self) -> Result<String> {
        for attempt in 0..3 {
            tracing::trace!("attempt {} to get access token", attempt + 1);
            match self.login().await {
                Ok(token) => return Ok(token),
                Err(err) => tracing::warn!("failed to get access token: {}", err),
            }
        }
        Err(anyhow!("failed to get access token after 3 attempts"))
    }

    async fn login(&self) -> Result<String> {
        let cfg = DeviceFlowConfig {
            device_auth_url: Some(&self.urls.device_code_url),
            token_url: &self.urls.access_token_url,
            client_id: &self.client_id,
            scopes: Some("read:user"),
            extra_headers: self.get_github_headers(),
            encoding: RequestEncoding::Json,
        };
        let tokens = run_device_flow(&self.client, &cfg).await?;
        Ok(tokens.access_token)
    }

    fn get_github_headers(&self) -> http::HeaderMap {
        let mut headers = http::HeaderMap::new();
        headers.insert(http::header::ACCEPT, "application/json".parse().unwrap());
        headers.insert(
            http::header::CONTENT_TYPE,
            "application/json".parse().unwrap(),
        );
        headers.insert(
            http::header::USER_AGENT,
            "GithubCopilot/1.155.0".parse().unwrap(),
        );
        headers.insert("editor-version", "vscode/1.85.1".parse().unwrap());
        headers.insert("editor-plugin-version", "copilot/1.155.0".parse().unwrap());
        headers
    }

    #[allow(clippy::too_many_arguments)]
    async fn stream_responses(
        &self,
        model_config: &ModelConfig,
        session_id: &str,
        is_user_initiated: bool,
        system: &str,
        messages: &[Message],
        tools: &[Tool],
        has_images: bool,
    ) -> Result<MessageStream, ProviderError> {
        let mut payload = create_responses_request(model_config, system, messages, tools)
            .map_err(|e| ProviderError::RequestFailed(e.to_string()))?;
        payload["stream"] = serde_json::Value::Bool(true);

        let mut log = RequestLog::start(model_config, &payload)?;

        let response = self
            .with_retry(|| async {
                let mut payload_clone = payload.clone();
                let resp = self
                    .post(
                        Some(session_id),
                        "responses",
                        is_user_initiated,
                        &mut payload_clone,
                        has_images,
                    )
                    .await?;
                handle_status(resp).await
            })
            .await
            .inspect_err(|e| {
                let _ = log.error(e);
            })?;

        stream_responses_compat(response, log)
    }

    #[allow(clippy::too_many_arguments)]
    async fn stream_chat_completions(
        &self,
        model_config: &ModelConfig,
        session_id: &str,
        is_user_initiated: bool,
        system: &str,
        messages: &[Message],
        tools: &[Tool],
        has_images: bool,
    ) -> Result<MessageStream, ProviderError> {
        let supports_streaming = GITHUB_COPILOT_STREAM_MODELS
            .iter()
            .any(|prefix| model_config.model_name.starts_with(prefix));

        if supports_streaming {
            let payload = create_request(
                model_config,
                system,
                messages,
                tools,
                &ImageFormat::OpenAi,
                true,
            )?;
            let mut log = RequestLog::start(model_config, &payload)?;

            let response = self
                .with_retry(|| async {
                    let mut payload_clone = payload.clone();
                    let resp = self
                        .post(
                            Some(session_id),
                            "chat/completions",
                            is_user_initiated,
                            &mut payload_clone,
                            has_images,
                        )
                        .await?;
                    handle_status(resp).await
                })
                .await
                .inspect_err(|e| {
                    let _ = log.error(e);
                })?;

            stream_openai_compat(response, log)
        } else {
            let session_id_opt = if session_id.is_empty() {
                None
            } else {
                Some(session_id)
            };
            let payload = create_request(
                model_config,
                system,
                messages,
                tools,
                &ImageFormat::OpenAi,
                false,
            )?;
            let mut log = RequestLog::start(model_config, &payload)?;

            let response = self
                .with_retry(|| async {
                    let mut payload_clone = payload.clone();
                    self.post(
                        session_id_opt,
                        "chat/completions",
                        is_user_initiated,
                        &mut payload_clone,
                        has_images,
                    )
                    .await
                })
                .await?;
            let response = handle_response_openai_compat(response).await?;

            let response = promote_tool_choice(response);

            let message = response_to_message(&response)?;
            let usage = response.get("usage").map(get_usage).unwrap_or_else(|| {
                tracing::debug!("Failed to get usage data");
                Usage::default()
            });
            let response_model = get_model(&response);
            log.write(&response, Some(&usage))?;

            Ok(super::base::stream_from_single_message(
                message,
                ProviderUsage::new(response_model, usage),
            ))
        }
    }
}

impl ProviderDef for GithubCopilotProvider {
    type Provider = Self;

    fn metadata() -> ProviderMetadata {
        ProviderMetadata::new(
            GITHUB_COPILOT_PROVIDER_NAME,
            "GitHub Copilot",
            "GitHub Copilot. Run `goose configure` and select copilot to set up.",
            GITHUB_COPILOT_DEFAULT_MODEL,
            GITHUB_COPILOT_KNOWN_MODELS.to_vec(),
            GITHUB_COPILOT_DOC_URL,
            vec![
                ConfigKey::new_oauth_device_code("GITHUB_COPILOT_TOKEN", true, true, None, false),
                ConfigKey::new("GITHUB_COPILOT_HOST", false, false, None, false),
                ConfigKey::new("GITHUB_COPILOT_CLIENT_ID", false, false, None, false),
                ConfigKey::new("GITHUB_COPILOT_TOKEN_URL", false, false, None, false),
            ],
        )
    }

    fn from_env(
        model: ModelConfig,
        _extensions: Vec<crate::config::ExtensionConfig>,
    ) -> BoxFuture<'static, Result<Self::Provider>> {
        Box::pin(Self::from_env(model))
    }
}

#[async_trait]
impl Provider for GithubCopilotProvider {
    fn get_name(&self) -> &str {
        &self.name
    }

    fn get_model_config(&self) -> ModelConfig {
        self.model.clone()
    }

    #[tracing::instrument(
        skip(self, model_config, session_id, system, messages, tools),
        fields(session.id = %session_id, gen_ai.request.model = %model_config.model_name)
    )]
    async fn complete(
        &self,
        model_config: &ModelConfig,
        session_id: &str,
        system: &str,
        messages: &[Message],
        tools: &[Tool],
    ) -> Result<(Message, ProviderUsage), ProviderError> {
        IS_AGENT_CALL
            .scope(true, async {
                collect_stream(
                    self.stream(model_config, session_id, system, messages, tools)
                        .await?,
                )
                .await
            })
            .await
    }

    async fn stream(
        &self,
        model_config: &ModelConfig,
        session_id: &str,
        system: &str,
        messages: &[Message],
        tools: &[Tool],
    ) -> Result<MessageStream, ProviderError> {
        let is_agent_call = IS_AGENT_CALL.try_with(|&v| v).unwrap_or(false);
        let last_is_tool_response = messages.last().is_some_and(|m| {
            m.content
                .iter()
                .any(|c| matches!(c, MessageContent::ToolResponse(_)))
        });
        let is_user_initiated = !is_agent_call && !last_is_tool_response;
        let has_images = Self::messages_contain_image(messages);

        if is_openai_responses_model(&model_config.model_name) {
            self.stream_responses(
                model_config,
                session_id,
                is_user_initiated,
                system,
                messages,
                tools,
                has_images,
            )
            .await
        } else {
            self.stream_chat_completions(
                model_config,
                session_id,
                is_user_initiated,
                system,
                messages,
                tools,
                has_images,
            )
            .await
        }
    }

    async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
        let (endpoint, token) = self.get_api_info().await?;
        let url = format!("{}/models", endpoint);

        let mut headers = http::HeaderMap::new();
        headers.insert(http::header::ACCEPT, "application/json".parse().unwrap());
        headers.insert(
            http::header::CONTENT_TYPE,
            "application/json".parse().unwrap(),
        );
        headers.insert("Copilot-Integration-Id", "vscode-chat".parse().unwrap());
        headers.insert(
            http::header::AUTHORIZATION,
            format!("Bearer {}", token).parse().unwrap(),
        );

        let response = self.client.get(url).headers(headers).send().await?;

        let json: serde_json::Value = response.json().await?;

        let arr = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| {
            ProviderError::RequestFailed(
                "Missing 'data' array in GitHub Copilot models response".to_string(),
            )
        })?;
        let mut models: Vec<String> = arr
            .iter()
            .filter_map(|m| {
                if let Some(s) = m.as_str() {
                    Some(s.to_string())
                } else if let Some(obj) = m.as_object() {
                    obj.get("id").and_then(|v| v.as_str()).map(str::to_string)
                } else {
                    None
                }
            })
            .collect();
        models.sort();
        Ok(models)
    }

    async fn configure_oauth(&self) -> Result<(), ProviderError> {
        let config = Config::global();

        if config.get_secret::<String>("GITHUB_COPILOT_TOKEN").is_ok() {
            match self.refresh_api_info().await {
                Ok(_) => return Ok(()),
                Err(_) => {
                    tracing::debug!("Existing token is invalid, starting OAuth flow");
                }
            }
        }

        let token = self
            .get_access_token()
            .await
            .map_err(|e| ProviderError::Authentication(format!("OAuth flow failed: {}", e)))?;

        config
            .set_secret("GITHUB_COPILOT_TOKEN", &token)
            .map_err(|e| ProviderError::ExecutionError(format!("Failed to save token: {}", e)))?;

        Ok(())
    }
}

// Copilot sometimes returns multiple choices in a completion response for
// Claude models and places the `tool_calls` payload in a non-zero index choice.
// This function ensures the first choice contains tool metadata so the shared formatter emits a
// `ToolRequest` instead of returning only the plain-text choice.
fn promote_tool_choice(response: Value) -> Value {
    let Some(choices) = response.get("choices").and_then(|c| c.as_array()) else {
        return response;
    };

    let tool_choice_idx = choices.iter().position(|choice| {
        choice
            .get("message")
            .and_then(|m| m.get("tool_calls"))
            .and_then(|tc| tc.as_array())
            .map(|arr| !arr.is_empty())
            .unwrap_or(false)
    });

    if let Some(idx) = tool_choice_idx {
        if idx != 0 {
            let mut new_response = response;
            if let Some(new_choices) = new_response
                .get_mut("choices")
                .and_then(|c| c.as_array_mut())
            {
                let choice = new_choices.remove(idx);
                new_choices.insert(0, choice);
            }
            return new_response;
        }
    }

    response
}

#[cfg(test)]
mod tests {
    use super::{normalize_host, promote_tool_choice, GithubCopilotProvider, GithubCopilotUrls};
    use crate::providers::utils::is_openai_responses_model;
    use serde_json::json;

    #[test]
    fn responses_models_routed_correctly() {
        assert!(is_openai_responses_model("gpt-5.5"));
        assert!(is_openai_responses_model("gpt-5.4"));
        assert!(is_openai_responses_model("gpt-5"));
        assert!(is_openai_responses_model("gpt-5-mini"));
        assert!(is_openai_responses_model("gpt-5-codex"));
        assert!(is_openai_responses_model("o3"));
        assert!(is_openai_responses_model("o3-mini"));

        assert!(!is_openai_responses_model("gpt-4.1"));
        assert!(!is_openai_responses_model("gpt-4o"));
        assert!(!is_openai_responses_model("claude-sonnet-4"));
        assert!(!is_openai_responses_model("claude-haiku-4.5"));
        assert!(!is_openai_responses_model("gemini-2.5-pro"));
    }

    #[test]
    fn detects_images_in_messages() {
        use crate::conversation::message::Message;

        let messages_with_image = vec![Message::user()
            .with_text("describe this")
            .with_image("base64data", "image/png")];
        assert!(GithubCopilotProvider::messages_contain_image(
            &messages_with_image
        ));

        let messages_without_image = vec![Message::user().with_text("plain text")];
        assert!(!GithubCopilotProvider::messages_contain_image(
            &messages_without_image
        ));
    }

    #[test]
    fn detects_images_in_tool_responses() {
        use crate::conversation::message::{Message, MessageContent};
        use rmcp::model::{CallToolResult, Content};

        let image_content = Content::image("aW1hZ2VkYXRh".to_string(), "image/png".to_string());
        let tool_result = Ok(CallToolResult::success(vec![image_content]));

        let messages =
            vec![Message::user()
                .with_content(MessageContent::tool_response("call_123", tool_result))];
        assert!(GithubCopilotProvider::messages_contain_image(&messages));

        let text_result = Ok(CallToolResult::success(vec![Content::text("no images")]));
        let messages_text_only =
            vec![Message::user()
                .with_content(MessageContent::tool_response("call_456", text_result))];
        assert!(!GithubCopilotProvider::messages_contain_image(
            &messages_text_only
        ));
    }

    #[test]
    fn promotes_choice_with_tool_call() {
        let response = json!({
            "choices": [
                {"message": {"content": "plain text"}},
                {"message": {"tool_calls": [{"function": {"name": "foo", "arguments": "{}"}}]}}
            ]
        });

        let promoted = promote_tool_choice(response);
        assert_eq!(
            promoted
                .get("choices")
                .and_then(|c| c.as_array())
                .map(|c| c.len()),
            Some(2)
        );
        let first_choice = promoted
            .get("choices")
            .and_then(|c| c.as_array())
            .and_then(|c| c.first())
            .unwrap();

        assert!(first_choice
            .get("message")
            .and_then(|m| m.get("tool_calls"))
            .is_some());
    }

    #[test]
    fn leaves_response_when_tool_choice_first() {
        let response = json!({
            "choices": [
                {"message": {"tool_calls": [{"function": {"name": "foo", "arguments": "{}"}}]}},
                {"message": {"content": "plain text"}}
            ]
        });

        let promoted = promote_tool_choice(response.clone());
        assert_eq!(promoted, response);
    }

    #[test]
    fn normalize_host_strips_prefix_and_slash() {
        assert_eq!(normalize_host("github.com"), "github.com");
        assert_eq!(normalize_host("https://github.com"), "github.com");
        assert_eq!(normalize_host("github.com/"), "github.com");
        assert_eq!(normalize_host("https://github.com/"), "github.com");
        assert_eq!(
            normalize_host("https://my-enterprise.ghe.com/"),
            "my-enterprise.ghe.com"
        );
    }

    #[test]
    fn urls_default_github_com() {
        let urls = GithubCopilotUrls::new("github.com", None);
        assert_eq!(urls.device_code_url, "https://github.com/login/device/code");
        assert_eq!(
            urls.access_token_url,
            "https://github.com/login/oauth/access_token"
        );
        assert_eq!(
            urls.copilot_token_url,
            "https://api.github.com/copilot_internal/v2/token"
        );
    }

    #[test]
    fn urls_enterprise_host() {
        let urls = GithubCopilotUrls::new("my-enterprise.ghe.com", None);
        assert_eq!(
            urls.device_code_url,
            "https://my-enterprise.ghe.com/login/device/code"
        );
        assert_eq!(
            urls.access_token_url,
            "https://my-enterprise.ghe.com/login/oauth/access_token"
        );
        assert_eq!(
            urls.copilot_token_url,
            "https://api.my-enterprise.ghe.com/copilot_internal/v2/token"
        );
    }

    #[test]
    fn urls_enterprise_with_token_url_override() {
        let urls = GithubCopilotUrls::new(
            "my-enterprise.ghe.com",
            Some("https://my-enterprise.ghe.com/api/v3/copilot_internal/v2/token"),
        );
        assert_eq!(
            urls.copilot_token_url,
            "https://my-enterprise.ghe.com/api/v3/copilot_internal/v2/token"
        );
    }
}
