// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

use std::collections::HashSet;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};

use serde::Deserialize;
use serde_json::json;

use crate::credential_store;
use crate::error::GwsError;

/// Response from Google's token endpoint
#[derive(Debug, Deserialize)]
struct OAuthTokenResponse {
    access_token: String,
    refresh_token: Option<String>,
    #[allow(dead_code)]
    expires_in: u64,
    #[allow(dead_code)]
    token_type: String,
}

/// Exchange authorization code for tokens using reqwest (supports HTTP proxy)
async fn exchange_code_with_reqwest(
    client_id: &str,
    client_secret: &str,
    code: &str,
    redirect_uri: &str,
) -> Result<OAuthTokenResponse, GwsError> {
    let client = crate::client::shared_client()?;
    let params = [
        ("client_id", client_id),
        ("client_secret", client_secret),
        ("code", code),
        ("redirect_uri", redirect_uri),
        ("grant_type", "authorization_code"),
    ];

    let response = client
        .post("https://oauth2.googleapis.com/token")
        .form(&params)
        .send()
        .await
        .map_err(|e| GwsError::Auth(format!("Failed to send token request: {e}")))?;

    if !response.status().is_success() {
        let status = response.status();
        let body = crate::auth::response_text_or_placeholder(response.text().await);
        return Err(GwsError::Auth(format!(
            "Token exchange failed with status {}: {}",
            status, body
        )));
    }

    response
        .json()
        .await
        .map_err(|e| GwsError::Auth(format!("Failed to parse token response: {e}")))
}

fn build_proxy_auth_url(client_id: &str, redirect_uri: &str, scopes: &[String]) -> String {
    let scopes_str = scopes.join(" ");
    format!(
        "https://accounts.google.com/o/oauth2/auth?\
         scope={}&\
         access_type=offline&\
         redirect_uri={}&\
         response_type=code&\
         client_id={}&\
         prompt=select_account+consent",
        urlencoding(&scopes_str),
        urlencoding(redirect_uri),
        urlencoding(client_id)
    )
}

fn extract_authorization_code(request_line: &str) -> Result<String, GwsError> {
    let path = request_line
        .split_whitespace()
        .nth(1)
        .ok_or_else(|| GwsError::Auth("Invalid HTTP request".to_string()))?;

    path.split('?')
        .nth(1)
        .and_then(|query| {
            query.split('&').find_map(|pair| {
                let mut parts = pair.split('=');
                if parts.next() == Some("code") {
                    parts.next().map(|value| value.to_string())
                } else {
                    None
                }
            })
        })
        .ok_or_else(|| GwsError::Auth("No authorization code in callback".to_string()))
}

/// Perform OAuth login flow with proxy support using reqwest for token exchange
async fn login_with_proxy_support(
    client_id: &str,
    client_secret: &str,
    scopes: &[String],
) -> Result<(String, String), GwsError> {
    // Start local server to receive OAuth callback
    let listener = TcpListener::bind("127.0.0.1:0")
        .map_err(|e| GwsError::Auth(format!("Failed to start local server: {e}")))?;
    let port = listener
        .local_addr()
        .map_err(|e| GwsError::Auth(format!("Failed to inspect local server: {e}")))?
        .port();
    let redirect_uri = format!("http://localhost:{}", port);

    let auth_url = build_proxy_auth_url(client_id, &redirect_uri, scopes);

    println!("Open this URL in your browser to authenticate:\n");
    println!("  {}\n", auth_url);

    // Wait for OAuth callback
    let (mut stream, _) = listener
        .accept()
        .map_err(|e| GwsError::Auth(format!("Failed to accept connection: {e}")))?;

    let mut reader = BufReader::new(&stream);
    let mut request_line = String::new();
    reader
        .read_line(&mut request_line)
        .map_err(|e| GwsError::Auth(format!("Failed to read request: {e}")))?;

    let code = extract_authorization_code(&request_line)?;

    // Send success response to browser
    let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\
        <html><body><h1>Success!</h1><p>You may now close this window.</p></body></html>";
    let _ = stream.write_all(response.as_bytes());

    // Exchange code for tokens using reqwest (proxy-aware)
    let token_response =
        exchange_code_with_reqwest(client_id, client_secret, &code, &redirect_uri).await?;

    let refresh_token = token_response.refresh_token.ok_or_else(|| {
        GwsError::Auth(
            "OAuth flow completed but no refresh token was returned. \
                 Ensure the OAuth consent screen includes 'offline' access."
                .to_string(),
        )
    })?;

    Ok((token_response.access_token, refresh_token))
}

fn read_refresh_token_from_cache(temp_path: &Path) -> Result<String, GwsError> {
    let token_data = std::fs::read(temp_path)
        .ok()
        .and_then(|bytes| crate::credential_store::decrypt(&bytes).ok())
        .and_then(|decrypted| String::from_utf8(decrypted).ok())
        .unwrap_or_default();

    extract_refresh_token(&token_data).ok_or_else(|| {
        GwsError::Auth(
            "OAuth flow completed but no refresh token was returned. \
             Ensure the OAuth consent screen includes 'offline' access."
                .to_string(),
        )
    })
}

async fn login_with_yup_oauth(
    config_dir: &Path,
    client_id: &str,
    client_secret: &str,
    scopes: &[String],
) -> Result<(String, String), GwsError> {
    let secret = yup_oauth2::ApplicationSecret {
        client_id: client_id.to_string(),
        client_secret: client_secret.to_string(),
        auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(),
        token_uri: "https://oauth2.googleapis.com/token".to_string(),
        redirect_uris: vec!["http://localhost".to_string()],
        ..Default::default()
    };

    let temp_path = config_dir.join("credentials.tmp");
    let _ = std::fs::remove_file(&temp_path);

    let result = async {
        let auth = yup_oauth2::InstalledFlowAuthenticator::builder(
            secret,
            yup_oauth2::InstalledFlowReturnMethod::HTTPRedirect,
        )
        .with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new(
            temp_path.clone(),
        )))
        .force_account_selection(true)
        .flow_delegate(Box::new(CliFlowDelegate { login_hint: None }))
        .build()
        .await
        .map_err(|e| GwsError::Auth(format!("Failed to build authenticator: {e}")))?;

        let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
        let token = auth
            .token(&scope_refs)
            .await
            .map_err(|e| GwsError::Auth(format!("OAuth flow failed: {e}")))?;

        let access_token = token
            .token()
            .ok_or_else(|| GwsError::Auth("No access token returned".to_string()))?
            .to_string();
        let refresh_token = read_refresh_token_from_cache(&temp_path)?;

        Ok((access_token, refresh_token))
    }
    .await;

    let _ = std::fs::remove_file(&temp_path);
    result
}

/// Simple URL encoding
fn urlencoding(s: &str) -> String {
    percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string()
}

/// Mask a secret string by showing only the first 4 and last 4 characters.
/// Strings with 8 or fewer characters are fully replaced with "***".
///
/// Uses char-based indexing (not byte offsets) so multi-byte UTF-8 secrets
/// never cause a panic.
fn mask_secret(s: &str) -> String {
    const MASK_PREFIX_LEN: usize = 4;
    const MASK_SUFFIX_LEN: usize = 4;
    const MIN_LEN_FOR_PARTIAL_MASK: usize = MASK_PREFIX_LEN + MASK_SUFFIX_LEN;

    let char_count = s.chars().count();
    if char_count > MIN_LEN_FOR_PARTIAL_MASK {
        let prefix: String = s.chars().take(MASK_PREFIX_LEN).collect();
        let suffix: String = s.chars().skip(char_count - MASK_SUFFIX_LEN).collect();
        format!("{prefix}...{suffix}")
    } else {
        "***".to_string()
    }
}

/// Minimal scopes for first-run login — only core Workspace APIs that never
/// trigger Google's `restricted_client` / unverified-app block.
///
/// These are the safest scopes for unverified OAuth apps and personal Cloud
/// projects.  Users can request broader access with `--scopes` or `--full`.
pub const MINIMAL_SCOPES: &[&str] = &[
    "https://www.googleapis.com/auth/drive",
    "https://www.googleapis.com/auth/spreadsheets",
    "https://www.googleapis.com/auth/gmail.modify",
    "https://www.googleapis.com/auth/calendar",
    "https://www.googleapis.com/auth/documents",
    "https://www.googleapis.com/auth/presentations",
    "https://www.googleapis.com/auth/tasks",
];

/// Default scopes for login.  Alias for [`MINIMAL_SCOPES`] — deliberately kept
/// narrow so first-run logins succeed even with an unverified OAuth app.
///
/// Previously this included `pubsub` and `cloud-platform`, which Google marks
/// as *restricted* and blocks for unverified apps, causing `Error 403:
/// restricted_client`.  Use `--scopes` to add those scopes explicitly when you
/// have a verified app or a GCP project with the APIs enabled and approved.
pub const DEFAULT_SCOPES: &[&str] = MINIMAL_SCOPES;

/// Full scopes — all common Workspace APIs plus GCP platform access.
///
/// Use `gws auth login --full` to request these.  Unverified OAuth apps will
/// receive a Google consent-screen warning, and some scopes (e.g. `pubsub`,
/// `cloud-platform`) require app verification or a Workspace domain admin to
/// grant access.
pub const FULL_SCOPES: &[&str] = &[
    "https://www.googleapis.com/auth/drive",
    "https://www.googleapis.com/auth/spreadsheets",
    "https://www.googleapis.com/auth/gmail.modify",
    "https://www.googleapis.com/auth/calendar",
    "https://www.googleapis.com/auth/documents",
    "https://www.googleapis.com/auth/presentations",
    "https://www.googleapis.com/auth/tasks",
    "https://www.googleapis.com/auth/pubsub",
    "https://www.googleapis.com/auth/cloud-platform",
];

/// Readonly scopes — read-only Workspace access.
const READONLY_SCOPES: &[&str] = &[
    "https://www.googleapis.com/auth/drive.readonly",
    "https://www.googleapis.com/auth/spreadsheets.readonly",
    "https://www.googleapis.com/auth/gmail.readonly",
    "https://www.googleapis.com/auth/calendar.readonly",
    "https://www.googleapis.com/auth/documents.readonly",
    "https://www.googleapis.com/auth/presentations.readonly",
    "https://www.googleapis.com/auth/tasks.readonly",
];

pub fn config_dir() -> PathBuf {
    if let Ok(dir) = std::env::var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR") {
        return PathBuf::from(dir);
    }

    // Use ~/.config/gws on all platforms for a consistent, user-friendly path.
    let primary = dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".config")
        .join("gws");
    if primary.exists() {
        return primary;
    }

    // Backward compat: fall back to OS-specific config dir for existing installs
    // (e.g. ~/Library/Application Support/gws on macOS, %APPDATA%\gws on Windows).
    let legacy = dirs::config_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join("gws");
    if legacy.exists() {
        return legacy;
    }

    primary
}

fn plain_credentials_path() -> PathBuf {
    if let Ok(path) = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE") {
        return PathBuf::from(path);
    }
    config_dir().join("credentials.json")
}

fn token_cache_path() -> PathBuf {
    config_dir().join("token_cache.json")
}

/// Which scope set to use for login.
enum ScopeMode {
    /// Use the default scopes (MINIMAL_SCOPES).
    Default,
    /// Use readonly scopes.
    Readonly,
    /// Use full scopes (incl. pubsub + cloud-platform).
    Full,
    /// Use explicitly provided custom scopes.
    Custom(Vec<String>),
}

/// Build the clap Command for the `login` subcommand.
/// Used by both `auth_command()` and `login_command()` as single source of truth.
fn build_login_subcommand() -> clap::Command {
    clap::Command::new("login")
        .about("Authenticate via OAuth2 (opens browser)")
        .arg(
            clap::Arg::new("readonly")
                .long("readonly")
                .help("Request read-only scopes")
                .action(clap::ArgAction::SetTrue)
                .conflicts_with_all(["full", "scopes"]),
        )
        .arg(
            clap::Arg::new("full")
                .long("full")
                .help("Request all scopes incl. pubsub + cloud-platform")
                .action(clap::ArgAction::SetTrue)
                .conflicts_with_all(["readonly", "scopes"]),
        )
        .arg(
            clap::Arg::new("scopes")
                .long("scopes")
                .help("Comma-separated custom scopes")
                .value_name("scopes")
                .conflicts_with_all(["readonly", "full"]),
        )
        .arg(
            clap::Arg::new("services")
                .short('s')
                .long("services")
                .help(
                    "Comma-separated service names to limit scope picker (e.g. drive,gmail,sheets)",
                )
                .value_name("services"),
        )
}

/// Build the clap Command for `gws auth`.
fn auth_command() -> clap::Command {
    clap::Command::new("auth")
        .about("Manage authentication for Google Workspace APIs")
        .subcommand_required(false)
        .subcommand(build_login_subcommand())
        .subcommand(
            clap::Command::new("setup")
                .about("Configure GCP project + OAuth client (requires gcloud)")
                .disable_help_flag(true)
                // setup has its own clap-based arg parsing in setup.rs,
                // so we pass remaining args through.
                .arg(
                    clap::Arg::new("args")
                        .trailing_var_arg(true)
                        .allow_hyphen_values(true)
                        .num_args(0..)
                        .value_name("ARGS"),
                ),
        )
        .subcommand(clap::Command::new("status").about("Show current authentication state"))
        .subcommand(
            clap::Command::new("export")
                .about("Print decrypted credentials to stdout")
                .arg(
                    clap::Arg::new("unmasked")
                        .long("unmasked")
                        .help("Show secrets without masking")
                        .action(clap::ArgAction::SetTrue),
                ),
        )
        .subcommand(clap::Command::new("logout").about("Clear saved credentials and token cache"))
}

/// Handle `gws auth <subcommand>`.
pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {
    let matches = match auth_command()
        .try_get_matches_from(std::iter::once("auth".to_string()).chain(args.iter().cloned()))
    {
        Ok(m) => m,
        Err(e)
            if e.kind() == clap::error::ErrorKind::DisplayHelp
                || e.kind() == clap::error::ErrorKind::DisplayVersion =>
        {
            e.print().map_err(|io_err| {
                GwsError::Validation(format!("Failed to print help: {io_err}"))
            })?;
            return Ok(());
        }
        Err(e) => return Err(GwsError::Validation(e.to_string())),
    };

    match matches.subcommand() {
        Some(("login", sub_m)) => {
            let (scope_mode, services_filter) = parse_login_args(sub_m);

            handle_login_inner(scope_mode, services_filter).await
        }
        Some(("setup", sub_m)) => {
            // Collect remaining args and delegate to setup's own clap parser.
            let setup_args: Vec<String> = sub_m
                .get_many::<String>("args")
                .map(|vals| vals.cloned().collect())
                .unwrap_or_default();
            crate::setup::run_setup(&setup_args).await
        }
        Some(("status", _)) => handle_status().await,
        Some(("export", sub_m)) => {
            let unmasked = sub_m.get_flag("unmasked");
            handle_export(unmasked).await
        }
        Some(("logout", _)) => handle_logout(),
        _ => {
            // No subcommand → print help
            auth_command()
                .print_help()
                .map_err(|e| GwsError::Validation(format!("Failed to print help: {e}")))?;
            Ok(())
        }
    }
}

/// Build the clap Command for `gws auth login` (used by `run_login` for
/// standalone parsing when called from setup.rs).
fn login_command() -> clap::Command {
    build_login_subcommand()
}

/// Extract `ScopeMode` and optional services filter from parsed login args.
fn parse_login_args(matches: &clap::ArgMatches) -> (ScopeMode, Option<HashSet<String>>) {
    let scope_mode = if let Some(scopes_str) = matches.get_one::<String>("scopes") {
        ScopeMode::Custom(
            scopes_str
                .split(',')
                .map(|s| s.trim())
                .filter(|s| !s.is_empty())
                .map(String::from)
                .collect(),
        )
    } else if matches.get_flag("readonly") {
        ScopeMode::Readonly
    } else if matches.get_flag("full") {
        ScopeMode::Full
    } else {
        ScopeMode::Default
    };

    let services_filter: Option<HashSet<String>> = matches.get_one::<String>("services").map(|v| {
        v.split(',')
            .map(|s| s.trim().to_lowercase())
            .filter(|s| !s.is_empty())
            .collect()
    });

    (scope_mode, services_filter)
}

/// Run the `auth login` flow.
///
/// Exposed for internal orchestration (e.g. `auth setup --login`).
/// Accepts raw args for backward compat with setup.rs calling `run_login(&[])`.
pub async fn run_login(args: &[String]) -> Result<(), GwsError> {
    let matches = match login_command()
        .try_get_matches_from(std::iter::once("login".to_string()).chain(args.iter().cloned()))
    {
        Ok(m) => m,
        Err(e)
            if e.kind() == clap::error::ErrorKind::DisplayHelp
                || e.kind() == clap::error::ErrorKind::DisplayVersion =>
        {
            e.print().map_err(|io_err| {
                GwsError::Validation(format!("Failed to print help: {io_err}"))
            })?;
            return Ok(());
        }
        Err(e) => return Err(GwsError::Validation(e.to_string())),
    };

    let (scope_mode, services_filter) = parse_login_args(&matches);

    handle_login_inner(scope_mode, services_filter).await
}
/// Custom delegate that prints the OAuth URL on its own line for easy copying.
/// Optionally includes `login_hint` in the URL for account pre-selection.
struct CliFlowDelegate {
    login_hint: Option<String>,
}

impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelegate {
    fn present_user_url<'a>(
        &'a self,
        url: &'a str,
        _need_code: bool,
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String, String>> + Send + 'a>>
    {
        Box::pin(async move {
            // Inject login_hint into the OAuth URL if we have one
            let display_url = if let Some(ref hint) = self.login_hint {
                let encoded: String = percent_encoding::percent_encode(
                    hint.as_bytes(),
                    percent_encoding::NON_ALPHANUMERIC,
                )
                .to_string();
                if url.contains('?') {
                    format!("{url}&login_hint={encoded}")
                } else {
                    format!("{url}?login_hint={encoded}")
                }
            } else {
                url.to_string()
            };
            eprintln!("Open this URL in your browser to authenticate:\n");
            eprintln!("  {display_url}\n");
            Ok(String::new())
        })
    }
}

/// Inner login implementation that takes already-parsed options.
async fn handle_login_inner(
    scope_mode: ScopeMode,
    services_filter: Option<HashSet<String>>,
) -> Result<(), GwsError> {
    // Resolve client_id and client_secret:
    // 1. Env vars (highest priority)
    // 2. Saved client_secret.json from `gws auth setup` or manual download
    let (client_id, client_secret, project_id) = resolve_client_credentials()?;

    // Persist credentials to client_secret.json if not already saved,
    // so they survive env var removal or shell session changes.
    if !crate::oauth_config::client_config_path().exists() {
        let _ = crate::oauth_config::save_client_config(
            &client_id,
            &client_secret,
            project_id.as_deref().unwrap_or(""),
        );
    }

    // Determine scopes: explicit flags > interactive TUI > defaults
    let scopes = resolve_scopes(scope_mode, project_id.as_deref(), services_filter.as_ref()).await;

    // Remove restrictive scopes when broader alternatives are present.
    let mut scopes = filter_redundant_restrictive_scopes(scopes);

    // Ensure openid + email + profile scopes are always present so we can
    // identify the user via the userinfo endpoint after login, and so the
    // Gmail helpers can fall back to the People API to populate the From
    // display name when the send-as identity lacks one (Workspace accounts).
    let identity_scopes = [
        "openid",
        "https://www.googleapis.com/auth/userinfo.email",
        "https://www.googleapis.com/auth/userinfo.profile",
    ];
    for s in &identity_scopes {
        if !scopes.iter().any(|existing| existing == s) {
            scopes.push(s.to_string());
        }
    }

    // Ensure config directory exists
    let config = config_dir();
    std::fs::create_dir_all(&config)
        .map_err(|e| GwsError::Validation(format!("Failed to create config directory: {e}")))?;

    // If proxy env vars are set, use proxy-aware OAuth flow (reqwest)
    // Otherwise use yup-oauth2 (faster, but doesn't support proxy)
    let (access_token, refresh_token) = if crate::auth::has_proxy_env() {
        login_with_proxy_support(&client_id, &client_secret, &scopes).await?
    } else {
        login_with_yup_oauth(&config, &client_id, &client_secret, &scopes).await?
    };

    // Build credentials in the standard authorized_user format
    let creds_json = json!({
        "type": "authorized_user",
        "client_id": client_id,
        "client_secret": client_secret,
        "refresh_token": refresh_token,
    });

    let creds_str = serde_json::to_string_pretty(&creds_json)
        .map_err(|e| GwsError::Validation(format!("Failed to serialize credentials: {e}")))?;

    // Fetch the user's email from Google userinfo
    let actual_email = fetch_userinfo_email(&access_token).await;

    // Save encrypted credentials
    let enc_path = credential_store::save_encrypted(&creds_str)
        .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?;

    let output = json!({
        "status": "success",
        "message": "Authentication successful. Encrypted credentials saved.",
        "account": actual_email.as_deref().unwrap_or("(unknown)"),
        "credentials_file": enc_path.display().to_string(),
        "encryption": "AES-256-GCM (key in OS keyring or local `.encryption_key`; set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file for headless)",
        "scopes": scopes,
    });
    println!(
        "{}",
        serde_json::to_string_pretty(&output).unwrap_or_default()
    );
    Ok(())
}

/// Fetch the authenticated user's email from Google's userinfo endpoint.
async fn fetch_userinfo_email(access_token: &str) -> Option<String> {
    let client = match crate::client::build_client() {
        Ok(c) => c,
        Err(_) => return None,
    };
    let resp = client
        .get("https://www.googleapis.com/oauth2/v2/userinfo")
        .bearer_auth(access_token)
        .send()
        .await
        .ok()?;
    if !resp.status().is_success() {
        return None;
    }
    let body: serde_json::Value = resp.json().await.ok()?;
    body.get("email")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
}

async fn handle_export(unmasked: bool) -> Result<(), GwsError> {
    let enc_path = credential_store::encrypted_credentials_path();
    if !enc_path.exists() {
        return Err(GwsError::Auth(
            "No encrypted credentials found. Run 'gws auth login' first.".to_string(),
        ));
    }

    match credential_store::load_encrypted() {
        Ok(contents) => {
            if unmasked {
                println!("{contents}");
            } else if let Ok(mut creds) = serde_json::from_str::<serde_json::Value>(&contents) {
                if let Some(obj) = creds.as_object_mut() {
                    for key in ["client_secret", "refresh_token"] {
                        if let Some(val) = obj.get_mut(key) {
                            if let Some(s) = val.as_str() {
                                *val = json!(mask_secret(s));
                            }
                        }
                    }
                }
                println!("{}", serde_json::to_string_pretty(&creds).unwrap());
            } else {
                println!("{contents}");
            }
            Ok(())
        }
        Err(e) => Err(GwsError::Auth(format!(
            "Failed to decrypt credentials: {e}. May have been created on a different machine.",
        ))),
    }
}

/// Resolve OAuth client credentials from env vars or saved config file.
fn resolve_client_credentials() -> Result<(String, String, Option<String>), GwsError> {
    // 1. Try env vars first
    let env_id = std::env::var("GOOGLE_WORKSPACE_CLI_CLIENT_ID").ok();
    let env_secret = std::env::var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET").ok();

    if let (Some(id), Some(secret)) = (env_id, env_secret) {
        // Still try to load project_id from config file for the scope picker
        let project_id = crate::oauth_config::load_client_config()
            .ok()
            .map(|c| c.project_id);
        return Ok((id, secret, project_id));
    }

    // 2. Try saved client_secret.json
    match crate::oauth_config::load_client_config() {
        Ok(config) => Ok((
            config.client_id,
            config.client_secret,
            Some(config.project_id),
        )),
        Err(_) => Err(GwsError::Auth(
            format!(
                "No OAuth client configured.\n\n\
                 Either:\n  \
                   1. Run `gws auth setup` to configure a GCP project and OAuth client\n  \
                   2. Download client_secret.json from Google Cloud Console and save it to:\n     \
                      {}\n  \
                   3. Set env vars: GOOGLE_WORKSPACE_CLI_CLIENT_ID and GOOGLE_WORKSPACE_CLI_CLIENT_SECRET",
                crate::oauth_config::client_config_path().display()
            ),
        )),
    }
}

/// Resolve OAuth scopes: explicit flags > interactive picker > defaults.
///
/// When `services_filter` is `Some`, only scopes belonging to the specified
/// services are shown in the picker (or returned in non-interactive mode).
async fn resolve_scopes(
    scope_mode: ScopeMode,
    project_id: Option<&str>,
    services_filter: Option<&HashSet<String>>,
) -> Vec<String> {
    match scope_mode {
        ScopeMode::Custom(scopes) => return scopes,
        ScopeMode::Readonly => {
            let scopes: Vec<String> = READONLY_SCOPES.iter().map(|s| s.to_string()).collect();
            let mut result = filter_scopes_by_services(scopes, services_filter);
            augment_with_dynamic_scopes(&mut result, services_filter, true).await;
            return result;
        }
        ScopeMode::Full => {
            let scopes: Vec<String> = FULL_SCOPES.iter().map(|s| s.to_string()).collect();
            let mut result = filter_scopes_by_services(scopes, services_filter);
            augment_with_dynamic_scopes(&mut result, services_filter, false).await;
            return result;
        }
        ScopeMode::Default => {} // fall through to interactive picker / defaults
    }

    // Interactive scope picker when running in a TTY
    if !cfg!(test) && std::io::IsTerminal::is_terminal(&std::io::stdin()) {
        // If we have a project_id, use discovery-based scope picker (rich templates)
        if let Some(pid) = project_id {
            let enabled_apis = crate::setup::get_enabled_apis(pid);
            if !enabled_apis.is_empty() {
                let api_ids: Vec<String> = enabled_apis;
                let scopes = crate::setup::fetch_scopes_for_apis(&api_ids).await;
                if !scopes.is_empty() {
                    if let Some(selected) = run_discovery_scope_picker(&scopes, services_filter) {
                        return selected;
                    }
                }
            }
        }

        // Fallback: simple scope picker using static SCOPE_ENTRIES
        if let Some(selected) = run_simple_scope_picker(services_filter) {
            return selected;
        }
    }

    let defaults: Vec<String> = DEFAULT_SCOPES.iter().map(|s| s.to_string()).collect();
    let mut result = filter_scopes_by_services(defaults, services_filter);
    augment_with_dynamic_scopes(&mut result, services_filter, false).await;
    result
}

/// Check if a scope URL belongs to one of the specified services.
///
/// Matching is done on the scope's short name (the part after
/// `https://www.googleapis.com/auth/`). A scope matches a service if its
/// short name equals the service or starts with `service.` (e.g. service
/// `drive` matches `drive`, `drive.readonly`, `drive.metadata.readonly`).
///
/// The `cloud-platform` scope always passes through since it's a
/// cross-service platform scope.
fn scope_matches_service(scope_url: &str, services: &HashSet<String>) -> bool {
    let short = scope_url
        .strip_prefix("https://www.googleapis.com/auth/")
        .unwrap_or(scope_url);

    // cloud-platform is a cross-service scope, always include
    if short == "cloud-platform" {
        return true;
    }

    let prefix = short.split('.').next().unwrap_or(short);

    services.iter().any(|svc| {
        let prefixes = map_service_to_scope_prefixes(svc);
        prefixes
            .iter()
            .any(|mapped| prefix == *mapped || short.starts_with(&format!("{mapped}.")))
    })
}

/// Map user-friendly service names to their OAuth scope prefixes.
/// Some services map to multiple scope prefixes (e.g. People API uses
/// both `contacts` and `directory` scopes).
fn map_service_to_scope_prefixes(service: &str) -> Vec<&str> {
    match service {
        "sheets" => vec!["spreadsheets"],
        "slides" => vec!["presentations"],
        "docs" => vec!["documents"],
        "people" => vec!["contacts", "directory"],
        s => vec![s],
    }
}

/// Remove restrictive scopes that are redundant when broader alternatives
/// are present. For example, `gmail.metadata` restricts query parameters
/// and is unnecessary when `gmail.modify`, `gmail.readonly`, or the full
/// `https://mail.google.com/` scope is already included.
///
/// This prevents Google from enforcing the restrictive scope's limitations
/// on the access token even though broader access was granted.
fn filter_redundant_restrictive_scopes(scopes: Vec<String>) -> Vec<String> {
    // Scopes that restrict API behavior when present in a token, even alongside
    // broader scopes. Each entry maps a restrictive scope to the broader scopes
    // that make it redundant. The restrictive scope is removed only if at least
    // one of its broader alternatives is already in the list.
    const RESTRICTIVE_SCOPES: &[(&str, &[&str])] = &[(
        "https://www.googleapis.com/auth/gmail.metadata",
        &[
            "https://mail.google.com/",
            "https://www.googleapis.com/auth/gmail.modify",
            "https://www.googleapis.com/auth/gmail.readonly",
        ],
    )];

    let scope_set: std::collections::HashSet<String> = scopes.iter().cloned().collect();

    scopes
        .into_iter()
        .filter(|scope| {
            !RESTRICTIVE_SCOPES.iter().any(|(restrictive, broader)| {
                scope.as_str() == *restrictive && broader.iter().any(|b| scope_set.contains(*b))
            })
        })
        .collect()
}

/// Filter a list of scope URLs to only those matching the given services.
/// If no filter is provided, returns all scopes unchanged.
fn filter_scopes_by_services(
    scopes: Vec<String>,
    services_filter: Option<&HashSet<String>>,
) -> Vec<String> {
    match services_filter {
        Some(services) if !services.is_empty() => scopes
            .into_iter()
            .filter(|s| scope_matches_service(s, services))
            .collect(),
        _ => scopes,
    }
}

/// Check if a scope is subsumed by a broader scope in the list.
/// e.g. "drive.metadata" is subsumed by "drive", "calendar.events" by "calendar".
fn is_subsumed_scope(short: &str, all_shorts: &[&str]) -> bool {
    all_shorts.iter().any(|&other| {
        other != short
            && short.starts_with(other)
            && short.as_bytes().get(other.len()) == Some(&b'.')
    })
}

/// Determine if a discovered scope should be included in the "Recommended" template.
///
/// When a services filter is active, recommends all top-level (non-subsumed) scopes.
/// Otherwise, recommends only the curated `MINIMAL_SCOPES` list to stay under
/// the 25-scope limit for unverified apps and @gmail.com accounts.
///
/// Always excludes admin-only and Workspace-admin scopes.
fn is_recommended_scope(
    entry: &crate::setup::DiscoveredScope,
    all_shorts: &[&str],
    has_services_filter: bool,
) -> bool {
    if entry.short.starts_with("admin.") || is_workspace_admin_scope(&entry.url) {
        return false;
    }
    if has_services_filter {
        !is_subsumed_scope(&entry.short, all_shorts)
    } else {
        MINIMAL_SCOPES.contains(&entry.url.as_str())
    }
}

/// Run the rich discovery-based scope picker with templates.
fn run_discovery_scope_picker(
    relevant_scopes: &[crate::setup::DiscoveredScope],
    services_filter: Option<&HashSet<String>>,
) -> Option<Vec<String>> {
    use crate::setup::{ScopeClassification, PLATFORM_SCOPE};
    use crate::setup_tui::{PickerResult, SelectItem};

    let mut recommended_scopes = vec![];
    let mut readonly_scopes = vec![];
    let mut all_scopes = vec![];

    // Pre-filter scopes by services if a filter is specified
    let filtered_scopes: Vec<&crate::setup::DiscoveredScope> = relevant_scopes
        .iter()
        .filter(|e| {
            services_filter.is_none_or(|services| {
                services.is_empty() || scope_matches_service(&e.url, services)
            })
        })
        .collect();

    // Collect all short names for hierarchical dedup of Full Access template
    let all_shorts: Vec<&str> = filtered_scopes
        .iter()
        .filter(|e| !is_app_only_scope(&e.url))
        .map(|e| e.short.as_str())
        .collect();

    for entry in &filtered_scopes {
        // Skip app-only scopes that can't be used with user OAuth
        if is_app_only_scope(&entry.url) {
            continue;
        }

        if is_recommended_scope(entry, &all_shorts, services_filter.is_some()) {
            recommended_scopes.push(entry.short.to_string());
        }
        if entry.is_readonly {
            readonly_scopes.push(entry.short.to_string());
        }
        // For "Full Access": skip if a broader scope exists (hierarchical dedup)
        // e.g. "drive.metadata" is subsumed by "drive", "calendar.events" by "calendar"
        if !is_subsumed_scope(&entry.short, &all_shorts) {
            all_scopes.push(entry.short.to_string());
        }
    }

    let mut items: Vec<SelectItem> = vec![
        SelectItem {
            label: "✨ Recommended (Core Consumer Scopes)".to_string(),
            description: "Selects Drive, Gmail, Calendar, Docs, Sheets, Slides, and Tasks"
                .to_string(),
            selected: true,
            is_fixed: false,
            is_template: true,
            template_selects: recommended_scopes,
        },
        SelectItem {
            label: "🔒 Read Only".to_string(),
            description: "Selects only readonly scopes for enabled APIs".to_string(),
            selected: false,
            is_fixed: false,
            is_template: true,
            template_selects: readonly_scopes,
        },
        SelectItem {
            label: "⚠️ Full Access (All Scopes)".to_string(),
            description: "Selects ALL scopes, including restricted write scopes".to_string(),
            selected: false,
            is_fixed: false,
            is_template: true,
            template_selects: all_scopes,
        },
    ];
    let template_count = items.len();

    let mut valid_scope_indices: Vec<usize> = Vec::new();
    for (idx, entry) in filtered_scopes.iter().enumerate() {
        // Skip app-only scopes from the picker entirely
        if is_app_only_scope(&entry.url) {
            continue;
        }

        let (prefix, emoji) = match entry.classification {
            ScopeClassification::Restricted => ("RESTRICTED ", "⛔ "),
            ScopeClassification::Sensitive => ("SENSITIVE ", "⚠️  "),
            ScopeClassification::NonSensitive => ("", ""),
        };

        let desc_str = if entry.description.is_empty() {
            entry.url.clone()
        } else {
            entry.description.clone()
        };

        let description = if prefix.is_empty() {
            desc_str
        } else {
            format!("{}{}{}", emoji, prefix, desc_str)
        };

        let is_recommended = if entry.is_readonly {
            let superset = entry.url.strip_suffix(".readonly").unwrap_or(&entry.url);
            let superset_is_recommended = filtered_scopes
                .iter()
                .any(|s| s.url == superset && s.classification != ScopeClassification::Restricted);
            !superset_is_recommended
        } else {
            entry.classification != ScopeClassification::Restricted
        };

        items.push(SelectItem {
            label: entry.short.to_string(),
            description,
            selected: is_recommended,
            is_fixed: false,
            is_template: false,
            template_selects: vec![],
        });
        valid_scope_indices.push(idx);
    }

    match crate::setup_tui::run_picker(
        "Select OAuth scopes",
        "Space to toggle, Enter to confirm",
        items,
        true,
    ) {
        Ok(PickerResult::Confirmed(items)) => {
            let recommended = items.first().is_some_and(|i| i.selected);
            let readonly = items.get(1).is_some_and(|i| i.selected);
            let full = items.get(2).is_some_and(|i| i.selected);

            let mut selected: Vec<String> = Vec::new();

            if full && !recommended && !readonly {
                // Full Access: include all non-app-only scopes
                // (hierarchical dedup is applied in post-processing below)
                for entry in &filtered_scopes {
                    if is_app_only_scope(&entry.url) {
                        continue;
                    }
                    selected.push(entry.url.to_string());
                }
            } else if recommended && !full && !readonly {
                // Recommended: consumer scopes only (or top-level scopes if filtered).
                for entry in &filtered_scopes {
                    if is_app_only_scope(&entry.url) {
                        continue;
                    }
                    if is_recommended_scope(entry, &all_shorts, services_filter.is_some()) {
                        selected.push(entry.url.to_string());
                    }
                }
            } else if readonly && !full && !recommended {
                for entry in &filtered_scopes {
                    if is_app_only_scope(&entry.url) {
                        continue;
                    }
                    if entry.is_readonly {
                        selected.push(entry.url.to_string());
                    }
                }
            } else {
                for (i, item) in items.iter().enumerate().skip(template_count) {
                    if item.selected {
                        let picker_idx = i - template_count;
                        if let Some(&scope_idx) = valid_scope_indices.get(picker_idx) {
                            if let Some(entry) = filtered_scopes.get(scope_idx) {
                                selected.push(entry.url.to_string());
                            }
                        }
                    }
                }
            }

            // Always include cloud-platform scope
            if !selected.contains(&PLATFORM_SCOPE.to_string()) {
                selected.push(PLATFORM_SCOPE.to_string());
            }

            // Hierarchical dedup: if we have both a broad scope (e.g. `.../auth/drive`)
            // and a narrower scope (e.g. `.../auth/drive.metadata`, `.../auth/drive.readonly`),
            // drop the narrower one since the broad scope subsumes it.
            let prefix = "https://www.googleapis.com/auth/";
            let shorts: Vec<&str> = selected
                .iter()
                .filter_map(|s| s.strip_prefix(prefix))
                .collect();

            let mut deduplicated: Vec<String> = Vec::new();
            for scope in &selected {
                if let Some(short) = scope.strip_prefix(prefix) {
                    // Check if any OTHER selected scope is a prefix of this one
                    // e.g. "drive" is a prefix of "drive.metadata" → drop "drive.metadata"
                    let is_subsumed = shorts.iter().any(|&other| {
                        other != short
                            && short.starts_with(other)
                            && short.as_bytes().get(other.len()) == Some(&b'.')
                    });
                    if is_subsumed {
                        continue;
                    }
                }
                deduplicated.push(scope.clone());
            }

            if deduplicated.len() > 30 {
                eprintln!(
                    "⚠️  Warning: {} scopes selected. Unverified OAuth apps may fail with this many scopes.",
                    deduplicated.len()
                );
            }

            if deduplicated.is_empty() {
                None
            } else {
                Some(deduplicated)
            }
        }
        _ => None, // GoBack, Cancelled, or error
    }
}

/// Run the simple static scope picker (fallback when no project_id available).
fn run_simple_scope_picker(services_filter: Option<&HashSet<String>>) -> Option<Vec<String>> {
    use crate::setup_tui::{PickerResult, SelectItem};

    // Pre-filter SCOPE_ENTRIES by services if a filter is provided
    let entries: Vec<&ScopeEntry> = SCOPE_ENTRIES
        .iter()
        .filter(|entry| {
            services_filter.is_none_or(|services| {
                services.is_empty() || scope_matches_service(entry.scope, services)
            })
        })
        .collect();

    let items: Vec<SelectItem> = entries
        .iter()
        .map(|entry| SelectItem {
            label: entry.label.to_string(),
            description: entry.scope.to_string(),
            selected: true,
            is_fixed: false,
            is_template: false,
            template_selects: vec![],
        })
        .collect();

    match crate::setup_tui::run_picker(
        "Select OAuth scopes",
        "Space to toggle, 'a' to select all, Enter to confirm",
        items,
        true,
    ) {
        Ok(PickerResult::Confirmed(items)) => {
            let selected: Vec<String> = items
                .iter()
                .enumerate()
                .filter(|(_, item)| item.selected)
                .map(|(i, _)| entries[i].scope.to_string())
                .collect();
            if selected.is_empty() {
                None
            } else {
                Some(selected)
            }
        }
        _ => None,
    }
}

async fn handle_status() -> Result<(), GwsError> {
    let plain_path = plain_credentials_path();
    let enc_path = credential_store::encrypted_credentials_path();
    let token_cache = token_cache_path();

    let has_encrypted = enc_path.exists();
    let has_plain = plain_path.exists();
    let has_token_cache = token_cache.exists();

    let auth_method = if has_encrypted || has_plain {
        "oauth2"
    } else {
        "none"
    };

    let storage = if has_encrypted {
        "encrypted"
    } else if has_plain {
        "plaintext"
    } else {
        "none"
    };

    let mut output = json!({
        "auth_method": auth_method,
        "storage": storage,
        "keyring_backend": credential_store::active_backend_name(),
        "encrypted_credentials": enc_path.display().to_string(),
        "encrypted_credentials_exists": has_encrypted,
        "plain_credentials": plain_path.display().to_string(),
        "plain_credentials_exists": has_plain,
        "token_cache_exists": has_token_cache,
    });

    // Show client config (client_secret.json) status
    let config_path = crate::oauth_config::client_config_path();
    let has_config = config_path.exists();
    output["client_config"] = json!(config_path.display().to_string());
    output["client_config_exists"] = json!(has_config);

    if has_config {
        match crate::oauth_config::load_client_config() {
            Ok(config) => {
                output["project_id"] = json!(config.project_id);
                let masked_id = if config.client_id.len() > 12 {
                    format!(
                        "{}...{}",
                        &config.client_id[..8],
                        &config.client_id[config.client_id.len() - 4..]
                    )
                } else {
                    config.client_id.clone()
                };
                output["config_client_id"] = json!(masked_id);
            }
            Err(e) => {
                output["client_config_error"] = json!(e.to_string());
            }
        }
    }

    // Show credential source by attempting actual resolution
    let has_token_env = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN")
        .ok()
        .filter(|t| !t.is_empty())
        .is_some();

    let credential_source = if has_token_env {
        output["token_env_var"] = json!(true);
        "token_env_var"
    } else {
        match resolve_client_credentials() {
            Ok((_, _, _)) => {
                let has_env_id = std::env::var("GOOGLE_WORKSPACE_CLI_CLIENT_ID").is_ok();
                let has_env_secret = std::env::var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET").is_ok();
                if has_env_id && has_env_secret {
                    "environment_variables"
                } else {
                    "client_secret.json"
                }
            }
            Err(_) => "none",
        }
    };
    output["credential_source"] = json!(credential_source);

    // Try to read and show masked info from encrypted credentials
    // Skip real credential/network access in test builds
    if !cfg!(test) {
        if has_encrypted {
            match credential_store::load_encrypted() {
                Ok(contents) => {
                    if let Ok(creds) = serde_json::from_str::<serde_json::Value>(&contents) {
                        if let Some(client_id) = creds.get("client_id").and_then(|v| v.as_str()) {
                            let masked = if client_id.len() > 12 {
                                format!(
                                    "{}...{}",
                                    &client_id[..8],
                                    &client_id[client_id.len() - 4..]
                                )
                            } else {
                                client_id.to_string()
                            };
                            output["client_id"] = json!(masked);
                        }
                        output["has_refresh_token"] = json!(creds
                            .get("refresh_token")
                            .and_then(|v| v.as_str())
                            .is_some());
                    }
                    output["encryption_valid"] = json!(true);
                }
                Err(_) => {
                    output["encryption_valid"] = json!(false);
                    output["encryption_error"] =
                        json!("Could not decrypt. May have been created on a different machine.");
                }
            }
        } else if has_plain {
            match tokio::fs::read_to_string(&plain_path).await {
                Ok(contents) => {
                    if let Ok(creds) = serde_json::from_str::<serde_json::Value>(&contents) {
                        if let Some(client_id) = creds.get("client_id").and_then(|v| v.as_str()) {
                            let masked = if client_id.len() > 12 {
                                format!(
                                    "{}...{}",
                                    &client_id[..8],
                                    &client_id[client_id.len() - 4..]
                                )
                            } else {
                                client_id.to_string()
                            };
                            output["client_id"] = json!(masked);
                        }
                        output["has_refresh_token"] = json!(creds.get("refresh_token").is_some());
                    }
                }
                Err(_) => {
                    output["credentials_readable"] = json!(false);
                }
            }
        }
    } // end !cfg!(test)

    // If we have credentials, try to get live info (user, scopes, APIs)
    // Skip all network calls and subprocess spawning in test builds
    if !cfg!(test) {
        let creds_json_str = if has_encrypted {
            credential_store::load_encrypted().ok()
        } else if has_plain {
            tokio::fs::read_to_string(&plain_path).await.ok()
        } else {
            None
        };

        if let Some(creds_str) = creds_json_str {
            if let Ok(creds) = serde_json::from_str::<serde_json::Value>(&creds_str) {
                let client_id = creds.get("client_id").and_then(|v| v.as_str());
                let client_secret = creds.get("client_secret").and_then(|v| v.as_str());
                let refresh_token = creds.get("refresh_token").and_then(|v| v.as_str());

                if let (Some(cid), Some(csec), Some(rt)) = (client_id, client_secret, refresh_token)
                {
                    // Exchange refresh token for access token
                    if let Ok(http_client) = crate::client::shared_client() {
                        let token_resp = http_client
                            .post("https://oauth2.googleapis.com/token")
                            .form(&[
                                ("client_id", cid),
                                ("client_secret", csec),
                                ("refresh_token", rt),
                                ("grant_type", "refresh_token"),
                            ])
                            .send()
                            .await;

                        if let Ok(resp) = token_resp {
                            if let Ok(token_json) = resp.json::<serde_json::Value>().await {
                                if let Some(access_token) =
                                    token_json.get("access_token").and_then(|v| v.as_str())
                                {
                                    output["token_valid"] = json!(true);

                                    // Get user info
                                    if let Ok(user_resp) = http_client
                                        .get("https://www.googleapis.com/oauth2/v1/userinfo")
                                        .bearer_auth(access_token)
                                        .send()
                                        .await
                                    {
                                        if let Ok(user_json) =
                                            user_resp.json::<serde_json::Value>().await
                                        {
                                            if let Some(email) =
                                                user_json.get("email").and_then(|v| v.as_str())
                                            {
                                                output["user"] = json!(email);
                                            }
                                        }
                                    }

                                    // Get granted scopes via tokeninfo
                                    let tokeninfo_url = format!(
                                        "https://oauth2.googleapis.com/tokeninfo?access_token={}",
                                        access_token
                                    );
                                    if let Ok(info_resp) =
                                        http_client.get(&tokeninfo_url).send().await
                                    {
                                        if let Ok(info_json) =
                                            info_resp.json::<serde_json::Value>().await
                                        {
                                            if let Some(scope_str) =
                                                info_json.get("scope").and_then(|v| v.as_str())
                                            {
                                                let scopes: Vec<&str> =
                                                    scope_str.split(' ').collect();
                                                output["scopes"] = json!(scopes);
                                                output["scope_count"] = json!(scopes.len());
                                            }
                                        }
                                    }
                                } else {
                                    output["token_valid"] = json!(false);
                                    if let Some(err) =
                                        token_json.get("error_description").and_then(|v| v.as_str())
                                    {
                                        output["token_error"] = json!(err);
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }

        // Show enabled APIs if we have a project_id
        if let Some(pid) = output.get("project_id").and_then(|v| v.as_str()) {
            let enabled = crate::setup::get_enabled_apis(pid);
            if !enabled.is_empty() {
                output["enabled_apis"] = json!(enabled);
                output["enabled_api_count"] = json!(enabled.len());
            }
        }
    } // end !cfg!(test)

    println!(
        "{}",
        serde_json::to_string_pretty(&output).unwrap_or_default()
    );
    Ok(())
}

fn handle_logout() -> Result<(), GwsError> {
    let plain_path = plain_credentials_path();
    let enc_path = credential_store::encrypted_credentials_path();
    let token_cache = token_cache_path();
    let sa_token_cache = config_dir().join("sa_token_cache.json");

    let mut removed = Vec::new();

    for path in [&enc_path, &plain_path, &token_cache, &sa_token_cache] {
        if path.exists() {
            std::fs::remove_file(path).map_err(|e| {
                GwsError::Validation(format!("Failed to remove {}: {e}", path.display()))
            })?;
            removed.push(path.display().to_string());
        }
    }

    // Invalidate cached account timezone (may belong to old account)
    crate::timezone::invalidate_cache();

    let output = if removed.is_empty() {
        json!({
            "status": "success",
            "message": "No credentials found to remove.",
        })
    } else {
        json!({
            "status": "success",
            "message": "Logged out. All credentials and token caches removed.",
            "removed": removed,
        })
    };

    println!(
        "{}",
        serde_json::to_string_pretty(&output).unwrap_or_default()
    );
    Ok(())
}

/// Extract refresh_token from yup-oauth2 v12 token cache.
///
/// Supports two formats:
/// 1. Array format (yup-oauth2 default file storage):
///    [{"scopes":[...], "token":{"access_token":..., "refresh_token":...}}]
/// 2. Object/HashMap format (EncryptedTokenStorage serialization):
///    {"scope_key": {"access_token":..., "refresh_token":..., ...}}
pub fn extract_refresh_token(token_data: &str) -> Option<String> {
    let cache: serde_json::Value = serde_json::from_str(token_data).ok()?;

    // Format 1: array of {scopes, token} entries
    if let Some(arr) = cache.as_array() {
        let result = arr.iter().find_map(|entry| {
            entry
                .get("token")
                .and_then(|t| t.get("refresh_token"))
                .and_then(|v| v.as_str())
                .map(|s| s.to_string())
        });
        if result.is_some() {
            return result;
        }
    }

    // Format 2: HashMap<String, TokenInfo> — values are TokenInfo structs
    if let Some(obj) = cache.as_object() {
        for value in obj.values() {
            if let Some(rt) = value.get("refresh_token").and_then(|v| v.as_str()) {
                return Some(rt.to_string());
            }
        }
    }

    None
}

/// Parse --scopes or --readonly from args, falling back to DEFAULT_SCOPES.
/// Scope entry with a human-readable label for the TUI picker.
struct ScopeEntry {
    scope: &'static str,
    label: &'static str,
}

const SCOPE_ENTRIES: &[ScopeEntry] = &[
    ScopeEntry {
        scope: "https://www.googleapis.com/auth/drive",
        label: "Google Drive",
    },
    ScopeEntry {
        scope: "https://www.googleapis.com/auth/spreadsheets",
        label: "Google Sheets",
    },
    ScopeEntry {
        scope: "https://www.googleapis.com/auth/gmail.modify",
        label: "Gmail",
    },
    ScopeEntry {
        scope: "https://www.googleapis.com/auth/calendar",
        label: "Google Calendar",
    },
    ScopeEntry {
        scope: "https://www.googleapis.com/auth/documents",
        label: "Google Docs",
    },
    ScopeEntry {
        scope: "https://www.googleapis.com/auth/presentations",
        label: "Google Slides",
    },
    ScopeEntry {
        scope: "https://www.googleapis.com/auth/tasks",
        label: "Google Tasks",
    },
    ScopeEntry {
        scope: "https://www.googleapis.com/auth/pubsub",
        label: "Cloud Pub/Sub",
    },
    ScopeEntry {
        scope: "https://www.googleapis.com/auth/cloud-platform",
        label: "Cloud Platform",
    },
];

// (parse_scopes removed — replaced by resolve_scopes above)

/// Helper: check if a scope can't be used with user OAuth consent flow
/// (requires a Chat app or service account).
fn is_app_only_scope(url: &str) -> bool {
    url.contains("/auth/chat.app.")
        || url.contains("/auth/chat.bot")
        || url.contains("/auth/chat.import")
        || url.contains("/auth/keep")
        || url.contains("/auth/apps.alerts")
}

/// Helper: check if a scope requires Workspace domain admin access and therefore
/// cannot be granted to personal `@gmail.com` accounts via standard user OAuth.
///
/// These scopes are valid in Workspace environments with a domain admin, but
/// Google returns `400 invalid_scope` when requested by personal accounts.
/// They are excluded from the "Recommended" preset to avoid login failures.
///
/// Affected scope families:
/// - `apps.*`            — Alert Center, Groups Settings, Licensing, Reseller
/// - `cloud-identity.*`  — Cloud Identity: devices, groups, inbound SSO, policies
/// - `ediscovery`        — Google Vault
/// - `directory.readonly`— Admin SDK Directory (read-only)
/// - `groups`            — Groups Management
fn is_workspace_admin_scope(url: &str) -> bool {
    let short = url
        .strip_prefix("https://www.googleapis.com/auth/")
        .unwrap_or(url);
    short.starts_with("apps.")
        || short.starts_with("cloud-identity.")
        || short.starts_with("chat.admin.")
        || short.starts_with("classroom.")
        || short == "ediscovery"
        || short == "directory.readonly"
        || short == "groups"
}

/// Identify services from the filter that have no matching scopes in the result.
///
/// `cloud-platform` is a cross-service scope and does not count as a match
/// for any specific service.
fn find_unmatched_services(scopes: &[String], services: &HashSet<String>) -> HashSet<String> {
    let mut matched_services = HashSet::new();

    for scope in scopes.iter().filter(|s| !s.ends_with("/cloud-platform")) {
        let short = match scope.strip_prefix("https://www.googleapis.com/auth/") {
            Some(s) => s,
            None => continue,
        };
        let prefix = short.split('.').next().unwrap_or(short);

        for service in services {
            if matched_services.contains(service) {
                continue;
            }
            let prefixes = map_service_to_scope_prefixes(service);
            if prefixes
                .iter()
                .any(|mapped| prefix == *mapped || short.starts_with(&format!("{mapped}.")))
            {
                matched_services.insert(service.clone());
            }
        }
    }

    services.difference(&matched_services).cloned().collect()
}

/// Extract OAuth scope URLs from a Discovery document.
///
/// Filters out app-only scopes (e.g. `chat.bot`, `chat.app.*`) and optionally
/// restricts to `.readonly` scopes when `readonly_only` is true.
fn extract_scopes_from_doc(
    doc: &crate::discovery::RestDescription,
    readonly_only: bool,
) -> Vec<String> {
    let scopes = match doc
        .auth
        .as_ref()
        .and_then(|a| a.oauth2.as_ref())
        .and_then(|o| o.scopes.as_ref())
    {
        Some(s) => s,
        None => return Vec::new(),
    };
    scopes
        .keys()
        .filter(|url| !is_app_only_scope(url))
        .filter(|url| !readonly_only || url.ends_with(".readonly"))
        .cloned()
        .collect()
}

/// Fetch scopes from Discovery docs for services that had no matching scopes
/// in the static lists. Failures are silently skipped (graceful degradation).
async fn fetch_scopes_for_unmatched_services(
    services: &HashSet<String>,
    readonly_only: bool,
) -> Vec<String> {
    let futures: Vec<_> = services
        .iter()
        .filter_map(|svc| {
            let (api_name, version) = crate::services::resolve_service(svc).ok()?;
            Some(async move {
                crate::discovery::fetch_discovery_document(&api_name, &version)
                    .await
                    .ok()
                    .map(|doc| extract_scopes_from_doc(&doc, readonly_only))
            })
        })
        .collect();

    let mut result: Vec<String> = futures_util::future::join_all(futures)
        .await
        .into_iter()
        .flatten()
        .flatten()
        .collect();
    result.sort();
    result.dedup();
    result
}

/// If a services filter is active and some services have no matching scopes in
/// the static result, dynamically fetch their scopes from Discovery docs.
async fn augment_with_dynamic_scopes(
    result: &mut Vec<String>,
    services_filter: Option<&HashSet<String>>,
    readonly_only: bool,
) {
    if let Some(services) = services_filter {
        let missing = find_unmatched_services(result, services);
        if !missing.is_empty() {
            let dynamic = fetch_scopes_for_unmatched_services(&missing, readonly_only).await;
            for scope in dynamic {
                if !result.contains(&scope) {
                    result.push(scope);
                }
            }
        }
    }
}

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

    /// Helper to run resolve_scopes in tests (async).
    fn run_resolve_scopes(scope_mode: ScopeMode, project_id: Option<&str>) -> Vec<String> {
        let rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(resolve_scopes(scope_mode, project_id, None))
    }

    /// Helper to run resolve_scopes with a services filter.
    fn run_resolve_scopes_with_services(
        scope_mode: ScopeMode,
        project_id: Option<&str>,
        services: &[&str],
    ) -> Vec<String> {
        let filter: HashSet<String> = services.iter().map(|s| s.to_string()).collect();
        let rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(resolve_scopes(scope_mode, project_id, Some(&filter)))
    }

    #[test]
    fn resolve_scopes_returns_defaults_when_no_flag() {
        let scopes = run_resolve_scopes(ScopeMode::Default, None);
        assert_eq!(scopes.len(), DEFAULT_SCOPES.len());
        assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive");
    }

    #[test]
    fn resolve_scopes_returns_custom_scopes() {
        let scopes = run_resolve_scopes(
            ScopeMode::Custom(vec![
                "https://www.googleapis.com/auth/drive.readonly".to_string()
            ]),
            None,
        );
        assert_eq!(scopes.len(), 1);
        assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive.readonly");
    }

    #[test]
    fn resolve_scopes_handles_multiple_custom() {
        let scopes = run_resolve_scopes(
            ScopeMode::Custom(vec![
                "https://www.googleapis.com/auth/drive".to_string(),
                "https://www.googleapis.com/auth/gmail.readonly".to_string(),
            ]),
            None,
        );
        assert_eq!(scopes.len(), 2);
        assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive");
        assert_eq!(scopes[1], "https://www.googleapis.com/auth/gmail.readonly");
    }

    #[test]
    fn resolve_scopes_readonly_returns_readonly_scopes() {
        let scopes = run_resolve_scopes(ScopeMode::Readonly, None);
        assert_eq!(scopes.len(), READONLY_SCOPES.len());
        for scope in &scopes {
            assert!(
                scope.ends_with(".readonly"),
                "Expected readonly scope, got: {scope}"
            );
        }
    }

    #[test]
    fn resolve_scopes_full_returns_full_scopes() {
        let scopes = run_resolve_scopes(ScopeMode::Full, None);
        assert_eq!(scopes.len(), FULL_SCOPES.len());
    }

    #[test]
    #[serial_test::serial]
    fn resolve_client_credentials_from_env_vars() {
        unsafe {
            std::env::set_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID", "test-id");
            std::env::set_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET", "test-secret");
        }
        let result = resolve_client_credentials();
        unsafe {
            std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID");
            std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET");
        }
        let (id, secret, _project_id) = result.unwrap();
        assert_eq!(id, "test-id");
        assert_eq!(secret, "test-secret");
        // project_id may be Some if client_secret.json exists on the machine
    }

    #[test]
    #[serial_test::serial]
    fn resolve_client_credentials_missing_env_vars_uses_config() {
        unsafe {
            std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID");
            std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET");
        }
        // Result depends on whether client_secret.json exists on the machine
        let result = resolve_client_credentials();
        if crate::oauth_config::client_config_path().exists() {
            assert!(
                result.is_ok(),
                "Should succeed when client_secret.json exists"
            );
        } else {
            assert!(result.is_err());
            let err_msg = result.unwrap_err().to_string();
            assert!(err_msg.contains("No OAuth client configured"));
        }
    }

    #[test]
    #[serial_test::serial]
    fn config_dir_returns_gws_subdir() {
        let path = config_dir();
        assert!(path.ends_with("gws"));
    }

    #[test]
    fn config_dir_primary_uses_dot_config() {
        // The primary (non-test) path should be ~/.config/gws.
        // We can't easily test the real function without env override,
        // but we verify the building blocks: home_dir + .config + gws.
        let primary = dirs::home_dir().unwrap().join(".config").join("gws");
        assert!(primary.ends_with(".config/gws") || primary.ends_with(r".config\gws"));
    }

    #[test]
    #[serial_test::serial]
    fn config_dir_fallback_to_legacy() {
        // When GOOGLE_WORKSPACE_CLI_CONFIG_DIR points to a legacy-style dir,
        // config_dir() should return it (simulating the test env override).
        let dir = tempfile::tempdir().unwrap();
        let legacy = dir.path().join("legacy_gws");
        std::fs::create_dir_all(&legacy).unwrap();

        unsafe {
            std::env::set_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", legacy.to_str().unwrap());
        }
        let path = config_dir();
        assert_eq!(path, legacy);
        unsafe {
            std::env::remove_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR");
        }
    }

    #[test]
    #[serial_test::serial]
    fn plain_credentials_path_defaults_to_config_dir() {
        // Without env var, should be in config dir
        unsafe {
            std::env::remove_var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE");
        }
        let path = plain_credentials_path();
        assert!(path.ends_with("credentials.json"));
        assert!(path.starts_with(config_dir()));
    }

    #[test]
    #[serial_test::serial]
    fn plain_credentials_path_respects_env_var() {
        unsafe {
            std::env::set_var(
                "GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE",
                "/tmp/test-creds.json",
            );
        }
        let path = plain_credentials_path();
        assert_eq!(path, PathBuf::from("/tmp/test-creds.json"));
        unsafe {
            std::env::remove_var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE");
        }
    }

    #[test]
    fn token_cache_path_is_in_config_dir() {
        let path = token_cache_path();
        assert!(path.ends_with("token_cache.json"));
        assert!(path.starts_with(config_dir()));
    }

    #[tokio::test]
    async fn handle_auth_command_empty_args_prints_usage() {
        let args: Vec<String> = vec![];
        let result = handle_auth_command(&args).await;
        // Empty args now prints usage and returns Ok
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn handle_auth_command_help_flag_returns_ok() {
        let args = vec!["--help".to_string()];
        let result = handle_auth_command(&args).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn handle_auth_command_help_short_flag_returns_ok() {
        let args = vec!["-h".to_string()];
        let result = handle_auth_command(&args).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn handle_auth_command_invalid_subcommand() {
        let args = vec!["frobnicate".to_string()];
        let result = handle_auth_command(&args).await;
        assert!(result.is_err());
        match result.unwrap_err() {
            GwsError::Validation(msg) => assert!(msg.contains("frobnicate")),
            other => panic!("Expected Validation error, got: {other:?}"),
        }
    }

    #[test]
    #[serial_test::serial]
    fn resolve_credentials_fails_without_env_vars_or_config() {
        unsafe {
            std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID");
            std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET");
        }
        // When no env vars AND no client_secret.json on disk, should fail
        let result = resolve_client_credentials();
        if !crate::oauth_config::client_config_path().exists() {
            assert!(result.is_err());
            match result.unwrap_err() {
                GwsError::Auth(msg) => assert!(msg.contains("No OAuth client configured")),
                other => panic!("Expected Auth error, got: {other:?}"),
            }
        }
        // If client_secret.json exists on the dev machine, credentials resolve
        // successfully — that's correct behavior, not a test failure.
    }

    #[test]
    #[serial_test::serial]
    fn resolve_credentials_uses_env_vars_when_present() {
        unsafe {
            std::env::set_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID", "test-id");
            std::env::set_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET", "test-secret");
        }

        let result = resolve_client_credentials();

        // Clean up immediately
        unsafe {
            std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID");
            std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET");
        }

        let (id, secret, _) = result.unwrap();
        assert_eq!(id, "test-id");
        assert_eq!(secret, "test-secret");
    }

    #[tokio::test]
    async fn handle_status_succeeds_without_credentials() {
        // status should always succeed and report "none"
        let args = vec!["status".to_string()];
        let result = handle_auth_command(&args).await;
        assert!(result.is_ok());
    }

    #[test]
    fn credential_store_save_load_round_trip() {
        // Use encrypt/decrypt directly to avoid writing to the real config dir
        let json = r#"{"client_id":"test","client_secret":"secret","refresh_token":"tok"}"#;
        let encrypted = credential_store::encrypt(json.as_bytes()).expect("encrypt should succeed");
        let decrypted = credential_store::decrypt(&encrypted).expect("decrypt should succeed");
        assert_eq!(String::from_utf8(decrypted).unwrap(), json);
    }

    #[test]
    fn extract_refresh_token_from_yup_oauth2_format() {
        // Actual format produced by yup-oauth2 v12
        let data = r#"[{"scopes":["https://www.googleapis.com/auth/drive"],"token":{"access_token":"ya29.test","refresh_token":"1//test-refresh-token","expires_at":[2026,43,19,44,15,0,0,0,0],"id_token":null}}]"#;
        assert_eq!(
            extract_refresh_token(data),
            Some("1//test-refresh-token".to_string())
        );
    }

    #[test]
    fn extract_refresh_token_missing_token() {
        let data = r#"[{"scopes":["scope"],"token":{"access_token":"ya29.test"}}]"#;
        assert_eq!(extract_refresh_token(data), None);
    }

    #[test]
    fn extract_refresh_token_empty_array() {
        assert_eq!(extract_refresh_token("[]"), None);
    }

    #[test]
    fn extract_refresh_token_invalid_json() {
        assert_eq!(extract_refresh_token("not json"), None);
    }

    #[test]
    fn extract_refresh_token_object_format() {
        // HashMap<String, TokenInfo> format from EncryptedTokenStorage
        let data = r#"{"key":{"access_token":"ya29","refresh_token":"1//tok"}}"#;
        assert_eq!(extract_refresh_token(data), Some("1//tok".to_string()));
    }

    // ── is_workspace_admin_scope tests ──────────────────────────────────

    #[test]
    fn is_workspace_admin_scope_apps_alerts() {
        assert!(is_workspace_admin_scope(
            "https://www.googleapis.com/auth/apps.alerts"
        ));
    }

    #[test]
    fn is_workspace_admin_scope_apps_groups_settings() {
        assert!(is_workspace_admin_scope(
            "https://www.googleapis.com/auth/apps.groups.settings"
        ));
    }

    #[test]
    fn is_workspace_admin_scope_apps_licensing() {
        assert!(is_workspace_admin_scope(
            "https://www.googleapis.com/auth/apps.licensing"
        ));
    }

    #[test]
    fn is_workspace_admin_scope_cloud_identity() {
        assert!(is_workspace_admin_scope(
            "https://www.googleapis.com/auth/cloud-identity.groups"
        ));
        assert!(is_workspace_admin_scope(
            "https://www.googleapis.com/auth/cloud-identity.devices"
        ));
        assert!(is_workspace_admin_scope(
            "https://www.googleapis.com/auth/cloud-identity.policies"
        ));
    }

    #[test]
    fn is_workspace_admin_scope_ediscovery() {
        assert!(is_workspace_admin_scope(
            "https://www.googleapis.com/auth/ediscovery"
        ));
    }

    #[test]
    fn is_workspace_admin_scope_directory_readonly() {
        assert!(is_workspace_admin_scope(
            "https://www.googleapis.com/auth/directory.readonly"
        ));
    }

    #[test]
    fn is_workspace_admin_scope_groups() {
        assert!(is_workspace_admin_scope(
            "https://www.googleapis.com/auth/groups"
        ));
    }

    #[test]
    fn is_workspace_admin_scope_normal_scopes_not_admin() {
        // Consumer/personal-account scopes must NOT be classified as admin-only
        assert!(!is_workspace_admin_scope(
            "https://www.googleapis.com/auth/drive"
        ));
        assert!(!is_workspace_admin_scope(
            "https://www.googleapis.com/auth/gmail.modify"
        ));
        assert!(!is_workspace_admin_scope(
            "https://www.googleapis.com/auth/calendar"
        ));
        assert!(!is_workspace_admin_scope(
            "https://www.googleapis.com/auth/spreadsheets"
        ));
        assert!(!is_workspace_admin_scope(
            "https://www.googleapis.com/auth/chat.messages"
        ));
    }

    // ── is_workspace_admin_scope – new patterns ─────────────────────────

    #[test]
    fn is_workspace_admin_scope_chat_admin() {
        assert!(is_workspace_admin_scope(
            "https://www.googleapis.com/auth/chat.admin.memberships"
        ));
        assert!(is_workspace_admin_scope(
            "https://www.googleapis.com/auth/chat.admin.memberships.readonly"
        ));
        assert!(is_workspace_admin_scope(
            "https://www.googleapis.com/auth/chat.admin.spaces"
        ));
        assert!(is_workspace_admin_scope(
            "https://www.googleapis.com/auth/chat.admin.spaces.readonly"
        ));
    }

    #[test]
    fn is_workspace_admin_scope_classroom() {
        assert!(is_workspace_admin_scope(
            "https://www.googleapis.com/auth/classroom.courses"
        ));
        assert!(is_workspace_admin_scope(
            "https://www.googleapis.com/auth/classroom.rosters"
        ));
    }

    // ── scope_matches_service tests ──────────────────────────────────────

    #[test]
    fn scope_matches_service_exact_match() {
        let services: HashSet<String> = ["drive"].iter().map(|s| s.to_string()).collect();
        assert!(scope_matches_service(
            "https://www.googleapis.com/auth/drive",
            &services
        ));
    }

    #[test]
    fn scope_matches_service_aliases() {
        let services: HashSet<String> = ["sheets", "docs", "slides"]
            .iter()
            .map(|s| s.to_string())
            .collect();

        assert!(scope_matches_service(
            "https://www.googleapis.com/auth/spreadsheets",
            &services
        ));
        assert!(scope_matches_service(
            "https://www.googleapis.com/auth/documents",
            &services
        ));
        assert!(scope_matches_service(
            "https://www.googleapis.com/auth/presentations",
            &services
        ));
    }

    #[test]
    fn scope_matches_service_prefix_match() {
        let services: HashSet<String> = ["drive"].iter().map(|s| s.to_string()).collect();
        assert!(scope_matches_service(
            "https://www.googleapis.com/auth/drive.readonly",
            &services
        ));
        assert!(scope_matches_service(
            "https://www.googleapis.com/auth/drive.metadata.readonly",
            &services
        ));
    }

    #[test]
    fn scope_matches_service_no_match() {
        let services: HashSet<String> = ["gmail"].iter().map(|s| s.to_string()).collect();
        assert!(!scope_matches_service(
            "https://www.googleapis.com/auth/drive",
            &services
        ));
    }

    #[test]
    fn scope_matches_service_cloud_platform_always_matches() {
        let services: HashSet<String> = ["drive"].iter().map(|s| s.to_string()).collect();
        assert!(scope_matches_service(
            "https://www.googleapis.com/auth/cloud-platform",
            &services
        ));
    }

    #[test]
    fn scope_matches_service_no_partial_name_collision() {
        // "drive" should NOT match "driveactivity" or similar
        let services: HashSet<String> = ["drive"].iter().map(|s| s.to_string()).collect();
        assert!(!scope_matches_service(
            "https://www.googleapis.com/auth/driveactivity",
            &services
        ));
    }

    #[test]
    fn scope_matches_service_people_contacts() {
        let services: HashSet<String> = ["people"].iter().map(|s| s.to_string()).collect();
        assert!(scope_matches_service(
            "https://www.googleapis.com/auth/contacts",
            &services
        ));
        assert!(scope_matches_service(
            "https://www.googleapis.com/auth/contacts.readonly",
            &services
        ));
        assert!(scope_matches_service(
            "https://www.googleapis.com/auth/contacts.other.readonly",
            &services
        ));
        assert!(scope_matches_service(
            "https://www.googleapis.com/auth/directory.readonly",
            &services
        ));
    }

    #[test]
    fn scope_matches_service_chat() {
        let services: HashSet<String> = ["chat"].iter().map(|s| s.to_string()).collect();
        assert!(scope_matches_service(
            "https://www.googleapis.com/auth/chat.spaces",
            &services
        ));
        assert!(scope_matches_service(
            "https://www.googleapis.com/auth/chat.messages",
            &services
        ));
    }

    // ── services filter integration tests ────────────────────────────────

    #[test]
    fn resolve_scopes_with_services_filter() {
        let scopes =
            run_resolve_scopes_with_services(ScopeMode::Default, None, &["drive", "gmail"]);
        assert!(!scopes.is_empty());
        for scope in &scopes {
            let short = scope
                .strip_prefix("https://www.googleapis.com/auth/")
                .unwrap_or(scope);
            assert!(
                short.starts_with("drive")
                    || short.starts_with("gmail")
                    || short == "cloud-platform",
                "Unexpected scope with service filter: {scope}"
            );
        }
    }

    #[test]
    fn resolve_scopes_services_filter_unknown_service_ignored() {
        let scopes =
            run_resolve_scopes_with_services(ScopeMode::Default, None, &["drive", "nonexistent"]);
        assert!(!scopes.is_empty());
        // Should contain drive scope but not be affected by nonexistent
        assert!(scopes.iter().any(|s| s.contains("/auth/drive")));
    }

    #[test]
    fn resolve_scopes_services_takes_priority_with_readonly() {
        let scopes = run_resolve_scopes_with_services(ScopeMode::Readonly, None, &["drive"]);
        assert!(!scopes.is_empty());
        for scope in &scopes {
            let short = scope
                .strip_prefix("https://www.googleapis.com/auth/")
                .unwrap_or(scope);
            assert!(
                short.starts_with("drive") || short == "cloud-platform",
                "Unexpected scope with service + readonly filter: {scope}"
            );
        }
    }

    #[test]
    fn resolve_scopes_services_takes_priority_with_full() {
        let scopes = run_resolve_scopes_with_services(ScopeMode::Full, None, &["gmail"]);
        assert!(!scopes.is_empty());
        for scope in &scopes {
            let short = scope
                .strip_prefix("https://www.googleapis.com/auth/")
                .unwrap_or(scope);
            assert!(
                short.starts_with("gmail") || short == "cloud-platform",
                "Unexpected scope with service + full filter: {scope}"
            );
        }
    }

    #[test]
    fn resolve_scopes_explicit_scopes_bypass_services_filter() {
        // Custom scopes take priority over services filter
        let scopes = run_resolve_scopes_with_services(
            ScopeMode::Custom(vec!["https://www.googleapis.com/auth/calendar".to_string()]),
            None,
            &["drive"],
        );
        assert_eq!(scopes.len(), 1);
        assert_eq!(scopes[0], "https://www.googleapis.com/auth/calendar");
    }

    #[test]
    fn filter_scopes_by_services_none_returns_all() {
        let scopes = vec![
            "https://www.googleapis.com/auth/drive".to_string(),
            "https://www.googleapis.com/auth/gmail.modify".to_string(),
        ];
        let result = filter_scopes_by_services(scopes.clone(), None);
        assert_eq!(result, scopes);
    }

    #[test]
    fn filter_scopes_by_services_empty_set_returns_all() {
        let scopes = vec![
            "https://www.googleapis.com/auth/drive".to_string(),
            "https://www.googleapis.com/auth/gmail.modify".to_string(),
        ];
        let empty: HashSet<String> = HashSet::new();
        let result = filter_scopes_by_services(scopes.clone(), Some(&empty));
        assert_eq!(result, scopes);
    }

    #[test]
    fn filter_restrictive_removes_metadata_when_broader_present() {
        let scopes = vec![
            "https://www.googleapis.com/auth/gmail.modify".to_string(),
            "https://www.googleapis.com/auth/gmail.metadata".to_string(),
            "https://www.googleapis.com/auth/drive".to_string(),
        ];
        let result = filter_redundant_restrictive_scopes(scopes);
        assert!(!result.iter().any(|s| s.contains("gmail.metadata")));
        assert_eq!(result.len(), 2);
    }

    #[test]
    fn filter_restrictive_removes_metadata_when_full_gmail_present() {
        let scopes = vec![
            "https://mail.google.com/".to_string(),
            "https://www.googleapis.com/auth/gmail.metadata".to_string(),
        ];
        let result = filter_redundant_restrictive_scopes(scopes);
        assert_eq!(result, vec!["https://mail.google.com/"]);
    }

    #[test]
    fn filter_restrictive_keeps_metadata_when_only_scope() {
        let scopes = vec![
            "https://www.googleapis.com/auth/gmail.metadata".to_string(),
            "https://www.googleapis.com/auth/drive".to_string(),
        ];
        let result = filter_redundant_restrictive_scopes(scopes.clone());
        assert_eq!(result, scopes);
    }

    #[test]
    fn mask_secret_long_string() {
        let masked = mask_secret("GOCSPX-abcdefghijklmnopqrstuvwxyz");
        assert_eq!(masked, "GOCS...wxyz");
    }

    #[test]
    fn mask_secret_short_string() {
        // 8 chars or fewer should be fully masked
        assert_eq!(mask_secret("12345678"), "***");
        assert_eq!(mask_secret("short"), "***");
        assert_eq!(mask_secret(""), "***");
    }

    #[test]
    fn mask_secret_boundary() {
        // Exactly 9 chars — first 4 + last 4 with "..." in between
        assert_eq!(mask_secret("123456789"), "1234...6789");
    }

    #[test]
    fn mask_secret_multibyte_utf8() {
        // Multi-byte chars must not panic (previously used byte slicing)
        assert_eq!(mask_secret("áéíóúñüÁÉÍÓÚ"), "áéíó...ÉÍÓÚ");
        // Short multi-byte — should fully mask
        assert_eq!(mask_secret("café"), "***");
        // Exactly at boundary with multi-byte (9 Greek chars)
        assert_eq!(mask_secret("αβγδεζηθι"), "αβγδ...ζηθι");
    }

    #[test]
    fn find_unmatched_services_identifies_missing() {
        let scopes = vec![
            "https://www.googleapis.com/auth/drive".to_string(),
            "https://www.googleapis.com/auth/cloud-platform".to_string(),
        ];
        let services: HashSet<String> = ["drive", "chat"].iter().map(|s| s.to_string()).collect();
        let missing = find_unmatched_services(&scopes, &services);
        assert!(!missing.contains("drive"));
        assert!(missing.contains("chat"));
    }

    #[test]
    fn find_unmatched_services_all_matched() {
        let scopes = vec![
            "https://www.googleapis.com/auth/drive".to_string(),
            "https://www.googleapis.com/auth/gmail.modify".to_string(),
        ];
        let services: HashSet<String> = ["drive", "gmail"].iter().map(|s| s.to_string()).collect();
        let missing = find_unmatched_services(&scopes, &services);
        assert!(missing.is_empty());
    }

    fn make_test_discovery_doc(scope_urls: &[&str]) -> crate::discovery::RestDescription {
        let mut scopes = std::collections::HashMap::new();
        for url in scope_urls {
            scopes.insert(
                url.to_string(),
                crate::discovery::ScopeDescription {
                    description: Some("test".to_string()),
                },
            );
        }
        crate::discovery::RestDescription {
            auth: Some(crate::discovery::AuthDescription {
                oauth2: Some(crate::discovery::OAuth2Description {
                    scopes: Some(scopes),
                }),
            }),
            ..Default::default()
        }
    }

    #[test]
    fn extract_scopes_from_doc_filters_app_only() {
        let doc = make_test_discovery_doc(&[
            "https://www.googleapis.com/auth/chat.messages",
            "https://www.googleapis.com/auth/chat.bot",
            "https://www.googleapis.com/auth/chat.app.spaces",
            "https://www.googleapis.com/auth/chat.spaces",
        ]);
        let mut result = extract_scopes_from_doc(&doc, false);
        result.sort();
        assert_eq!(
            result,
            vec![
                "https://www.googleapis.com/auth/chat.messages",
                "https://www.googleapis.com/auth/chat.spaces",
            ]
        );
    }

    #[test]
    fn extract_scopes_from_doc_readonly_filter() {
        let doc = make_test_discovery_doc(&[
            "https://www.googleapis.com/auth/chat.messages",
            "https://www.googleapis.com/auth/chat.messages.readonly",
            "https://www.googleapis.com/auth/chat.spaces",
            "https://www.googleapis.com/auth/chat.spaces.readonly",
        ]);
        let mut result = extract_scopes_from_doc(&doc, true);
        result.sort();
        assert_eq!(
            result,
            vec![
                "https://www.googleapis.com/auth/chat.messages.readonly",
                "https://www.googleapis.com/auth/chat.spaces.readonly",
            ]
        );
    }

    #[test]
    fn extract_scopes_from_doc_empty_auth() {
        let doc = crate::discovery::RestDescription {
            auth: None,
            ..Default::default()
        };
        let result = extract_scopes_from_doc(&doc, false);
        assert!(result.is_empty());
    }

    #[test]
    fn build_proxy_auth_url_encodes_scope_and_redirect_uri() {
        let scopes = vec![
            "https://www.googleapis.com/auth/drive".to_string(),
            "openid".to_string(),
        ];
        let url = build_proxy_auth_url("client id", "http://localhost:8080/callback path", &scopes);

        assert!(url.contains("client_id=client%20id"));
        assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback%20path"));
        assert!(url.contains(&format!(
            "scope={}",
            urlencoding("https://www.googleapis.com/auth/drive openid")
        )));
    }

    #[test]
    fn extract_authorization_code_returns_code() {
        let code =
            extract_authorization_code("GET /?state=abc&code=4/test-code&scope=openid HTTP/1.1")
                .unwrap();
        assert_eq!(code, "4/test-code");
    }

    #[test]
    fn extract_authorization_code_rejects_missing_code() {
        let err = extract_authorization_code("GET /?state=abc HTTP/1.1").unwrap_err();
        assert!(err.to_string().contains("No authorization code"));
    }

    #[test]
    fn read_refresh_token_from_cache_reads_encrypted_storage() {
        let token_data = r#"[{"token":{"refresh_token":"1//refresh-token"}}]"#;
        let encrypted = crate::credential_store::encrypt(token_data.as_bytes()).unwrap();
        let mut file = tempfile::NamedTempFile::new().unwrap();
        std::io::Write::write_all(&mut file, &encrypted).unwrap();

        let refresh_token = read_refresh_token_from_cache(file.path()).unwrap();
        assert_eq!(refresh_token, "1//refresh-token");
    }

    #[test]
    fn read_refresh_token_from_cache_requires_refresh_token() {
        let token_data = r#"[{"token":{"access_token":"ya29.no-refresh"}}]"#;
        let encrypted = crate::credential_store::encrypt(token_data.as_bytes()).unwrap();
        let mut file = tempfile::NamedTempFile::new().unwrap();
        std::io::Write::write_all(&mut file, &encrypted).unwrap();

        let err = read_refresh_token_from_cache(file.path()).unwrap_err();
        assert!(err.to_string().contains("no refresh token was returned"));
    }
}
