use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
use zeroclaw_api::tool::{Tool, ToolOutput, ToolResult};
use zeroclaw_config::policy::SecurityPolicy;
use zeroclaw_config::schema::GoogleWorkspaceAllowedOperation;

#[cfg(test)]
const DEFAULT_GWS_TIMEOUT_SECS: u64 = 30;
/// Maximum output size in bytes (1MB).
const MAX_OUTPUT_BYTES: usize = 1_048_576;

use zeroclaw_config::schema::DEFAULT_GWS_SERVICES;

pub struct GoogleWorkspaceTool {
    security: Arc<SecurityPolicy>,
    allowed_services: Vec<String>,
    allowed_operations: Vec<GoogleWorkspaceAllowedOperation>,
    credentials_path: Option<String>,
    default_account: Option<String>,
    #[allow(dead_code)] // Config field for future rate-limiting
    rate_limit_per_minute: u32,
    timeout_secs: u64,
    audit_log: bool,
}

impl GoogleWorkspaceTool {
    /// Create a new `GoogleWorkspaceTool`.
    /// If `allowed_services` is empty, the default service set is used.
    pub fn new(
        security: Arc<SecurityPolicy>,
        allowed_services: Vec<String>,
        allowed_operations: Vec<GoogleWorkspaceAllowedOperation>,
        credentials_path: Option<String>,
        default_account: Option<String>,
        rate_limit_per_minute: u32,
        timeout_secs: u64,
        audit_log: bool,
    ) -> Self {
        let services = if allowed_services.is_empty() {
            DEFAULT_GWS_SERVICES
                .iter()
                .map(|s| (*s).to_string())
                .collect()
        } else {
            allowed_services
                .into_iter()
                .map(|s| s.trim().to_string())
                .collect()
        };
        // Normalize stored operation fields at construction time so runtime
        // comparisons can use plain equality without repeated .trim() calls.
        let operations = allowed_operations
            .into_iter()
            .map(|op| GoogleWorkspaceAllowedOperation {
                service: op.service.trim().to_string(),
                resource: op.resource.trim().to_string(),
                sub_resource: op.sub_resource.as_deref().map(|s| s.trim().to_string()),
                methods: op.methods.iter().map(|m| m.trim().to_string()).collect(),
            })
            .collect();
        Self {
            security,
            allowed_services: services,
            allowed_operations: operations,
            credentials_path,
            default_account,
            rate_limit_per_minute,
            timeout_secs,
            audit_log,
        }
    }

    /// Build the positional `gws` arguments: `[service, resource, (sub_resource,)? method]`.
    fn positional_cmd_args(
        service: &str,
        resource: &str,
        sub_resource: Option<&str>,
        method: &str,
    ) -> Vec<String> {
        let mut args = vec![service.to_string(), resource.to_string()];
        if let Some(sub) = sub_resource {
            args.push(sub.to_string());
        }
        args.push(method.to_string());
        args
    }

    /// Build the `--page-all` and `--page-limit` flags from validated pagination inputs.
    /// `page_limit` alone (without `page_all`) caps page count; both together fetch all pages
    /// up to the limit.
    fn build_pagination_args(page_all: bool, page_limit: Option<u64>) -> Vec<String> {
        let mut args = Vec::new();
        if page_all {
            args.push("--page-all".into());
        }
        if page_all || page_limit.is_some() {
            args.push("--page-limit".into());
            args.push(page_limit.unwrap_or(10).to_string());
        }
        args
    }

    fn is_operation_allowed(
        &self,
        service: &str,
        resource: &str,
        sub_resource: Option<&str>,
        method: &str,
    ) -> bool {
        if self.allowed_operations.is_empty() {
            return true;
        }
        self.allowed_operations.iter().any(|operation| {
            operation.service == service
                && operation.resource == resource
                && operation.sub_resource.as_deref() == sub_resource
                && operation.methods.iter().any(|allowed| allowed == method)
        })
    }
}

#[async_trait]
impl Tool for GoogleWorkspaceTool {
    fn name(&self) -> &str {
        "google_workspace"
    }

    fn description(&self) -> &str {
        "Interact with Google Workspace services (Drive, Gmail, Calendar, Sheets, Docs, etc.) \
         via the gws CLI. Requires gws to be installed and authenticated. \
         IMPORTANT: Gmail commands are 4-segment and REQUIRE sub_resource. \
         To list Gmail messages, use service=gmail, resource=users, sub_resource=messages, method=list \
         (this becomes `gws gmail users messages list`). \
         Without sub_resource, Gmail calls will fail. \
         Drive, Calendar, and Sheets are 3-segment and do NOT use sub_resource."
    }

    fn parameters_schema(&self) -> serde_json::Value {
        json!({
            "type": "object",
            "properties": {
                "service": {
                    "type": "string",
                    "description": "Google Workspace service (e.g. drive, gmail, calendar, sheets, docs, slides, tasks, people, chat, classroom, forms, keep, meet, events)"
                },
                "resource": {
                    "type": "string",
                    "description": "Top-level resource. For Gmail this is always 'users'. For Drive use 'files'. For Calendar use 'events' or 'calendars'. For Sheets use 'spreadsheets'."
                },
                "method": {
                    "type": "string",
                    "description": "Method to call on the resource (e.g. list, get, create, update, delete)"
                },
                "sub_resource": {
                    "type": "string",
                    "description": "Sub-resource for 4-segment gws commands. REQUIRED for Gmail: use 'messages', 'threads', 'drafts', or 'labels' (e.g. gmail/users/messages/list). Omit for 3-segment services like Drive, Calendar, and Sheets."
                },
                "params": {
                    "type": "object",
                    "description": "URL/query parameters as key-value pairs (passed as --params JSON). For Gmail, ALWAYS include `userId: \"me\"` to refer to the authenticated user (e.g. {\"userId\":\"me\",\"maxResults\":10}). For Calendar events.list, include `calendarId: \"primary\"`."
                },
                "body": {
                    "type": "object",
                    "description": "Request body for POST/PATCH/PUT operations (passed as --json JSON)"
                },
                "format": {
                    "type": "string",
                    "enum": ["json", "table", "yaml", "csv"],
                    "description": "Output format (default: json)"
                },
                "page_all": {
                    "type": "boolean",
                    "description": "Auto-paginate through all results"
                },
                "page_limit": {
                    "type": "integer",
                    "description": "Max pages to fetch when using page_all (default: 10)"
                }
            },
            "required": ["service", "resource", "method"]
        })
    }

    /// Execute a Google Workspace CLI command with input validation and security enforcement.
    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
        let service = args
            .get("service")
            .and_then(|v| v.as_str())
            .ok_or_else(|| {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_attrs(::serde_json::json!({"param": "service"})),
                    "google_workspace: missing service parameter"
                );
                anyhow::Error::msg("Missing 'service' parameter")
            })?;
        let resource = args
            .get("resource")
            .and_then(|v| v.as_str())
            .ok_or_else(|| {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                        .with_attrs(::serde_json::json!({"param": "resource"})),
                    "google_workspace: missing resource parameter"
                );
                anyhow::Error::msg("Missing 'resource' parameter")
            })?;
        let method = args.get("method").and_then(|v| v.as_str()).ok_or_else(|| {
            ::zeroclaw_log::record!(
                WARN,
                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
                    .with_attrs(::serde_json::json!({"param": "method"})),
                "google_workspace: missing method parameter"
            );
            anyhow::Error::msg("Missing 'method' parameter")
        })?;

        // Extract and validate sub_resource early so the allowlist check can account for it.
        let sub_resource: Option<&str> = if let Some(sub_resource_value) = args.get("sub_resource")
        {
            let s = match sub_resource_value.as_str() {
                Some(s) => s,
                None => {
                    return Ok(ToolResult {
                        success: false,
                        output: ToolOutput::default(),
                        error: Some("'sub_resource' must be a string".into()),
                    });
                }
            };
            if !s
                .chars()
                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
            {
                return Ok(ToolResult {
                    success: false,
                    output: ToolOutput::default(),
                    error: Some(
                        "Invalid characters in 'sub_resource': only lowercase alphanumeric, underscore, and hyphen are allowed"
                            .into(),
                    ),
                });
            }
            Some(s)
        } else {
            None
        };

        // Security checks
        if self.security.is_rate_limited() {
            return Ok(ToolResult {
                success: false,
                output: ToolOutput::default(),
                error: Some("Rate limit exceeded: too many actions in the last hour".into()),
            });
        }

        // Validate service is in the allowlist
        if !self.allowed_services.iter().any(|s| s == service) {
            return Ok(ToolResult {
                success: false,
                output: ToolOutput::default(),
                error: Some(format!(
                    "Service '{service}' is not in the allowed services list. \
                     Allowed: {}",
                    self.allowed_services.join(", ")
                )),
            });
        }

        if !self.is_operation_allowed(service, resource, sub_resource, method) {
            let op_path = match sub_resource {
                Some(sub) => format!("{service}/{resource}/{sub}/{method}"),
                None => format!("{service}/{resource}/{method}"),
            };
            return Ok(ToolResult {
                success: false,
                output: ToolOutput::default(),
                error: Some(format!(
                    "Operation '{op_path}' is not in the allowed operations list"
                )),
            });
        }

        // Validate inputs contain no shell metacharacters
        for (label, value) in [
            ("service", service),
            ("resource", resource),
            ("method", method),
        ] {
            if !value
                .chars()
                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
            {
                return Ok(ToolResult {
                    success: false,
                    output: ToolOutput::default(),
                    error: Some(format!(
                        "Invalid characters in '{label}': only lowercase alphanumeric, underscore, and hyphen are allowed"
                    )),
                });
            }
        }

        // Build the gws command — validate all optional fields before consuming budget
        let mut cmd_args = Self::positional_cmd_args(service, resource, sub_resource, method);

        if let Some(params) = args.get("params") {
            if !params.is_object() {
                return Ok(ToolResult {
                    success: false,
                    output: ToolOutput::default(),
                    error: Some("'params' must be an object".into()),
                });
            }
            cmd_args.push("--params".into());
            cmd_args.push(params.to_string());
        }

        if let Some(body) = args.get("body") {
            if !body.is_object() {
                return Ok(ToolResult {
                    success: false,
                    output: ToolOutput::default(),
                    error: Some("'body' must be an object".into()),
                });
            }
            cmd_args.push("--json".into());
            cmd_args.push(body.to_string());
        }

        if let Some(format_value) = args.get("format") {
            let format = match format_value.as_str() {
                Some(s) => s,
                None => {
                    return Ok(ToolResult {
                        success: false,
                        output: ToolOutput::default(),
                        error: Some("'format' must be a string".into()),
                    });
                }
            };
            match format {
                "json" | "table" | "yaml" | "csv" => {
                    cmd_args.push("--format".into());
                    cmd_args.push(format.to_string());
                }
                _ => {
                    return Ok(ToolResult {
                        success: false,
                        output: ToolOutput::default(),
                        error: Some(format!(
                            "Invalid format '{format}': must be json, table, yaml, or csv"
                        )),
                    });
                }
            }
        }

        let page_all = match args.get("page_all") {
            Some(v) => match v.as_bool() {
                Some(b) => b,
                None => {
                    return Ok(ToolResult {
                        success: false,
                        output: ToolOutput::default(),
                        error: Some("'page_all' must be a boolean".into()),
                    });
                }
            },
            None => false,
        };
        let page_limit = match args.get("page_limit") {
            Some(v) => match v.as_u64() {
                Some(n) => Some(n),
                None => {
                    return Ok(ToolResult {
                        success: false,
                        output: ToolOutput::default(),
                        error: Some("'page_limit' must be a non-negative integer".into()),
                    });
                }
            },
            None => None,
        };
        cmd_args.extend(Self::build_pagination_args(page_all, page_limit));

        // Charge action budget only after all validation passes
        if !self.security.record_action() {
            return Ok(ToolResult {
                success: false,
                output: ToolOutput::default(),
                error: Some("Rate limit exceeded: action budget exhausted".into()),
            });
        }

        // Resolve `gws` via PATH so Windows `.cmd` shims (e.g. npm-installed
        // `gws.cmd`) are picked up — `Command::new` on Windows does not append
        // PATHEXT itself. Falls back to bare "gws" so the not-found error path
        // below still fires when the binary is genuinely missing.
        let gws_path: std::path::PathBuf = which::which("gws").unwrap_or_else(|_| "gws".into());
        let mut cmd = tokio::process::Command::new(gws_path);
        cmd.args(&cmd_args);
        cmd.env_clear();
        // gws needs PATH to find itself and HOME/APPDATA for credential storage
        for key in &["PATH", "HOME", "APPDATA", "USERPROFILE", "LANG", "TERM"] {
            if let Ok(val) = std::env::var(key) {
                cmd.env(key, val);
            }
        }

        // Apply credential path if configured
        if let Some(ref creds) = self.credentials_path {
            cmd.env("GOOGLE_APPLICATION_CREDENTIALS", creds);
        }

        // Apply default account if configured
        if let Some(ref account) = self.default_account {
            cmd.args(["--account", account]);
        }

        if self.audit_log {
            ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"tool": "google_workspace", "service": service, "resource": resource, "sub_resource": sub_resource.unwrap_or(""), "method": method})), "gws audit: executing API call");
        }

        let result =
            tokio::time::timeout(Duration::from_secs(self.timeout_secs), cmd.output()).await;

        match result {
            Ok(Ok(output)) => {
                let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
                let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();

                if stdout.len() > MAX_OUTPUT_BYTES {
                    // Find a valid char boundary at or before MAX_OUTPUT_BYTES
                    let mut boundary = MAX_OUTPUT_BYTES;
                    while boundary > 0 && !stdout.is_char_boundary(boundary) {
                        boundary -= 1;
                    }
                    stdout.truncate(boundary);
                    stdout.push_str("\n... [output truncated at 1MB]");
                }
                if stderr.len() > MAX_OUTPUT_BYTES {
                    let mut boundary = MAX_OUTPUT_BYTES;
                    while boundary > 0 && !stderr.is_char_boundary(boundary) {
                        boundary -= 1;
                    }
                    stderr.truncate(boundary);
                    stderr.push_str("\n... [stderr truncated at 1MB]");
                }

                Ok(ToolResult {
                    success: output.status.success(),
                    output: stdout.into(),
                    error: if stderr.is_empty() {
                        None
                    } else {
                        Some(stderr)
                    },
                })
            }
            Ok(Err(e)) => Ok(ToolResult {
                success: false,
                output: ToolOutput::default(),
                error: Some(format!(
                    "Failed to execute gws: {e}. Is gws installed? Run: npm install -g @googleworkspace/cli"
                )),
            }),
            Err(_) => Ok(ToolResult {
                success: false,
                output: ToolOutput::default(),
                error: Some(format!(
                    "gws command timed out after {}s and was killed",
                    self.timeout_secs
                )),
            }),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use zeroclaw_config::autonomy::AutonomyLevel;
    use zeroclaw_config::policy::SecurityPolicy;

    fn test_security() -> Arc<SecurityPolicy> {
        Arc::new(SecurityPolicy {
            autonomy: AutonomyLevel::Full,
            workspace_dir: std::env::temp_dir(),
            ..SecurityPolicy::default()
        })
    }

    // PATH resolution must produce a usable PathBuf
    // even when `gws` is not installed, so the executor can still emit the
    // documented "Failed to execute gws" error rather than panicking.
    #[test]
    fn gws_path_resolution_falls_back_when_not_on_path() {
        let resolved: std::path::PathBuf =
            which::which("definitely-not-a-real-binary-zc6410").unwrap_or_else(|_| "gws".into());
        assert_eq!(resolved.as_os_str(), "gws");
    }

    #[test]
    fn tool_name() {
        let tool =
            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
        assert_eq!(tool.name(), "google_workspace");
    }

    #[test]
    fn tool_description_non_empty() {
        let tool =
            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
        assert!(!tool.description().is_empty());
    }

    #[test]
    fn tool_schema_has_required_fields() {
        let tool =
            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
        let schema = tool.parameters_schema();
        assert!(schema["properties"]["service"].is_object());
        assert!(schema["properties"]["resource"].is_object());
        assert!(schema["properties"]["method"].is_object());
        let required = schema["required"]
            .as_array()
            .expect("required should be an array");
        assert!(required.contains(&json!("service")));
        assert!(required.contains(&json!("resource")));
        assert!(required.contains(&json!("method")));
    }

    #[test]
    fn default_allowed_services_populated() {
        let tool =
            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
        assert!(!tool.allowed_services.is_empty());
        assert!(tool.allowed_services.contains(&"drive".to_string()));
        assert!(tool.allowed_services.contains(&"gmail".to_string()));
        assert!(tool.allowed_services.contains(&"calendar".to_string()));
    }

    #[test]
    fn custom_allowed_services_override_defaults() {
        let tool = GoogleWorkspaceTool::new(
            test_security(),
            vec!["drive".into(), "sheets".into()],
            vec![],
            None,
            None,
            60,
            30,
            false,
        );
        assert_eq!(tool.allowed_services.len(), 2);
        assert!(tool.allowed_services.contains(&"drive".to_string()));
        assert!(tool.allowed_services.contains(&"sheets".to_string()));
        assert!(!tool.allowed_services.contains(&"gmail".to_string()));
    }

    #[tokio::test]
    async fn rejects_disallowed_service() {
        let tool = GoogleWorkspaceTool::new(
            test_security(),
            vec!["drive".into()],
            vec![],
            None,
            None,
            60,
            30,
            false,
        );
        let result = tool
            .execute(json!({
                "service": "gmail",
                "resource": "users",
                "method": "list"
            }))
            .await
            .expect("disallowed service should return a result");
        assert!(!result.success);
        assert!(
            result
                .error
                .as_deref()
                .unwrap_or("")
                .contains("not in the allowed")
        );
    }

    #[tokio::test]
    async fn rejects_shell_injection_in_service() {
        let tool = GoogleWorkspaceTool::new(
            test_security(),
            vec!["drive; rm -rf /".into()],
            vec![],
            None,
            None,
            60,
            30,
            false,
        );
        let result = tool
            .execute(json!({
                "service": "drive; rm -rf /",
                "resource": "files",
                "method": "list"
            }))
            .await
            .expect("shell injection should return a result");
        assert!(!result.success);
        assert!(
            result
                .error
                .as_deref()
                .unwrap_or("")
                .contains("Invalid characters")
        );
    }

    #[tokio::test]
    async fn rejects_shell_injection_in_resource() {
        let tool =
            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
        let result = tool
            .execute(json!({
                "service": "drive",
                "resource": "files$(whoami)",
                "method": "list"
            }))
            .await
            .expect("shell injection should return a result");
        assert!(!result.success);
        assert!(
            result
                .error
                .as_deref()
                .unwrap_or("")
                .contains("Invalid characters")
        );
    }

    #[tokio::test]
    async fn rejects_invalid_format() {
        let tool =
            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
        let result = tool
            .execute(json!({
                "service": "drive",
                "resource": "files",
                "method": "list",
                "format": "xml"
            }))
            .await
            .expect("invalid format should return a result");
        assert!(!result.success);
        assert!(
            result
                .error
                .as_deref()
                .unwrap_or("")
                .contains("Invalid format")
        );
    }

    #[tokio::test]
    async fn rejects_wrong_type_params() {
        let tool =
            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
        let result = tool
            .execute(json!({
                "service": "drive",
                "resource": "files",
                "method": "list",
                "params": "not_an_object"
            }))
            .await
            .expect("wrong type params should return a result");
        assert!(!result.success);
        assert!(
            result
                .error
                .as_deref()
                .unwrap_or("")
                .contains("'params' must be an object")
        );
    }

    #[tokio::test]
    async fn rejects_wrong_type_body() {
        let tool =
            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
        let result = tool
            .execute(json!({
                "service": "drive",
                "resource": "files",
                "method": "create",
                "body": "not_an_object"
            }))
            .await
            .expect("wrong type body should return a result");
        assert!(!result.success);
        assert!(
            result
                .error
                .as_deref()
                .unwrap_or("")
                .contains("'body' must be an object")
        );
    }

    #[tokio::test]
    async fn rejects_wrong_type_page_all() {
        let tool =
            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
        let result = tool
            .execute(json!({
                "service": "drive",
                "resource": "files",
                "method": "list",
                "page_all": "yes"
            }))
            .await
            .expect("wrong type page_all should return a result");
        assert!(!result.success);
        assert!(
            result
                .error
                .as_deref()
                .unwrap_or("")
                .contains("'page_all' must be a boolean")
        );
    }

    #[tokio::test]
    async fn rejects_wrong_type_page_limit() {
        let tool =
            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
        let result = tool
            .execute(json!({
                "service": "drive",
                "resource": "files",
                "method": "list",
                "page_limit": "ten"
            }))
            .await
            .expect("wrong type page_limit should return a result");
        assert!(!result.success);
        assert!(
            result
                .error
                .as_deref()
                .unwrap_or("")
                .contains("'page_limit' must be a non-negative integer")
        );
    }

    #[tokio::test]
    async fn rejects_wrong_type_sub_resource() {
        let tool =
            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
        let result = tool
            .execute(json!({
                "service": "drive",
                "resource": "files",
                "method": "list",
                "sub_resource": 123
            }))
            .await
            .expect("wrong type sub_resource should return a result");
        assert!(!result.success);
        assert!(
            result
                .error
                .as_deref()
                .unwrap_or("")
                .contains("'sub_resource' must be a string")
        );
    }

    #[tokio::test]
    async fn missing_required_param_returns_error() {
        let tool =
            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
        let result = tool.execute(json!({"service": "drive"})).await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn rate_limited_returns_error() {
        let security = Arc::new(SecurityPolicy {
            autonomy: AutonomyLevel::Full,
            max_actions_per_hour: 0,
            workspace_dir: std::env::temp_dir(),
            ..SecurityPolicy::default()
        });
        let tool = GoogleWorkspaceTool::new(security, vec![], vec![], None, None, 60, 30, false);
        let result = tool
            .execute(json!({
                "service": "drive",
                "resource": "files",
                "method": "list"
            }))
            .await
            .expect("rate-limited should return a result");
        assert!(!result.success);
        assert!(result.error.as_deref().unwrap_or("").contains("Rate limit"));
    }

    #[test]
    fn gws_timeout_is_reasonable() {
        assert_eq!(DEFAULT_GWS_TIMEOUT_SECS, 30);
    }

    #[test]
    fn operation_allowlist_defaults_to_allow_all() {
        let tool =
            GoogleWorkspaceTool::new(test_security(), vec![], vec![], None, None, 60, 30, false);
        // Empty allowlist: everything passes regardless of sub_resource
        assert!(tool.is_operation_allowed("gmail", "users", Some("messages"), "send"));
        assert!(tool.is_operation_allowed("drive", "files", None, "list"));
    }

    #[test]
    fn operation_allowlist_matches_gmail_sub_resource_shape() {
        let tool = GoogleWorkspaceTool::new(
            test_security(),
            vec!["gmail".into()],
            vec![GoogleWorkspaceAllowedOperation {
                service: "gmail".into(),
                resource: "users".into(),
                sub_resource: Some("drafts".into()),
                methods: vec!["create".into(), "update".into()],
            }],
            None,
            None,
            60,
            30,
            false,
        );

        // Exact match: allowed
        assert!(tool.is_operation_allowed("gmail", "users", Some("drafts"), "create"));
        assert!(tool.is_operation_allowed("gmail", "users", Some("drafts"), "update"));
        // Send not in methods: denied
        assert!(!tool.is_operation_allowed("gmail", "users", Some("drafts"), "send"));
        // Different sub_resource: denied
        assert!(!tool.is_operation_allowed("gmail", "users", Some("messages"), "list"));
        // No sub_resource when entry requires one: denied
        assert!(!tool.is_operation_allowed("gmail", "users", None, "create"));
    }

    #[test]
    fn operation_allowlist_matches_drive_3_segment_shape() {
        let tool = GoogleWorkspaceTool::new(
            test_security(),
            vec!["drive".into()],
            vec![GoogleWorkspaceAllowedOperation {
                service: "drive".into(),
                resource: "files".into(),
                sub_resource: None,
                methods: vec!["list".into(), "get".into()],
            }],
            None,
            None,
            60,
            30,
            false,
        );

        assert!(tool.is_operation_allowed("drive", "files", None, "list"));
        assert!(tool.is_operation_allowed("drive", "files", None, "get"));
        // Delete not in methods: denied
        assert!(!tool.is_operation_allowed("drive", "files", None, "delete"));
        // Entry has no sub_resource; call with sub_resource must not match
        assert!(!tool.is_operation_allowed("drive", "files", Some("permissions"), "list"));
    }

    #[tokio::test]
    async fn rejects_disallowed_operation() {
        let tool = GoogleWorkspaceTool::new(
            test_security(),
            vec!["gmail".into()],
            vec![GoogleWorkspaceAllowedOperation {
                service: "gmail".into(),
                resource: "users".into(),
                sub_resource: Some("drafts".into()),
                methods: vec!["create".into()],
            }],
            None,
            None,
            60,
            30,
            false,
        );

        // send is not in the allowed methods list
        let result = tool
            .execute(json!({
                "service": "gmail",
                "resource": "users",
                "sub_resource": "drafts",
                "method": "send"
            }))
            .await
            .expect("disallowed operation should return a result");

        assert!(!result.success);
        assert!(
            result
                .error
                .as_deref()
                .unwrap_or("")
                .contains("allowed operations list")
        );
    }

    #[tokio::test]
    async fn rejects_operation_with_unlisted_sub_resource() {
        let tool = GoogleWorkspaceTool::new(
            test_security(),
            vec!["gmail".into()],
            vec![GoogleWorkspaceAllowedOperation {
                service: "gmail".into(),
                resource: "users".into(),
                sub_resource: Some("drafts".into()),
                methods: vec!["create".into()],
            }],
            None,
            None,
            60,
            30,
            false,
        );

        // messages is not in the allowlist (only drafts is)
        let result = tool
            .execute(json!({
                "service": "gmail",
                "resource": "users",
                "sub_resource": "messages",
                "method": "send"
            }))
            .await
            .expect("unlisted sub_resource should return a result");

        assert!(!result.success);
        assert!(
            result
                .error
                .as_deref()
                .unwrap_or("")
                .contains("allowed operations list")
        );
    }

    // ── cmd_args ordering ────────────────────────────────────

    #[test]
    fn cmd_args_3_segment_shape_drive() {
        // Drive uses gws <service> <resource> <method> — no sub_resource.
        let args = GoogleWorkspaceTool::positional_cmd_args("drive", "files", None, "list");
        assert_eq!(args, vec!["drive", "files", "list"]);
    }

    #[test]
    fn cmd_args_4_segment_shape_gmail() {
        // Gmail uses gws <service> <resource> <sub_resource> <method>.
        let args =
            GoogleWorkspaceTool::positional_cmd_args("gmail", "users", Some("messages"), "list");
        assert_eq!(args, vec!["gmail", "users", "messages", "list"]);
    }

    #[test]
    fn cmd_args_sub_resource_precedes_method() {
        // sub_resource must come before method in the positional args.
        let args =
            GoogleWorkspaceTool::positional_cmd_args("gmail", "users", Some("drafts"), "create");
        let sub_idx = args.iter().position(|a| a == "drafts").unwrap();
        let method_idx = args.iter().position(|a| a == "create").unwrap();
        assert!(sub_idx < method_idx, "sub_resource must precede method");
    }

    // ── denial error message ─────────────────────────────────

    #[tokio::test]
    async fn denial_error_includes_sub_resource_when_present() {
        let tool = GoogleWorkspaceTool::new(
            test_security(),
            vec!["gmail".into()],
            vec![GoogleWorkspaceAllowedOperation {
                service: "gmail".into(),
                resource: "users".into(),
                sub_resource: Some("drafts".into()),
                methods: vec!["create".into()],
            }],
            None,
            None,
            60,
            30,
            false,
        );

        let result = tool
            .execute(json!({
                "service": "gmail",
                "resource": "users",
                "sub_resource": "messages",
                "method": "send"
            }))
            .await
            .expect("denied operation should return a result");

        let error = result.error.as_deref().unwrap_or("");
        // Error must include sub_resource so the operator can distinguish
        // gmail/users/messages/send from gmail/users/drafts/send.
        assert!(
            error.contains("gmail/users/messages/send"),
            "expected full 4-segment path in error, got: {error}"
        );
    }

    // ── whitespace normalization ─────────────────────────────

    #[test]
    fn allowed_operations_config_values_trimmed_at_construction() {
        let tool = GoogleWorkspaceTool::new(
            test_security(),
            vec!["gmail".into()],
            vec![GoogleWorkspaceAllowedOperation {
                service: " gmail ".into(), // leading/trailing whitespace
                resource: " users ".into(),
                sub_resource: Some(" drafts ".into()),
                methods: vec![" create ".into()],
            }],
            None,
            None,
            60,
            30,
            false,
        );

        // After construction, stored values are trimmed and plain equality works.
        assert!(tool.is_operation_allowed("gmail", "users", Some("drafts"), "create"));
        assert!(!tool.is_operation_allowed("gmail", "users", Some(" drafts "), "create"));
    }

    // ── page_limit / page_all flag building ─────────────────

    #[test]
    fn pagination_page_limit_alone_appends_limit_without_page_all() {
        // page_limit without page_all caps page count without requesting all pages.
        let flags = GoogleWorkspaceTool::build_pagination_args(false, Some(5));
        assert!(flags.contains(&"--page-limit".to_string()));
        assert!(!flags.contains(&"--page-all".to_string()));
        let limit_idx = flags.iter().position(|f| f == "--page-limit").unwrap();
        assert_eq!(flags[limit_idx + 1], "5");
    }

    #[test]
    fn pagination_page_all_without_limit_uses_default() {
        let flags = GoogleWorkspaceTool::build_pagination_args(true, None);
        assert!(flags.contains(&"--page-all".to_string()));
        assert!(flags.contains(&"--page-limit".to_string()));
        let limit_idx = flags.iter().position(|f| f == "--page-limit").unwrap();
        assert_eq!(flags[limit_idx + 1], "10"); // default cap
    }

    #[test]
    fn pagination_page_all_with_limit_appends_both() {
        let flags = GoogleWorkspaceTool::build_pagination_args(true, Some(20));
        assert!(flags.contains(&"--page-all".to_string()));
        let limit_idx = flags.iter().position(|f| f == "--page-limit").unwrap();
        assert_eq!(flags[limit_idx + 1], "20");
    }

    #[test]
    fn pagination_neither_appends_nothing() {
        let flags = GoogleWorkspaceTool::build_pagination_args(false, None);
        assert!(flags.is_empty());
    }
}
