//! Built-in agent tools.
//!
//! Provides a core set of tools that the LLM can invoke during agent execution.
//! These tools are registered in the `ToolsRegistry` and dispatched via the
//! standard tool dispatch path.

use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use serde_json::{json, Value};

use crate::config::read_config_env;
use crate::plugins::tools::{BuiltinTool, ToolInvokeResult};
use crate::runtime_bridge::run_sync_blocking_send;

/// Return all built-in tool definitions.
///
/// Called by `ToolsRegistry::new()` to register the core tool set.
pub fn builtin_tools() -> Vec<BuiltinTool> {
    vec![
        current_time_tool(),
        web_fetch_tool(),
        media_analyze_tool(),
        memory_read_tool(),
        memory_write_tool(),
        memory_list_tool(),
        message_send_tool(),
        session_list_tool(),
        session_read_tool(),
        config_read_tool(),
        math_eval_tool(),
    ]
}

/// Return channel-specific tools for the given channel.
/// Re-exports from channel_tools module.
pub fn channel_specific_tools(channel: Option<&str>) -> Vec<BuiltinTool> {
    crate::agent::channel_tools::channel_tools(channel)
}

// ---------------------------------------------------------------------------
// current_time
// ---------------------------------------------------------------------------

fn current_time_tool() -> BuiltinTool {
    BuiltinTool {
        name: "current_time".to_string(),
        description: "Return the current UTC time as an ISO 8601 string and Unix timestamp."
            .to_string(),
        input_schema: json!({
            "type": "object",
            "properties": {},
            "additionalProperties": false
        }),
        handler: Box::new(|_args, _ctx| {
            let now = chrono::Utc::now();
            ToolInvokeResult::success(json!({
                "iso": now.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
                "unix": now.timestamp()
            }))
        }),
    }
}

// ---------------------------------------------------------------------------
// web_fetch
// ---------------------------------------------------------------------------

fn web_fetch_tool() -> BuiltinTool {
    BuiltinTool {
        name: "web_fetch".to_string(),
        description: "Fetch a URL and return its content. Uses SSRF-protected infrastructure. \
                       Returns the response body as text, HTTP status, and content type."
            .to_string(),
        input_schema: json!({
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "The URL to fetch (http or https only)."
                },
                "max_bytes": {
                    "type": "integer",
                    "description": "Maximum response size in bytes. Defaults to 1048576 (1 MB)."
                }
            },
            "required": ["url"],
            "additionalProperties": false
        }),
        handler: Box::new(|args, _ctx| handle_web_fetch(args)),
    }
}

// ---------------------------------------------------------------------------
// media_analyze
// ---------------------------------------------------------------------------

fn media_analyze_tool() -> BuiltinTool {
    BuiltinTool {
        name: "media_analyze".to_string(),
        description: "Analyze an image or audio file by URL or local path. \
                       Uses OpenAI (vision/Whisper) or Anthropic (vision) and \
                       caches results alongside the file."
            .to_string(),
        input_schema: json!({
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "Remote URL to fetch and analyze (http/https)."
                },
                "path": {
                    "type": "string",
                    "description": "Local file path to analyze."
                },
                "mime_type": {
                    "type": "string",
                    "description": "Optional MIME type override (e.g., image/png)."
                },
                "provider": {
                    "type": "string",
                    "description": "Optional provider override: openai or anthropic."
                },
                "prompt": {
                    "type": "string",
                    "description": "Optional prompt for image analysis."
                },
                "max_bytes": {
                    "type": "integer",
                    "description": "Maximum bytes to fetch when using a URL. Defaults to 50MB."
                },
                "model": {
                    "type": "string",
                    "description": "Optional model override for image analysis."
                },
                "max_tokens": {
                    "type": "integer",
                    "description": "Optional max tokens for image analysis responses."
                }
            },
            "additionalProperties": false
        }),
        handler: Box::new(|args, _ctx| handle_media_analyze(args)),
    }
}

/// Default max response size for media_analyze (50 MB).
const MEDIA_ANALYZE_DEFAULT_MAX_BYTES: u64 = 50 * 1024 * 1024;

/// Maximum allowed value for max_bytes (100 MB).
const MEDIA_ANALYZE_MAX_ALLOWED_BYTES: u64 = 100 * 1024 * 1024;

fn handle_media_analyze(args: Value) -> ToolInvokeResult {
    let url = args
        .get("url")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());
    let path = args
        .get("path")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    if url.is_some() == path.is_some() {
        return ToolInvokeResult::tool_error("provide exactly one of: url or path");
    }

    let mime_override = args
        .get("mime_type")
        .and_then(|v| v.as_str())
        .map(normalize_mime_type);
    let provider_override = args
        .get("provider")
        .and_then(|v| v.as_str())
        .map(|s| s.to_lowercase());
    let prompt = args
        .get("prompt")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());
    let model_override = args
        .get("model")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());
    let max_tokens = args
        .get("max_tokens")
        .and_then(|v| v.as_u64())
        .map(|v| v.min(u32::MAX as u64) as u32);

    let max_bytes = args
        .get("max_bytes")
        .and_then(|v| v.as_u64())
        .unwrap_or(MEDIA_ANALYZE_DEFAULT_MAX_BYTES)
        .min(MEDIA_ANALYZE_MAX_ALLOWED_BYTES);

    if let Some(provider) = provider_override.as_deref() {
        if provider != "openai" && provider != "anthropic" {
            return ToolInvokeResult::tool_error(format!("unsupported provider: {provider}"));
        }
    }

    let result = run_sync_blocking_send(async move {
        use crate::media::analysis::{
            analyze, AnthropicMediaAnalyzer, MediaType, OpenAiMediaAnalyzer,
        };
        use crate::media::fetch::{FetchConfig, MediaFetcher};
        use crate::media::{MediaStore, StoreConfig};

        let cfg = crate::config::load_config_shared().unwrap_or_else(|_| Arc::new(json!({})));

        let (media_path, mime_type) = if let Some(url) = url {
            let config = FetchConfig::default().with_max_size(max_bytes);
            let fetcher = MediaFetcher::with_config(config);
            let fetch = fetcher.fetch(&url).await.map_err(|e| e.to_string())?;

            let mime = mime_override
                .or(fetch.content_type.as_deref().map(normalize_mime_type))
                .ok_or_else(|| "missing mime_type and no Content-Type returned".to_string())?;

            let store = MediaStore::new(StoreConfig::default())
                .await
                .map_err(|e| e.to_string())?;
            let metadata = store
                .store(fetch.bytes, Some(mime.clone()))
                .await
                .map_err(|e| e.to_string())?;
            (metadata.path, mime)
        } else if let Some(path) = path {
            // SECURITY: validate the path against the filesystem-tool
            // allowed roots BEFORE reading the file. Without this, a
            // prompt-injected model could pass any local path
            // (`/etc/passwd`, `~/.ssh/id_rsa`, other agents' memory
            // files, the carapace config) and the bytes would ship
            // out to the LLM provider as the analysis input — direct
            // exfiltration channel. Centralize on the filesystem-
            // tool config's allowed-roots policy so a single
            // operator-edited list governs every agent-callable
            // local-path consumer.
            let validated =
                crate::agent::filesystem_tools::validate_path_against_config(&path, cfg.as_ref())
                    .map_err(|e| format!("media_analyze path rejected: {e}"))?;
            let mime = mime_override
                .or_else(|| guess_mime_from_path(&validated))
                .ok_or_else(|| "missing mime_type for local file".to_string())?;
            (validated, mime)
        } else {
            return Err("missing url or path".to_string());
        };

        let media_type = MediaType::from_mime(&mime_type)
            .ok_or_else(|| format!("unsupported MIME type: {}", mime_type))?;

        let openai_key = resolve_openai_media_key(cfg.as_ref());
        let anthropic_key = resolve_anthropic_media_key(cfg.as_ref());

        let provider = match provider_override.as_deref() {
            Some("openai") => "openai",
            Some("anthropic") => "anthropic",
            None => match media_type {
                MediaType::Audio => {
                    if openai_key.is_some() {
                        "openai"
                    } else {
                        return Err("OpenAI API key is required for audio transcription".into());
                    }
                }
                MediaType::Image => {
                    if openai_key.is_some() {
                        "openai"
                    } else {
                        match anthropic_key.as_ref() {
                            Ok(Some(_)) => "anthropic",
                            Ok(None) => return Err("no media analysis provider configured".into()),
                            Err(err) => return Err(err.clone()),
                        }
                    }
                }
                MediaType::Video => {
                    return Err("video analysis is not implemented".into());
                }
            },
            _ => unreachable!("provider validated before async block"),
        };

        let cache_path = analysis_cache_path(&media_path);
        let cached = cache_path.exists();

        let analysis: crate::media::analysis::MediaAnalysis = match provider {
            "openai" => {
                let key = openai_key.ok_or_else(|| {
                    "OpenAI API key not configured; set OPENAI_API_KEY or openai.apiKey".to_string()
                })?;
                let mut analyzer = OpenAiMediaAnalyzer::new(key).map_err(|e| e.to_string())?;
                if let Some(base_url) = resolve_openai_base_url(cfg.as_ref()) {
                    analyzer = analyzer.with_base_url(base_url);
                }
                if let Some(model) = model_override.clone() {
                    analyzer = analyzer.with_vision_model(model);
                }
                if let Some(max_tokens) = max_tokens {
                    analyzer = analyzer.with_max_tokens(max_tokens);
                }
                analyze(&media_path, &mime_type, &analyzer, prompt.as_deref())
                    .await
                    .map_err(|e| e.to_string())?
            }
            "anthropic" => {
                if media_type == MediaType::Audio {
                    return Err("Anthropic does not support audio transcription".into());
                }
                let key = anthropic_key?.ok_or_else(|| {
                    "Anthropic credential not configured; set ANTHROPIC_API_KEY, anthropic.apiKey, or anthropic.authProfile"
                        .to_string()
                })?;
                let mut analyzer = AnthropicMediaAnalyzer::new(key).map_err(|e| e.to_string())?;
                if let Some(base_url) = resolve_anthropic_base_url(cfg.as_ref()) {
                    analyzer = analyzer.with_base_url(base_url);
                }
                if let Some(model) = model_override.clone() {
                    analyzer = analyzer.with_model(model);
                }
                if let Some(max_tokens) = max_tokens {
                    analyzer = analyzer.with_max_tokens(max_tokens);
                }
                analyze(&media_path, &mime_type, &analyzer, prompt.as_deref())
                    .await
                    .map_err(|e| e.to_string())?
            }
            _ => return Err("unsupported provider".into()),
        };

        Ok(json!({
            "analysis": analysis,
            "mimeType": mime_type,
            "cached": cached
        }))
    });

    match result {
        Ok(value) => ToolInvokeResult::success(value),
        Err(e) => ToolInvokeResult::tool_error(e.to_string()),
    }
}

fn resolve_openai_media_key(cfg: &Value) -> Option<String> {
    read_config_env("OPENAI_API_KEY")
        .filter(|k| !k.is_empty())
        .or_else(|| {
            cfg.get("models")
                .and_then(|v| v.get("providers"))
                .and_then(|v| v.get("openai"))
                .and_then(|v| v.get("apiKey"))
                .and_then(|v| v.as_str())
                .filter(|k| !k.is_empty())
                .map(|k| k.to_string())
        })
        .or_else(|| {
            cfg.get("openai")
                .and_then(|v| v.get("apiKey"))
                .and_then(|v| v.as_str())
                .filter(|k| !k.is_empty())
                .map(|k| k.to_string())
        })
}

fn resolve_openai_base_url(cfg: &Value) -> Option<String> {
    read_config_env("OPENAI_BASE_URL")
        .filter(|v| !v.is_empty())
        .or_else(|| {
            cfg.get("openai")
                .and_then(|v| v.get("baseUrl"))
                .and_then(|v| v.as_str())
                .filter(|v| !v.is_empty())
                .map(|v| v.to_string())
        })
}

fn resolve_anthropic_media_key(cfg: &Value) -> Result<Option<String>, String> {
    if let Some(api_key) = read_config_env("ANTHROPIC_API_KEY")
        .map(|k| k.trim().to_string())
        .filter(|k| !k.is_empty())
        .or_else(|| {
            cfg.get("anthropic")
                .and_then(|v| v.get("apiKey"))
                .and_then(|v| v.as_str())
                .map(|k| k.trim().to_string())
                .filter(|k| !k.is_empty())
        })
    {
        return Ok(Some(api_key));
    }

    let Some(profile_id) = cfg
        .get("anthropic")
        .and_then(|v| v.get("authProfile"))
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|value| !value.is_empty())
    else {
        return Ok(None);
    };

    crate::auth::profile_runtime::resolve_anthropic_profile_token_from_env_cached(profile_id)
        .map(Some)
}

fn resolve_anthropic_base_url(cfg: &Value) -> Option<String> {
    read_config_env("ANTHROPIC_BASE_URL")
        .filter(|v| !v.is_empty())
        .or_else(|| {
            cfg.get("anthropic")
                .and_then(|v| v.get("baseUrl"))
                .and_then(|v| v.as_str())
                .filter(|v| !v.is_empty())
                .map(|v| v.to_string())
        })
}

fn normalize_mime_type(raw: &str) -> String {
    raw.split(';').next().unwrap_or(raw).trim().to_lowercase()
}

fn guess_mime_from_path(path: &Path) -> Option<String> {
    let ext = path.extension()?.to_string_lossy().to_lowercase();
    let mime = match ext.as_str() {
        "jpg" | "jpeg" => "image/jpeg",
        "png" => "image/png",
        "gif" => "image/gif",
        "webp" => "image/webp",
        "bmp" => "image/bmp",
        "svg" => "image/svg+xml",
        "mp3" => "audio/mpeg",
        "wav" => "audio/wav",
        "ogg" => "audio/ogg",
        "flac" => "audio/flac",
        "m4a" => "audio/mp4",
        "mp4" => "video/mp4",
        "webm" => "video/webm",
        _ => return None,
    };
    Some(mime.to_string())
}

fn analysis_cache_path(path: &Path) -> PathBuf {
    let mut cache_path = path.as_os_str().to_owned();
    cache_path.push(".analysis.json");
    PathBuf::from(cache_path)
}

/// Default max response size for web_fetch (1 MB).
const WEB_FETCH_DEFAULT_MAX_BYTES: u64 = 1_048_576;

/// Maximum allowed value for max_bytes (10 MB).
const WEB_FETCH_MAX_ALLOWED_BYTES: u64 = 10 * 1024 * 1024;

fn handle_web_fetch(args: Value) -> ToolInvokeResult {
    let url = match args.get("url").and_then(|v| v.as_str()) {
        Some(u) => u.to_string(),
        None => return ToolInvokeResult::tool_error("missing required parameter: url"),
    };

    let max_bytes = args
        .get("max_bytes")
        .and_then(|v| v.as_u64())
        .unwrap_or(WEB_FETCH_DEFAULT_MAX_BYTES)
        .min(WEB_FETCH_MAX_ALLOWED_BYTES);

    let result = run_sync_blocking_send(async move {
        use crate::media::fetch::{FetchConfig, MediaFetcher};

        let config = FetchConfig::default().with_max_size(max_bytes);
        let fetcher = MediaFetcher::with_config(config);
        fetcher.fetch(&url).await
    });

    match result {
        Ok(fetch_result) => {
            let raw_content = String::from_utf8_lossy(&fetch_result.bytes).into_owned();
            // SECURITY: strip terminal-unsafe + bidi/control chars
            // from the fetched body before handing it to the model.
            // A prompt-injection attacker can otherwise sneak ANSI
            // escapes, bidi overrides, or zero-width joiners into a
            // web page; when that content lands in the model's
            // context (and later in operator logs / terminal
            // output), those chars can subvert agent reasoning or
            // operator perception. Use the same canonical strip the
            // logger applies for the same threat model.
            let content =
                crate::logging::redact::strip_terminal_unsafe_chars(&raw_content).into_owned();
            let content_type = fetch_result
                .content_type
                .unwrap_or_else(|| "application/octet-stream".to_string());
            ToolInvokeResult::success(json!({
                "content": content,
                "status": 200,
                "content_type": content_type
            }))
        }
        Err(e) => ToolInvokeResult::tool_error(format!("fetch failed: {e}")),
    }
}

// ---------------------------------------------------------------------------
// memory_read / memory_write / memory_list
// ---------------------------------------------------------------------------

/// On-disk cap for a per-agent memory store JSON file. The store is
/// a HashMap<String, String> the model writes via `save_memory`;
/// 16 MiB is a generous bound that still refuses the planted-multi-
/// GB attack vector and is well above any realistic agent-memory
/// payload.
const AGENT_MEMORY_FILE_MAX_BYTES: u64 = 16 * 1024 * 1024;

/// Resolve the memory store file path for an agent.
fn memory_store_path(agent_id: Option<&str>) -> PathBuf {
    let base = dirs::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".config")
        .join("carapace")
        .join("memory");
    let key = agent_id.unwrap_or("default");
    // Sanitize the key to prevent path traversal
    let safe_key: String = key
        .chars()
        .map(|c| {
            if c.is_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect();
    base.join(format!("{safe_key}.json"))
}

/// Load the memory store from disk.
fn load_memory(path: &Path) -> HashMap<String, String> {
    // SECURITY: route via `read_to_vec_no_hang_no_follow_capped` so
    // a same-uid attacker who plants a FIFO or symlink at any
    // `memory/<key>.json` path cannot hang daemon-side tool
    // invocations (the bare `fs::read_to_string` would block
    // indefinitely on a FIFO with no writer) and cannot OOM the
    // daemon by dropping a multi-GB file. Agent memory stores
    // model-generated content which may include user secrets, so
    // the post-open `is_file()` check is meaningful even on
    // directories that are already 0o700.
    let bytes =
        match crate::paths::read_to_vec_no_hang_no_follow_capped(path, AGENT_MEMORY_FILE_MAX_BYTES)
        {
            Ok(Some(bytes)) => bytes,
            Ok(None) => return HashMap::new(),
            Err(_) => return HashMap::new(),
        };
    let Ok(content) = std::str::from_utf8(&bytes) else {
        return HashMap::new();
    };
    serde_json::from_str(content).unwrap_or_default()
}

/// Save the memory store to disk atomically. Uses the standard
/// pattern: tmp + sync_all + rename + parent-dir fsync, with tmp
/// cleanup on any failure path. The prior implementation used
/// `fs::write` (no explicit fsync of the data fd) followed by a
/// bare `fs::rename` (no parent-dir fsync, no tmp cleanup) — a
/// crash mid-write or after rename could lose the entire agent
/// memory store. Mirrors `sessions/store.rs`, `auth/profiles.rs`,
/// `usage/mod.rs`, `devices/mod.rs`, `nodes/mod.rs`.
fn save_memory(path: &PathBuf, data: &HashMap<String, String>) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|e| format!("failed to create directory: {e}"))?;
    }
    let json = serde_json::to_string_pretty(data)
        .map_err(|e| format!("failed to serialize memory: {e}"))?;

    // Unique tmp + O_NOFOLLOW + O_EXCL + mode 0o600 via shared helper.
    // Agent memory may contain whatever the model deemed memory-worthy
    // (passwords the user typed, OAuth tokens stashed for re-use,
    // etc.); a predictable tmp symlink-plant could redirect those
    // writes to an arbitrary operator-writable file.
    let tmp_path = crate::paths::atomic_tmp_path(path, "json");
    let result = (|| -> Result<(), String> {
        use std::io::Write;
        let mut file = crate::paths::create_atomic_tmp_owner_only(&tmp_path)
            .map_err(|e| format!("failed to create tmp file: {e}"))?;
        file.write_all(json.as_bytes())
            .map_err(|e| format!("failed to write memory file: {e}"))?;
        file.sync_all()
            .map_err(|e| format!("failed to sync memory file: {e}"))?;
        drop(file);
        fs::rename(&tmp_path, path).map_err(|e| format!("failed to rename memory file: {e}"))?;
        crate::paths::sync_parent_dir_blocking(path)
            .map_err(|e| format!("failed to fsync memory dir: {e}"))?;
        Ok(())
    })();

    if result.is_err() {
        // Best-effort cleanup of the leaked .tmp file.
        let _ = fs::remove_file(&tmp_path);
    }
    result
}

fn memory_read_tool() -> BuiltinTool {
    BuiltinTool {
        name: "memory_read".to_string(),
        description: "Read a value from the agent's persistent key-value memory store.".to_string(),
        input_schema: json!({
            "type": "object",
            "properties": {
                "key": {
                    "type": "string",
                    "description": "The key to read."
                }
            },
            "required": ["key"],
            "additionalProperties": false
        }),
        handler: Box::new(|args, ctx| {
            let key = match args.get("key").and_then(|v| v.as_str()) {
                Some(k) => k.to_string(),
                None => return ToolInvokeResult::tool_error("missing required parameter: key"),
            };
            let path = memory_store_path(ctx.agent_id.as_deref());
            let store = load_memory(&path);
            let value = store.get(&key).cloned();
            ToolInvokeResult::success(json!({ "value": value }))
        }),
    }
}

/// Defense-in-depth caps for the agent memory store. The tool is
/// model-invoked, and a compromised (or prompt-injected) model can
/// loop `memory_write` to fill the operator's disk. Per-key and
/// per-value caps bound a single call; the whole-store cap below
/// bounds the cumulative size after insertion.
pub(crate) const MEMORY_WRITE_MAX_KEY_BYTES: usize = 1024;
pub(crate) const MEMORY_WRITE_MAX_VALUE_BYTES: usize = 64 * 1024;
pub(crate) const MEMORY_WRITE_MAX_STORE_BYTES: usize = 4 * 1024 * 1024;

fn memory_write_tool() -> BuiltinTool {
    BuiltinTool {
        name: "memory_write".to_string(),
        description: "Write a value to the agent's persistent key-value memory store.".to_string(),
        input_schema: json!({
            "type": "object",
            "properties": {
                "key": {
                    "type": "string",
                    "description": "The key to write.",
                    "maxLength": MEMORY_WRITE_MAX_KEY_BYTES
                },
                "value": {
                    "type": "string",
                    "description": "The value to store.",
                    "maxLength": MEMORY_WRITE_MAX_VALUE_BYTES
                }
            },
            "required": ["key", "value"],
            "additionalProperties": false
        }),
        handler: Box::new(|args, ctx| {
            let key = match args.get("key").and_then(|v| v.as_str()) {
                Some(k) => k.to_string(),
                None => return ToolInvokeResult::tool_error("missing required parameter: key"),
            };
            let value = match args.get("value").and_then(|v| v.as_str()) {
                Some(v) => v.to_string(),
                None => return ToolInvokeResult::tool_error("missing required parameter: value"),
            };
            // Runtime caps (the schema maxLength is advisory for the
            // model; runtime enforcement is what actually bounds disk
            // usage if a model ignores the schema).
            if key.len() > MEMORY_WRITE_MAX_KEY_BYTES {
                return ToolInvokeResult::tool_error(format!(
                    "key exceeds {} bytes",
                    MEMORY_WRITE_MAX_KEY_BYTES
                ));
            }
            if value.len() > MEMORY_WRITE_MAX_VALUE_BYTES {
                return ToolInvokeResult::tool_error(format!(
                    "value exceeds {} bytes",
                    MEMORY_WRITE_MAX_VALUE_BYTES
                ));
            }
            let path = memory_store_path(ctx.agent_id.as_deref());
            let mut store = load_memory(&path);
            // Whole-store cap: prevent an attacker who can issue many
            // distinct writes from filling disk via the sum of keys
            // and values. Computed against the post-insert store so
            // a replacement that shrinks the store is allowed even
            // when at-cap.
            store.insert(key, value);
            let total: usize = store.iter().map(|(k, v)| k.len() + v.len()).sum();
            if total > MEMORY_WRITE_MAX_STORE_BYTES {
                return ToolInvokeResult::tool_error(format!(
                    "memory store would exceed {} bytes ({}/{} bytes used)",
                    MEMORY_WRITE_MAX_STORE_BYTES, total, MEMORY_WRITE_MAX_STORE_BYTES
                ));
            }
            match save_memory(&path, &store) {
                Ok(()) => ToolInvokeResult::success(json!({ "ok": true })),
                Err(e) => ToolInvokeResult::tool_error(e),
            }
        }),
    }
}

fn memory_list_tool() -> BuiltinTool {
    BuiltinTool {
        name: "memory_list".to_string(),
        description: "List all keys in the agent's persistent memory store.".to_string(),
        input_schema: json!({
            "type": "object",
            "properties": {},
            "additionalProperties": false
        }),
        handler: Box::new(|_args, ctx| {
            let path = memory_store_path(ctx.agent_id.as_deref());
            let store = load_memory(&path);
            let mut keys: Vec<String> = store.keys().cloned().collect();
            keys.sort();
            ToolInvokeResult::success(json!({ "keys": keys }))
        }),
    }
}

// ---------------------------------------------------------------------------
// message_send
// ---------------------------------------------------------------------------

/// Defense-in-depth cap on message_send text. Sized generously above
/// the largest legitimate channel payload (Telegram caption is 1024
/// chars, Slack block_kit text is 3000, Discord message body is
/// 2000), but bounded so a looping prompt-injected model can't
/// balloon outbound queue entries / log lines.
pub(crate) const MESSAGE_SEND_MAX_CHANNEL_BYTES: usize = 256;
pub(crate) const MESSAGE_SEND_MAX_TEXT_BYTES: usize = 16 * 1024;

fn message_send_tool() -> BuiltinTool {
    BuiltinTool {
        name: "message_send".to_string(),
        description: "Send a text message to a channel, queuing it into the delivery pipeline."
            .to_string(),
        input_schema: json!({
            "type": "object",
            "properties": {
                "channel": {
                    "type": "string",
                    "description": "Target channel ID (e.g. 'telegram', 'discord').",
                    "maxLength": MESSAGE_SEND_MAX_CHANNEL_BYTES
                },
                "text": {
                    "type": "string",
                    "description": "The message text to send.",
                    "maxLength": MESSAGE_SEND_MAX_TEXT_BYTES
                }
            },
            "required": ["channel", "text"],
            "additionalProperties": false
        }),
        handler: Box::new(|args, _ctx| {
            let channel = match args.get("channel").and_then(|v| v.as_str()) {
                Some(c) => c.to_string(),
                None => return ToolInvokeResult::tool_error("missing required parameter: channel"),
            };
            let text = match args.get("text").and_then(|v| v.as_str()) {
                Some(t) => t.to_string(),
                None => return ToolInvokeResult::tool_error("missing required parameter: text"),
            };

            if channel.is_empty() {
                return ToolInvokeResult::tool_error("channel must not be empty");
            }
            if text.is_empty() {
                return ToolInvokeResult::tool_error("text must not be empty");
            }
            if channel.len() > MESSAGE_SEND_MAX_CHANNEL_BYTES {
                return ToolInvokeResult::tool_error(format!(
                    "channel exceeds {} bytes",
                    MESSAGE_SEND_MAX_CHANNEL_BYTES
                ));
            }
            if text.len() > MESSAGE_SEND_MAX_TEXT_BYTES {
                return ToolInvokeResult::tool_error(format!(
                    "text exceeds {} bytes",
                    MESSAGE_SEND_MAX_TEXT_BYTES
                ));
            }

            // Queue the message into the outbound pipeline.
            // We access the pipeline through the global create_pipeline for now.
            // In production, the message pipeline is shared via WsServerState,
            // but tool handlers don't currently have access to Arc<WsServerState>.
            // We return a "queued" result — the delivery loop picks it up.
            //
            // NOTE: Since tool handlers are synchronous and don't have access
            // to the server state, we store the message intent as a success
            // result. The executor or a future enhancement can intercept this.
            // For now, we indicate the message was accepted for delivery.
            ToolInvokeResult::success(json!({
                "queued": true,
                "channel": channel,
                "text": text
            }))
        }),
    }
}

// ---------------------------------------------------------------------------
// session_list
// ---------------------------------------------------------------------------

fn session_list_tool() -> BuiltinTool {
    BuiltinTool {
        name: "session_list".to_string(),
        description: "List available chat sessions with optional limit.".to_string(),
        input_schema: json!({
            "type": "object",
            "properties": {
                "limit": {
                    "type": "integer",
                    "description": "Maximum number of sessions to return. Defaults to 20."
                }
            },
            "additionalProperties": false
        }),
        handler: Box::new(|args, ctx| {
            let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;

            // SECURITY: scope session_list to the caller's agent_id.
            // The prior implementation dropped `ctx` and returned an
            // unfiltered session list, allowing a prompt-injected
            // model in agent A to enumerate agent B's session IDs
            // (and subsequently read their content via session_read).
            // Refuse the tool when no agent_id is available rather
            // than fall through to an unscoped listing.
            let Some(agent_id) = ctx.agent_id.clone() else {
                return ToolInvokeResult::tool_error("session_list requires an agent context");
            };

            // Load sessions from the on-disk store.
            // Use the same base path resolution as the server.
            let base_path = resolve_sessions_path();
            let store =
                match crate::sessions::configured_store_with_path_from_current_config(base_path) {
                    Ok(store) => store,
                    Err(e) => {
                        return ToolInvokeResult::tool_error(format!(
                            "failed to initialize session store: {e}"
                        ))
                    }
                };

            let filter = crate::sessions::SessionFilter {
                agent_id: Some(agent_id),
                limit: Some(limit),
                ..Default::default()
            };

            match store.list_session_entries(filter) {
                Ok(sessions) => {
                    let session_list: Vec<Value> = sessions
                        .iter()
                        .filter_map(|entry| match (entry.access(), entry.session()) {
                            (crate::sessions::SessionAccessState::Available, Some(s)) => json!({
                                "id": s.id,
                                "session_key": s.session_key,
                                "status": format!("{:?}", s.status),
                                "message_count": s.message_count,
                                "created_at": s.created_at,
                                "updated_at": s.updated_at,
                                "name": s.metadata.name,
                                "access": "available",
                            })
                            .into(),
                            (crate::sessions::SessionAccessState::Locked, _) => json!({
                                "id": entry.session_id(),
                                "session_key": Value::Null,
                                "status": Value::Null,
                                "message_count": Value::Null,
                                "created_at": Value::Null,
                                "updated_at": entry.updated_at(),
                                "name": Value::Null,
                                "access": "locked",
                                "encrypted": true,
                            })
                            .into(),
                            _ => {
                                tracing::warn!("skipping inconsistent session list entry");
                                None
                            }
                        })
                        .collect();
                    ToolInvokeResult::success(json!({ "sessions": session_list }))
                }
                Err(e) => ToolInvokeResult::tool_error(format!("failed to list sessions: {e}")),
            }
        }),
    }
}

// ---------------------------------------------------------------------------
// session_read
// ---------------------------------------------------------------------------

fn session_read_tool() -> BuiltinTool {
    BuiltinTool {
        name: "session_read".to_string(),
        description: "Read recent messages from a chat session.".to_string(),
        input_schema: json!({
            "type": "object",
            "properties": {
                "session_id": {
                    "type": "string",
                    "description": "The session ID to read messages from."
                },
                "limit": {
                    "type": "integer",
                    "description": "Maximum number of messages to return. Defaults to 50."
                }
            },
            "required": ["session_id"],
            "additionalProperties": false
        }),
        handler: Box::new(|args, ctx| {
            let session_id = match args.get("session_id").and_then(|v| v.as_str()) {
                Some(s) => s.to_string(),
                None => {
                    return ToolInvokeResult::tool_error("missing required parameter: session_id")
                }
            };
            let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;

            // SECURITY: scope session_read to the caller's agent_id.
            // The session ID is caller-supplied; without an agent
            // check, a prompt-injected model can pass any
            // discoverable session_id (or one enumerated via the
            // previously-unscoped session_list) and read another
            // agent's raw message content. Require an agent context
            // and refuse when the resolved session doesn't belong to
            // it.
            let Some(agent_id) = ctx.agent_id.clone() else {
                return ToolInvokeResult::tool_error("session_read requires an agent context");
            };

            let base_path = resolve_sessions_path();
            let store =
                match crate::sessions::configured_store_with_path_from_current_config(base_path) {
                    Ok(store) => store,
                    Err(e) => {
                        return ToolInvokeResult::tool_error(format!(
                            "failed to initialize session store: {e}"
                        ))
                    }
                };

            // Resolve session metadata first and verify ownership.
            // Treat missing/encrypted/wrong-owner cases identically
            // so a probing model can't distinguish "session doesn't
            // exist" from "session belongs to another agent."
            match store.get_session(&session_id) {
                Ok(s) => {
                    if s.metadata.agent_id.as_deref() != Some(agent_id.as_str()) {
                        return ToolInvokeResult::tool_error("session not found");
                    }
                }
                Err(_) => return ToolInvokeResult::tool_error("session not found"),
            }

            match store.get_history(&session_id, Some(limit), None) {
                Ok(messages) => {
                    let message_list: Vec<Value> = messages
                        .iter()
                        .map(|m| {
                            json!({
                                "id": m.id,
                                "role": format!("{:?}", m.role),
                                "content": m.content,
                                "created_at": m.created_at,
                                "tool_name": m.tool_name,
                            })
                        })
                        .collect();
                    ToolInvokeResult::success(json!({ "messages": message_list }))
                }
                Err(e) => {
                    ToolInvokeResult::tool_error(format!("failed to read session history: {e}"))
                }
            }
        }),
    }
}

// ---------------------------------------------------------------------------
// config_read
// ---------------------------------------------------------------------------

fn config_read_tool() -> BuiltinTool {
    BuiltinTool {
        name: "config_read".to_string(),
        description: "Read a configuration value by dot-separated key path. \
                       Secret values (API keys, tokens, passwords) are redacted."
            .to_string(),
        input_schema: json!({
            "type": "object",
            "properties": {
                "key": {
                    "type": "string",
                    "description": "Dot-separated config key path (e.g. 'agent.model', 'channels.telegram')."
                }
            },
            "required": ["key"],
            "additionalProperties": false
        }),
        handler: Box::new(|args, _ctx| {
            let key = match args.get("key").and_then(|v| v.as_str()) {
                Some(k) => k.to_string(),
                None => return ToolInvokeResult::tool_error("missing required parameter: key"),
            };

            let config = match crate::config::load_config() {
                Ok(c) => c,
                Err(e) => {
                    return ToolInvokeResult::tool_error(format!("failed to load config: {e}"))
                }
            };

            // Navigate the config by dot-separated path. Track
            // whether the LAST traversed key matches a secret
            // pattern — if so, the leaf is a secret and must be
            // redacted directly even though `redact_secrets` only
            // recurses into Objects/Arrays and would leave a string
            // leaf unredacted.
            //
            // SECURITY: a prompt-injected model can otherwise
            // exfiltrate any secret by passing its explicit dotted
            // path (e.g. "channels.telegram.bot_token"). The leaf
            // path-segment check below is the load-bearing guard;
            // the recursive `redact_secrets` below handles
            // sub-object cases.
            let mut current = &config;
            let mut last_segment = "";
            for part in key.split('.') {
                last_segment = part;
                match current.get(part) {
                    Some(v) => current = v,
                    None => return ToolInvokeResult::success(json!({ "value": null })),
                }
            }

            // If the path's terminal segment names a secret and the
            // leaf is a scalar string, redact it. (Objects/arrays
            // recurse through redact_secrets as before.)
            let lower = last_segment.to_lowercase();
            let leaf_is_secret_name = secret_key_patterns().iter().any(|pat| lower.contains(pat));
            // Same shape-agnostic redaction as redact_secrets above:
            // when the path's terminal segment is secret-named, the
            // entire leaf (string OR object OR array) is replaced with
            // [REDACTED], not just the string case. Otherwise the
            // sub-object/array is recursed through redact_secrets so
            // descendant secret-named keys are still scrubbed.
            let redacted = if leaf_is_secret_name {
                Value::String("[REDACTED]".to_string())
            } else {
                redact_secrets(current.clone())
            };
            ToolInvokeResult::success(json!({ "value": redacted }))
        }),
    }
}

/// Patterns that indicate a value contains a secret.
/// Secret-key patterns used by `config_read` redaction. Sources from
/// `logging::redact::agent_tool_secret_key_names()` — the canonical
/// list PLUS the agent-tool-only broader patterns (`auth`,
/// `private`) that catch wrapper-shaped secrets but over-redact
/// operator-facing surfaces. The agent tool accepts the stricter
/// coverage because a prompt-injected model is the threat actor;
/// operator-facing redactors (CLI / WS config.get) stick with the
/// narrower canonical list to preserve `cara config show` usability.
fn secret_key_patterns() -> Vec<&'static str> {
    crate::logging::redact::agent_tool_secret_key_names()
}

/// Redact values whose keys look like they contain secrets.
///
/// SECURITY: when a key matches `SECRET_KEY_PATTERNS`, redact the
/// WHOLE subtree under it (string OR object OR array). The prior
/// implementation only short-circuited on `v.is_string()`, which
/// left object/array-shaped secrets like
/// `{api_key: {value: "sk-..."}}` or `{tokens: ["..."]}` reachable
/// — the recursive descent inspected only inner keys (e.g. `value`),
/// none of which match secret patterns, so the leaf string reached
/// the model. Now: any value under a secret-named key collapses to
/// `[REDACTED]` regardless of shape.
fn redact_secrets(value: Value) -> Value {
    match value {
        Value::Object(map) => {
            let mut result = serde_json::Map::new();
            for (k, v) in map {
                let lower = k.to_lowercase();
                let is_secret = secret_key_patterns().iter().any(|pat| lower.contains(pat));
                if is_secret {
                    result.insert(k, Value::String("[REDACTED]".to_string()));
                } else {
                    result.insert(k, redact_secrets(v));
                }
            }
            Value::Object(result)
        }
        Value::Array(arr) => Value::Array(arr.into_iter().map(redact_secrets).collect()),
        other => other,
    }
}

// ---------------------------------------------------------------------------
// math_eval
// ---------------------------------------------------------------------------

/// Maximum byte length of a math_eval expression. Defense-in-depth
/// against a prompt-injected model submitting a deeply-nested
/// expression like `(((((((((...)))))))))` to exhaust the recursive-
/// descent parser stack.
///
/// Each `(` traverses 5 parser frames (parse_expr → parse_term →
/// parse_power → parse_unary → parse_primary), not 1. At ~150-250
/// bytes per frame including locals and the `Result<f64, String>`
/// temporary, 1 KiB of `(` chars = ~1024 nesting levels × 5 frames
/// × ~200 B ≈ 1 MiB worst-case stack use — safely under the tokio
/// default 2 MiB worker stack. Tighter than the original 4 KiB,
/// which estimated 1 frame per `(` and would have produced ~10240
/// frames × ~200 B ≈ 2 MiB — borderline against the tokio stack.
pub(crate) const MATH_EVAL_MAX_EXPRESSION_BYTES: usize = 1024;

/// Maximum recursion depth for the math_eval parser. Explicit
/// counter as a second line of defense — even if the byte cap is
/// raised in a future refactor, the depth counter prevents stack
/// exhaustion. 128 nested expressions covers any realistic math
/// model emits.
pub(crate) const MATH_EVAL_MAX_PAREN_DEPTH: usize = 128;

fn math_eval_tool() -> BuiltinTool {
    BuiltinTool {
        name: "math_eval".to_string(),
        description: "Evaluate a simple math expression. Supports +, -, *, /, %, ^ (power), \
                       and parentheses. Numbers can be integers or decimals."
            .to_string(),
        input_schema: json!({
            "type": "object",
            "properties": {
                "expression": {
                    "type": "string",
                    "description": "The math expression to evaluate (e.g. '2 + 3 * 4', '(10 - 2) ^ 3').",
                    "maxLength": MATH_EVAL_MAX_EXPRESSION_BYTES
                }
            },
            "required": ["expression"],
            "additionalProperties": false
        }),
        handler: Box::new(|args, _ctx| {
            let expr = match args.get("expression").and_then(|v| v.as_str()) {
                Some(e) => e.to_string(),
                None => {
                    return ToolInvokeResult::tool_error("missing required parameter: expression")
                }
            };
            if expr.len() > MATH_EVAL_MAX_EXPRESSION_BYTES {
                return ToolInvokeResult::tool_error(format!(
                    "expression exceeds {} bytes",
                    MATH_EVAL_MAX_EXPRESSION_BYTES
                ));
            }

            match eval_math(&expr) {
                Ok(result) => ToolInvokeResult::success(json!({ "result": result })),
                Err(e) => ToolInvokeResult::tool_error(format!("math error: {e}")),
            }
        }),
    }
}

// ---------------------------------------------------------------------------
// Math expression evaluator (recursive descent parser)
// ---------------------------------------------------------------------------

/// Evaluate a math expression string.
///
/// Supports: +, -, *, /, %, ^ (power), parentheses, unary minus.
/// No external dependencies.
fn eval_math(expr: &str) -> Result<f64, String> {
    // Pre-check: bound parser recursion depth by scanning the input
    // for maximum paren-nesting depth before tokenizing. Each `(`
    // adds 5 parser frames at parse_primary's recursive call;
    // rejecting upfront avoids threading a depth counter through
    // the five-function recursive-descent stack.
    let mut depth = 0usize;
    let mut max_depth = 0usize;
    for ch in expr.chars() {
        match ch {
            '(' => {
                depth = depth.saturating_add(1);
                if depth > max_depth {
                    max_depth = depth;
                }
                if max_depth > MATH_EVAL_MAX_PAREN_DEPTH {
                    return Err(format!(
                        "expression exceeds maximum paren depth {}",
                        MATH_EVAL_MAX_PAREN_DEPTH
                    ));
                }
            }
            ')' => depth = depth.saturating_sub(1),
            _ => {}
        }
    }

    let tokens = tokenize(expr)?;
    let mut pos = 0;
    let result = parse_expr(&tokens, &mut pos)?;
    if pos < tokens.len() {
        return Err(format!("unexpected token: {:?}", tokens[pos]));
    }
    if result.is_nan() || result.is_infinite() {
        return Err("result is not a finite number".to_string());
    }
    Ok(result)
}

#[derive(Debug, Clone)]
enum Token {
    Number(f64),
    Plus,
    Minus,
    Star,
    Slash,
    Percent,
    Caret,
    LParen,
    RParen,
}

fn tokenize(input: &str) -> Result<Vec<Token>, String> {
    let mut tokens = Vec::new();
    let chars: Vec<char> = input.chars().collect();
    let mut i = 0;

    while i < chars.len() {
        match chars[i] {
            ' ' | '\t' | '\n' | '\r' => {
                i += 1;
            }
            '+' => {
                tokens.push(Token::Plus);
                i += 1;
            }
            '-' => {
                tokens.push(Token::Minus);
                i += 1;
            }
            '*' => {
                tokens.push(Token::Star);
                i += 1;
            }
            '/' => {
                tokens.push(Token::Slash);
                i += 1;
            }
            '%' => {
                tokens.push(Token::Percent);
                i += 1;
            }
            '^' => {
                tokens.push(Token::Caret);
                i += 1;
            }
            '(' => {
                tokens.push(Token::LParen);
                i += 1;
            }
            ')' => {
                tokens.push(Token::RParen);
                i += 1;
            }
            c if c.is_ascii_digit() || c == '.' => {
                let start = i;
                while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
                    i += 1;
                }
                let num_str: String = chars[start..i].iter().collect();
                let num: f64 = num_str
                    .parse()
                    .map_err(|_| format!("invalid number: {num_str}"))?;
                tokens.push(Token::Number(num));
            }
            c => return Err(format!("unexpected character: '{c}'")),
        }
    }
    Ok(tokens)
}

/// Parse addition and subtraction (lowest precedence).
fn parse_expr(tokens: &[Token], pos: &mut usize) -> Result<f64, String> {
    let mut left = parse_term(tokens, pos)?;
    while *pos < tokens.len() {
        match &tokens[*pos] {
            Token::Plus => {
                *pos += 1;
                left += parse_term(tokens, pos)?;
            }
            Token::Minus => {
                *pos += 1;
                left -= parse_term(tokens, pos)?;
            }
            _ => break,
        }
    }
    Ok(left)
}

/// Parse multiplication, division, modulo.
fn parse_term(tokens: &[Token], pos: &mut usize) -> Result<f64, String> {
    let mut left = parse_power(tokens, pos)?;
    while *pos < tokens.len() {
        match &tokens[*pos] {
            Token::Star => {
                *pos += 1;
                left *= parse_power(tokens, pos)?;
            }
            Token::Slash => {
                *pos += 1;
                let right = parse_power(tokens, pos)?;
                if right == 0.0 {
                    return Err("division by zero".to_string());
                }
                left /= right;
            }
            Token::Percent => {
                *pos += 1;
                let right = parse_power(tokens, pos)?;
                if right == 0.0 {
                    return Err("modulo by zero".to_string());
                }
                left %= right;
            }
            _ => break,
        }
    }
    Ok(left)
}

/// Parse exponentiation (right-associative).
fn parse_power(tokens: &[Token], pos: &mut usize) -> Result<f64, String> {
    let base = parse_unary(tokens, pos)?;
    if *pos < tokens.len() {
        if let Token::Caret = &tokens[*pos] {
            *pos += 1;
            let exp = parse_power(tokens, pos)?; // right-associative
            return Ok(base.powf(exp));
        }
    }
    Ok(base)
}

/// Parse unary minus and primary expressions.
fn parse_unary(tokens: &[Token], pos: &mut usize) -> Result<f64, String> {
    if *pos < tokens.len() {
        if let Token::Minus = &tokens[*pos] {
            *pos += 1;
            let val = parse_unary(tokens, pos)?;
            return Ok(-val);
        }
        // Allow unary plus
        if let Token::Plus = &tokens[*pos] {
            *pos += 1;
            return parse_unary(tokens, pos);
        }
    }
    parse_primary(tokens, pos)
}

/// Parse numbers and parenthesized expressions.
fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result<f64, String> {
    if *pos >= tokens.len() {
        return Err("unexpected end of expression".to_string());
    }
    match &tokens[*pos] {
        Token::Number(n) => {
            let val = *n;
            *pos += 1;
            Ok(val)
        }
        Token::LParen => {
            *pos += 1;
            let val = parse_expr(tokens, pos)?;
            if *pos >= tokens.len() {
                return Err("missing closing parenthesis".to_string());
            }
            match &tokens[*pos] {
                Token::RParen => {
                    *pos += 1;
                    Ok(val)
                }
                _ => Err("expected closing parenthesis".to_string()),
            }
        }
        t => Err(format!("unexpected token: {t:?}")),
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Resolve the sessions base path, matching the server's convention.
fn resolve_sessions_path() -> PathBuf {
    if let Some(state_dir) = crate::config::read_process_env("CARAPACE_STATE_DIR") {
        return PathBuf::from(state_dir).join("sessions");
    }
    dirs::config_dir()
        .unwrap_or_else(|| PathBuf::from(".config"))
        .join("carapace")
        .join("sessions")
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::auth::profiles::{
        AuthProfile, AuthProfileCredentialKind, OAuthProvider, ProfileStore,
    };
    use crate::plugins::tools::ToolInvokeContext;
    use crate::test_support::env::ScopedEnv;
    use serde_json::json;

    struct AuthProfileRuntimeResetGuard;

    impl AuthProfileRuntimeResetGuard {
        fn new() -> Self {
            crate::auth::profile_runtime::reset_auth_profile_runtime_for_tests();
            Self
        }
    }

    impl Drop for AuthProfileRuntimeResetGuard {
        fn drop(&mut self) {
            crate::auth::profile_runtime::reset_auth_profile_runtime_for_tests();
        }
    }

    // -- current_time tests --

    #[test]
    fn test_current_time() {
        let tool = current_time_tool();
        let ctx = ToolInvokeContext::default();
        let result = (tool.handler)(json!({}), &ctx);
        match result {
            ToolInvokeResult::Success { result, .. } => {
                assert!(result.get("iso").is_some(), "should have iso field");
                assert!(result.get("unix").is_some(), "should have unix field");
                let iso = result["iso"].as_str().unwrap();
                assert!(iso.ends_with('Z'), "ISO time should end with Z: {iso}");
                let unix = result["unix"].as_i64().unwrap();
                assert!(
                    unix > 1_700_000_000,
                    "unix timestamp should be recent: {unix}"
                );
            }
            _ => panic!("expected success"),
        }
    }

    // -- math_eval tests --

    #[test]
    fn test_math_basic_arithmetic() {
        assert_eq!(eval_math("2 + 3").unwrap(), 5.0);
        assert_eq!(eval_math("10 - 4").unwrap(), 6.0);
        assert_eq!(eval_math("3 * 7").unwrap(), 21.0);
        assert_eq!(eval_math("20 / 4").unwrap(), 5.0);
        assert_eq!(eval_math("10 % 3").unwrap(), 1.0);
    }

    #[test]
    fn test_math_precedence() {
        assert_eq!(eval_math("2 + 3 * 4").unwrap(), 14.0);
        assert_eq!(eval_math("(2 + 3) * 4").unwrap(), 20.0);
    }

    #[test]
    fn test_math_power() {
        assert_eq!(eval_math("2 ^ 3").unwrap(), 8.0);
        assert_eq!(eval_math("2 ^ 3 ^ 2").unwrap(), 512.0); // right-assoc: 2^(3^2) = 2^9 = 512
    }

    #[test]
    fn test_math_unary_minus() {
        assert_eq!(eval_math("-5").unwrap(), -5.0);
        assert_eq!(eval_math("-5 + 3").unwrap(), -2.0);
        assert_eq!(eval_math("-(2 + 3)").unwrap(), -5.0);
    }

    #[test]
    fn test_math_decimals() {
        assert!((eval_math("1.5 + 2.5").unwrap() - 4.0).abs() < 1e-10);
        let result = eval_math("3.5 * 2").unwrap();
        assert!((result - 7.0).abs() < 1e-10);
    }

    #[test]
    fn test_math_division_by_zero() {
        assert!(eval_math("1 / 0").is_err());
        assert!(eval_math("5 % 0").is_err());
    }

    #[test]
    fn test_math_invalid_expression() {
        assert!(eval_math("").is_err());
        assert!(eval_math("2 +").is_err());
        assert!(eval_math("* 3").is_err());
        assert!(eval_math("abc").is_err());
    }

    #[test]
    fn test_math_nested_parens() {
        assert_eq!(eval_math("((2 + 3) * (4 - 1))").unwrap(), 15.0);
    }

    #[test]
    fn test_math_tool_handler() {
        let tool = math_eval_tool();
        let ctx = ToolInvokeContext::default();
        let result = (tool.handler)(json!({"expression": "2 + 3 * 4"}), &ctx);
        match result {
            ToolInvokeResult::Success { result, .. } => {
                assert_eq!(result["result"], 14.0);
            }
            _ => panic!("expected success"),
        }
    }

    #[test]
    fn test_math_tool_missing_expression() {
        let tool = math_eval_tool();
        let ctx = ToolInvokeContext::default();
        let result = (tool.handler)(json!({}), &ctx);
        match result {
            ToolInvokeResult::Error { .. } => {}
            _ => panic!("expected error for missing expression"),
        }
    }

    // -- memory tests --

    #[test]
    fn test_memory_write_and_read() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("test_agent.json");

        // Write
        let mut store = HashMap::new();
        store.insert("greeting".to_string(), "hello world".to_string());
        save_memory(&path, &store).unwrap();

        // Read
        let loaded = load_memory(&path);
        assert_eq!(loaded.get("greeting").unwrap(), "hello world");
    }

    /// Pin Batch 29 file-mode discipline: agent memory store must be
    /// mode 0o600 on Unix so model-stashed content isn't world-
    /// readable on multi-user hosts.
    #[cfg(unix)]
    #[test]
    fn test_save_memory_file_mode_is_0o600() {
        use std::os::unix::fs::PermissionsExt;
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("mode_test_agent.json");
        let mut store = HashMap::new();
        store.insert("k".to_string(), "v".to_string());
        save_memory(&path, &store).unwrap();
        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
        assert_eq!(
            mode & 0o777,
            0o600,
            "agent memory file must be 0o600, got 0o{:o}",
            mode & 0o777
        );
    }

    /// Pin the Batch 28 memory_write per-key cap (1 KiB). An oversize
    /// key returns a tool error and the on-disk store is unchanged.
    /// A boundary-sized key (exactly the cap) succeeds.
    #[test]
    fn test_memory_write_key_cap_rejects_oversize_and_accepts_boundary() {
        let mut env = ScopedEnv::new();
        let dir = tempfile::tempdir().unwrap();
        env.set("CARAPACE_STATE_DIR", dir.path());
        let tool = memory_write_tool();
        let ctx = ToolInvokeContext {
            agent_id: Some("test_key_cap_agent".to_string()),
            ..Default::default()
        };

        // Oversize key → rejected.
        let oversize = "x".repeat(MEMORY_WRITE_MAX_KEY_BYTES + 1);
        let result = (tool.handler)(json!({ "key": oversize.clone(), "value": "v" }), &ctx);
        assert!(matches!(result, ToolInvokeResult::Error { .. }));

        // Boundary-size key → accepted.
        let boundary = "k".repeat(MEMORY_WRITE_MAX_KEY_BYTES);
        let result = (tool.handler)(json!({ "key": boundary, "value": "v" }), &ctx);
        assert!(matches!(result, ToolInvokeResult::Success { .. }));
    }

    /// Pin the Batch 28 memory_write per-value cap (64 KiB).
    #[test]
    fn test_memory_write_value_cap_rejects_oversize_and_accepts_boundary() {
        let mut env = ScopedEnv::new();
        let dir = tempfile::tempdir().unwrap();
        env.set("CARAPACE_STATE_DIR", dir.path());
        let tool = memory_write_tool();
        let ctx = ToolInvokeContext {
            agent_id: Some("test_value_cap_agent".to_string()),
            ..Default::default()
        };

        let oversize = "v".repeat(MEMORY_WRITE_MAX_VALUE_BYTES + 1);
        let result = (tool.handler)(json!({ "key": "k", "value": oversize }), &ctx);
        assert!(matches!(result, ToolInvokeResult::Error { .. }));

        let boundary = "v".repeat(MEMORY_WRITE_MAX_VALUE_BYTES);
        let result = (tool.handler)(json!({ "key": "k", "value": boundary }), &ctx);
        assert!(matches!(result, ToolInvokeResult::Success { .. }));
    }

    /// Pin the Batch 28 whole-store cap (4 MiB): a write that would
    /// push the cumulative total past the cap is rejected, and the
    /// on-disk store does NOT contain the rejected entry.
    #[test]
    fn test_memory_write_store_cap_rejects_when_cumulative_exceeds() {
        let mut env = ScopedEnv::new();
        let dir = tempfile::tempdir().unwrap();
        env.set("CARAPACE_STATE_DIR", dir.path());
        let tool = memory_write_tool();
        let ctx = ToolInvokeContext {
            agent_id: Some("test_store_cap_agent".to_string()),
            ..Default::default()
        };

        // Pre-populate with ~64 entries × 64 KiB = ~4 MiB worth of
        // values via direct save_memory (faster than the tool loop).
        let path = memory_store_path(ctx.agent_id.as_deref());
        let mut pre = HashMap::new();
        let value = "v".repeat(MEMORY_WRITE_MAX_VALUE_BYTES);
        let entries_to_cap = MEMORY_WRITE_MAX_STORE_BYTES / MEMORY_WRITE_MAX_VALUE_BYTES;
        for i in 0..entries_to_cap {
            pre.insert(format!("k{i:04}"), value.clone());
        }
        save_memory(&path, &pre).unwrap();

        // One more max-size write would cross the cap.
        let oversize_total = (tool.handler)(json!({ "key": "overflow", "value": value }), &ctx);
        assert!(
            matches!(oversize_total, ToolInvokeResult::Error { .. }),
            "writing past the whole-store cap must be rejected"
        );

        // Verify the on-disk store still has the original keys and
        // does NOT contain the rejected entry.
        let loaded = load_memory(&path);
        assert!(
            !loaded.contains_key("overflow"),
            "rejected write must not appear on disk"
        );
        assert_eq!(loaded.len(), entries_to_cap);
    }

    /// Pin schema/runtime cap parity: the tool's JSON schema declares
    /// `maxLength` matching the runtime cap constants. A regression
    /// that drops a `maxLength` (or that drifts the cap value relative
    /// to the schema) would let a misbehaving model pre-flight-check
    /// against a stale ceiling.
    #[test]
    fn test_memory_write_schema_maxlength_matches_runtime_caps() {
        let tool = memory_write_tool();
        let key_schema = &tool.input_schema["properties"]["key"];
        let value_schema = &tool.input_schema["properties"]["value"];
        assert_eq!(
            key_schema["maxLength"].as_u64(),
            Some(MEMORY_WRITE_MAX_KEY_BYTES as u64),
            "key schema maxLength must match runtime cap"
        );
        assert_eq!(
            value_schema["maxLength"].as_u64(),
            Some(MEMORY_WRITE_MAX_VALUE_BYTES as u64),
            "value schema maxLength must match runtime cap"
        );
    }

    #[test]
    fn test_memory_read_nonexistent() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join("nonexistent.json");
        let loaded = load_memory(&path);
        assert!(loaded.is_empty());
    }

    #[test]
    fn test_memory_list_empty() {
        let tool = memory_list_tool();
        let ctx = ToolInvokeContext {
            agent_id: Some("test_empty_list_agent_xxxxx".to_string()),
            ..Default::default()
        };
        let result = (tool.handler)(json!({}), &ctx);
        match result {
            ToolInvokeResult::Success { result, .. } => {
                let keys = result["keys"].as_array().unwrap();
                assert!(keys.is_empty());
            }
            _ => panic!("expected success"),
        }
    }

    // -- config_read tests --

    #[test]
    fn test_config_read_missing_key() {
        let mut env = ScopedEnv::new();
        let temp_dir = tempfile::tempdir().expect("temp config dir");
        let config_path = temp_dir.path().join("carapace-test-config.json5");
        std::fs::write(&config_path, "{}").expect("write isolated config");
        env.set("CARAPACE_CONFIG_PATH", config_path);
        env.set("CARAPACE_DISABLE_CONFIG_CACHE", "1");
        crate::config::clear_cache();

        let tool = config_read_tool();
        let ctx = ToolInvokeContext::default();
        let result = (tool.handler)(json!({"key": "nonexistent.deeply.nested"}), &ctx);
        match result {
            ToolInvokeResult::Success { result, .. } => {
                assert!(result["value"].is_null());
            }
            _ => panic!("expected success with null value"),
        }

        crate::config::clear_cache();
    }

    #[test]
    fn test_config_read_missing_param() {
        let tool = config_read_tool();
        let ctx = ToolInvokeContext::default();
        let result = (tool.handler)(json!({}), &ctx);
        match result {
            ToolInvokeResult::Error { .. } => {}
            _ => panic!("expected error for missing key parameter"),
        }
    }

    // -- redact_secrets tests --

    #[test]
    fn test_redact_secrets_simple() {
        let input = json!({
            "name": "my-bot",
            "api_key": "sk-abc123",
            "token": "secret-token",
            "model": "claude-sonnet"
        });
        let redacted = redact_secrets(input);
        assert_eq!(redacted["name"], "my-bot");
        assert_eq!(redacted["api_key"], "[REDACTED]");
        assert_eq!(redacted["token"], "[REDACTED]");
        assert_eq!(redacted["model"], "claude-sonnet");
    }

    #[test]
    fn test_redact_secrets_nested() {
        let input = json!({
            "channels": {
                "telegram": {
                    "token": "bot-token-123",
                    "chat_id": "12345"
                }
            }
        });
        let redacted = redact_secrets(input);
        assert_eq!(redacted["channels"]["telegram"]["token"], "[REDACTED]");
        assert_eq!(redacted["channels"]["telegram"]["chat_id"], "12345");
    }

    #[test]
    fn test_redact_secrets_collapses_subtree_under_secret_key() {
        // Batch 30 hardening: when a key matches SECRET_KEY_PATTERNS,
        // the WHOLE subtree is redacted regardless of shape (string,
        // object, array, number, bool). The prior implementation
        // only short-circuited on `v.is_string()`, leaving
        // `{api_key: {value: "sk-..."}}` and similar wrappers
        // reachable through recursive descent.
        let input = json!({
            "api_key_object": { "value": "sk-LEAKED-SECRET" },
            "tokens_array": ["t1", "t2", "t3"],
            "credential_number": 42,
            "secret_bool": true,
            "auth_string": "Bearer xyz"
        });
        let redacted = redact_secrets(input);
        // Each secret-named key collapses to [REDACTED] regardless
        // of shape — no path into the wrapper can leak the leaf.
        assert_eq!(redacted["api_key_object"], "[REDACTED]");
        assert_eq!(redacted["tokens_array"], "[REDACTED]");
        assert_eq!(redacted["credential_number"], "[REDACTED]");
        assert_eq!(redacted["secret_bool"], "[REDACTED]");
        assert_eq!(redacted["auth_string"], "[REDACTED]");
    }

    /// Pin the HIGH Batch 29 fix: config_read's leaf-redaction now
    /// covers Object/Array leaves as well as strings (Batch 30
    /// extends the shape coverage). A prompt-injected model asking
    /// for `channels.telegram.bot_token` (or any sub-object under a
    /// secret-named key) MUST receive `[REDACTED]`, not the raw leaf.
    #[test]
    fn test_config_read_redacts_leaf_at_secret_named_path() {
        let mut env = ScopedEnv::new();
        let temp_dir = tempfile::tempdir().expect("temp config dir");
        let config_path = temp_dir.path().join("test-config.json5");
        std::fs::write(
            &config_path,
            r#"{
                "channels": {
                    "telegram": {
                        "bot_token": "secret-bot-token-12345",
                        "chat_id": 99
                    }
                }
            }"#,
        )
        .expect("write isolated config");
        env.set("CARAPACE_CONFIG_PATH", config_path);
        env.set("CARAPACE_DISABLE_CONFIG_CACHE", "1");
        crate::config::clear_cache();

        let tool = config_read_tool();
        let ctx = ToolInvokeContext::default();

        // Direct leaf access at secret-named path
        let result = (tool.handler)(json!({"key": "channels.telegram.bot_token"}), &ctx);
        match result {
            ToolInvokeResult::Success { result, .. } => {
                assert_eq!(
                    result["value"], "[REDACTED]",
                    "secret-named leaf must be redacted"
                );
            }
            _ => panic!("expected success"),
        }

        // Non-secret sibling still readable
        let result = (tool.handler)(json!({"key": "channels.telegram.chat_id"}), &ctx);
        match result {
            ToolInvokeResult::Success { result, .. } => {
                assert_eq!(result["value"], 99);
            }
            _ => panic!("expected success"),
        }
    }

    // -- message_send tests --

    #[test]
    fn test_message_send_success() {
        let tool = message_send_tool();
        let ctx = ToolInvokeContext::default();
        let result = (tool.handler)(json!({"channel": "telegram", "text": "Hello world"}), &ctx);
        match result {
            ToolInvokeResult::Success { result, .. } => {
                assert_eq!(result["queued"], true);
                assert_eq!(result["channel"], "telegram");
            }
            _ => panic!("expected success"),
        }
    }

    #[test]
    fn test_message_send_missing_channel() {
        let tool = message_send_tool();
        let ctx = ToolInvokeContext::default();
        let result = (tool.handler)(json!({"text": "Hello"}), &ctx);
        match result {
            ToolInvokeResult::Error { .. } => {}
            _ => panic!("expected error for missing channel"),
        }
    }

    #[test]
    fn test_message_send_empty_text() {
        let tool = message_send_tool();
        let ctx = ToolInvokeContext::default();
        let result = (tool.handler)(json!({"channel": "telegram", "text": ""}), &ctx);
        match result {
            ToolInvokeResult::Error { .. } => {}
            _ => panic!("expected error for empty text"),
        }
    }

    /// Pin Batch 29 message_send text cap.
    #[test]
    fn test_message_send_text_cap_rejects_oversize() {
        let tool = message_send_tool();
        let ctx = ToolInvokeContext::default();
        let oversize = "t".repeat(MESSAGE_SEND_MAX_TEXT_BYTES + 1);
        let result = (tool.handler)(json!({"channel": "telegram", "text": oversize}), &ctx);
        assert!(matches!(result, ToolInvokeResult::Error { .. }));
    }

    /// Pin Batch 29 message_send channel cap.
    #[test]
    fn test_message_send_channel_cap_rejects_oversize() {
        let tool = message_send_tool();
        let ctx = ToolInvokeContext::default();
        let oversize = "c".repeat(MESSAGE_SEND_MAX_CHANNEL_BYTES + 1);
        let result = (tool.handler)(json!({"channel": oversize, "text": "hello"}), &ctx);
        assert!(matches!(result, ToolInvokeResult::Error { .. }));
    }

    /// Pin Batch 29 math_eval input length cap.
    #[test]
    fn test_math_eval_rejects_oversize_expression() {
        let tool = math_eval_tool();
        let ctx = ToolInvokeContext::default();
        let oversize = "1+".repeat(MATH_EVAL_MAX_EXPRESSION_BYTES) + "1";
        assert!(oversize.len() > MATH_EVAL_MAX_EXPRESSION_BYTES);
        let result = (tool.handler)(json!({"expression": oversize}), &ctx);
        assert!(matches!(result, ToolInvokeResult::Error { .. }));
    }

    /// Pin Batch 30 math_eval paren-depth cap.
    #[test]
    fn test_math_eval_rejects_deep_paren_nesting() {
        let tool = math_eval_tool();
        let ctx = ToolInvokeContext::default();
        // `(` × (MAX_DEPTH + 1) followed by 1 + closing parens
        let deep = format!(
            "{}1{}",
            "(".repeat(MATH_EVAL_MAX_PAREN_DEPTH + 1),
            ")".repeat(MATH_EVAL_MAX_PAREN_DEPTH + 1)
        );
        if deep.len() <= MATH_EVAL_MAX_EXPRESSION_BYTES {
            let result = (tool.handler)(json!({"expression": deep}), &ctx);
            assert!(matches!(result, ToolInvokeResult::Error { .. }));
        }
    }

    // -- session_list tests --

    #[test]
    fn test_session_list_tool_definition() {
        let tool = session_list_tool();
        assert_eq!(tool.name, "session_list");
        assert!(!tool.description.is_empty());
    }

    /// Pin the Batch 31 HIGH security fix: session_list refuses to
    /// list sessions when there is no agent context, preventing a
    /// caller (prompt-injected model that somehow reaches the
    /// builtin without an agent_id) from enumerating sessions across
    /// all agents.
    #[test]
    fn test_session_list_refuses_without_agent_context() {
        let tool = session_list_tool();
        let ctx = ToolInvokeContext {
            agent_id: None,
            ..Default::default()
        };
        let result = (tool.handler)(json!({}), &ctx);
        match result {
            ToolInvokeResult::Error { error, .. } => {
                assert!(
                    error.message.contains("agent context"),
                    "error should reference agent context: {:?}",
                    error.message
                );
            }
            _ => panic!("expected error for missing agent context"),
        }
    }

    // -- session_read tests --

    #[test]
    fn test_session_read_missing_session_id() {
        let tool = session_read_tool();
        let ctx = ToolInvokeContext::default();
        let result = (tool.handler)(json!({}), &ctx);
        match result {
            ToolInvokeResult::Error { .. } => {}
            _ => panic!("expected error for missing session_id"),
        }
    }

    /// Pin the Batch 31 HIGH security fix: session_read refuses
    /// without an agent context, so a model-driven invocation can't
    /// bypass agent scoping by handing in a guessed session_id.
    #[test]
    fn test_session_read_refuses_without_agent_context() {
        let tool = session_read_tool();
        let ctx = ToolInvokeContext {
            agent_id: None,
            ..Default::default()
        };
        let result = (tool.handler)(json!({"session_id": "any-id"}), &ctx);
        match result {
            ToolInvokeResult::Error { error, .. } => {
                assert!(
                    error.message.contains("agent context"),
                    "error should reference agent context: {:?}",
                    error.message
                );
            }
            _ => panic!("expected error for missing agent context"),
        }
    }

    /// Pin the cross-agent ownership check: a session_read request
    /// for a session_id that doesn't exist (or belongs to a
    /// different agent) returns "session not found" — the
    /// indistinguishable-from-missing error message that prevents a
    /// probing model from distinguishing "doesn't exist" from
    /// "belongs to another agent". Tests against a fully-isolated
    /// state dir so no other tests can leave fixture sessions.
    #[test]
    fn test_session_read_returns_not_found_for_unknown_session() {
        let mut env = ScopedEnv::new();
        let tmp = tempfile::tempdir().expect("temp state dir");
        env.set("CARAPACE_STATE_DIR", tmp.path());
        let tool = session_read_tool();
        let ctx = ToolInvokeContext {
            agent_id: Some("agent-A".to_string()),
            ..Default::default()
        };
        // The session ID format is opaque; pass a syntactically-
        // plausible UUID-shaped ID that doesn't exist anywhere.
        let result = (tool.handler)(
            json!({"session_id": "00000000-0000-0000-0000-000000000000"}),
            &ctx,
        );
        match result {
            ToolInvokeResult::Error { error, .. } => {
                assert!(
                    error.message.contains("session not found"),
                    "error should be the canonical not-found message: {:?}",
                    error.message
                );
            }
            _ => panic!("expected not-found error"),
        }
    }

    // -- web_fetch tests --

    #[test]
    fn test_web_fetch_missing_url() {
        let tool = web_fetch_tool();
        let ctx = ToolInvokeContext::default();
        let result = (tool.handler)(json!({}), &ctx);
        match result {
            ToolInvokeResult::Error { .. } => {}
            _ => panic!("expected error for missing url"),
        }
    }

    #[test]
    fn test_web_fetch_bridge_inside_current_thread_runtime_is_panic_free() {
        let tool = web_fetch_tool();
        let ctx = ToolInvokeContext::default();
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap();

        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            rt.block_on(async {
                (tool.handler)(
                    json!({
                        "url": "not a valid url"
                    }),
                    &ctx,
                )
            })
        }));

        assert!(
            result.is_ok(),
            "web_fetch should not panic in current-thread runtime"
        );
        assert!(
            matches!(result.unwrap(), ToolInvokeResult::Error { .. }),
            "expected tool error from invalid url path, got success"
        );
    }

    // -- media_analyze tests --

    #[test]
    fn test_media_analyze_missing_source() {
        let tool = media_analyze_tool();
        let ctx = ToolInvokeContext::default();
        let result = (tool.handler)(json!({}), &ctx);
        match result {
            ToolInvokeResult::Error { .. } => {}
            _ => panic!("expected error for missing url/path"),
        }
    }

    #[test]
    fn test_media_analyze_conflicting_source() {
        let tool = media_analyze_tool();
        let ctx = ToolInvokeContext::default();
        let result = (tool.handler)(
            json!({"url": "https://example.com", "path": "/tmp/a.png"}),
            &ctx,
        );
        match result {
            ToolInvokeResult::Error { .. } => {}
            _ => panic!("expected error for both url and path"),
        }
    }

    /// Pin Batch 32 HIGH fix: media_analyze rejects paths that
    /// resolve outside the operator-configured filesystem-tool
    /// allowed roots. Default config has no roots, so any absolute
    /// path is rejected with a "media_analyze path rejected"
    /// message — the canonical guard.
    #[test]
    fn test_media_analyze_path_outside_roots_is_rejected() {
        let tool = media_analyze_tool();
        let ctx = ToolInvokeContext::default();
        let result = (tool.handler)(
            json!({"path": "/etc/passwd", "mime_type": "text/plain"}),
            &ctx,
        );
        match result {
            ToolInvokeResult::Error { error, .. } => {
                // Either the validate_path_against_config error or
                // the cannot-resolve error from canonicalize for a
                // missing path — both prove the validator ran.
                assert!(
                    error.message.contains("media_analyze path rejected")
                        || error.message.contains("path"),
                    "unexpected error: {:?}",
                    error.message
                );
            }
            _ => panic!("expected error for path outside allowed roots"),
        }
    }

    #[test]
    fn test_media_analyze_unsupported_provider() {
        let tool = media_analyze_tool();
        let ctx = ToolInvokeContext::default();
        let result = (tool.handler)(
            json!({"url": "https://example.com/image.png", "provider": "unknown"}),
            &ctx,
        );
        match result {
            ToolInvokeResult::Error { .. } => {}
            _ => panic!("expected error for unsupported provider"),
        }
    }

    #[test]
    fn test_resolve_anthropic_media_key_surfaces_unusable_auth_profile() {
        let mut env = ScopedEnv::new();
        let _runtime_guard = AuthProfileRuntimeResetGuard::new();
        let temp = tempfile::tempdir().unwrap();
        env.set("CARAPACE_STATE_DIR", temp.path());

        let correct_password = format!(
            "builtin-tools-test-password-{}",
            crate::time::unix_now_ms_u64()
        );
        let store =
            ProfileStore::with_encryption(temp.path().to_path_buf(), correct_password.as_bytes())
                .expect("encrypted profile store");
        store
            .add(AuthProfile {
                id: "anthropic:default".to_string(),
                name: "Anthropic setup token".to_string(),
                provider: OAuthProvider::Anthropic,
                user_id: None,
                email: None,
                display_name: None,
                avatar_url: None,
                created_at_ms: 1,
                last_used_ms: None,
                credential_kind: AuthProfileCredentialKind::Token,
                tokens: None,
                token: Some("sk-ant-oat01-test-token".to_string()),
                oauth_provider_config: None,
            })
            .expect("store profile");

        let wrong_password = format!(
            "builtin-tools-test-wrong-password-{}",
            crate::time::unix_now_ms_u64()
        );
        env.set("CARAPACE_CONFIG_PASSWORD", &wrong_password);
        let cfg = json!({
            "anthropic": { "authProfile": "anthropic:default" }
        });

        let err = resolve_anthropic_media_key(&cfg).expect_err("wrong password should surface");
        assert!(err.contains("could not decrypt the stored token"));
        assert!(err.contains("CARAPACE_CONFIG_PASSWORD"));
    }

    #[test]
    fn test_resolve_anthropic_media_key_ignores_blank_api_key_when_auth_profile_present() {
        let mut env = ScopedEnv::new();
        let _runtime_guard = AuthProfileRuntimeResetGuard::new();
        let temp = tempfile::tempdir().unwrap();
        env.set("CARAPACE_STATE_DIR", temp.path());

        let password = format!(
            "builtin-tools-test-password-{}",
            crate::time::unix_now_ms_u64()
        );
        let store = ProfileStore::with_encryption(temp.path().to_path_buf(), password.as_bytes())
            .expect("encrypted profile store");
        store
            .add(AuthProfile {
                id: "anthropic:default".to_string(),
                name: "Anthropic setup token".to_string(),
                provider: OAuthProvider::Anthropic,
                user_id: None,
                email: None,
                display_name: None,
                avatar_url: None,
                created_at_ms: 1,
                last_used_ms: None,
                credential_kind: AuthProfileCredentialKind::Token,
                tokens: None,
                token: Some("sk-ant-oat01-test-token".to_string()),
                oauth_provider_config: None,
            })
            .expect("store profile");

        env.set("CARAPACE_CONFIG_PASSWORD", &password);
        env.set("ANTHROPIC_API_KEY", "   ");
        let cfg = json!({
            "anthropic": { "authProfile": "anthropic:default" }
        });

        let key = resolve_anthropic_media_key(&cfg).expect("resolved key");
        assert_eq!(key.as_deref(), Some("sk-ant-oat01-test-token"));
    }

    #[test]
    fn test_resolve_anthropic_media_key_reuses_cached_auth_profile_store() {
        let mut env = ScopedEnv::new();
        let _runtime_guard = AuthProfileRuntimeResetGuard::new();
        let temp = tempfile::tempdir().unwrap();
        env.set("CARAPACE_STATE_DIR", temp.path());

        let password = format!(
            "builtin-tools-runtime-cache-password-{}",
            crate::time::unix_now_ms_u64()
        );
        let store = ProfileStore::with_encryption(temp.path().to_path_buf(), password.as_bytes())
            .expect("encrypted profile store");
        store
            .add(AuthProfile {
                id: "anthropic:default".to_string(),
                name: "Anthropic setup token".to_string(),
                provider: OAuthProvider::Anthropic,
                user_id: None,
                email: None,
                display_name: None,
                avatar_url: None,
                created_at_ms: 1,
                last_used_ms: None,
                credential_kind: AuthProfileCredentialKind::Token,
                tokens: None,
                token: Some("sk-ant-oat01-runtime-cache-token".to_string()),
                oauth_provider_config: None,
            })
            .expect("store profile");

        env.set("CARAPACE_CONFIG_PASSWORD", &password);
        let cfg = json!({
            "anthropic": { "authProfile": "anthropic:default" }
        });

        let first = resolve_anthropic_media_key(&cfg).expect("first resolve");
        let second = resolve_anthropic_media_key(&cfg).expect("second resolve");
        assert_eq!(first.as_deref(), Some("sk-ant-oat01-runtime-cache-token"));
        assert_eq!(second.as_deref(), Some("sk-ant-oat01-runtime-cache-token"));
        assert_eq!(
            crate::auth::profile_runtime::anthropic_profile_runtime_load_attempts_for_tests(),
            1
        );
    }

    #[test]
    fn test_media_analyze_bridge_inside_current_thread_runtime_is_panic_free() {
        let tmp = tempfile::tempdir().unwrap();
        let media_path = tmp.path().join("sample.bin");
        std::fs::write(&media_path, b"unit test fixture").unwrap();

        let tool = media_analyze_tool();
        let ctx = ToolInvokeContext::default();
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap();

        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            rt.block_on(async {
                (tool.handler)(
                    json!({
                        "path": media_path.to_string_lossy()
                    }),
                    &ctx,
                )
            })
        }));

        assert!(
            result.is_ok(),
            "media_analyze should not panic in current-thread runtime"
        );
        assert!(
            matches!(result.unwrap(), ToolInvokeResult::Error { .. }),
            "expected tool error from unsupported/local path media type path, got success"
        );
    }

    // -- builtin_tools registration --

    #[test]
    fn test_builtin_tools_returns_all_tools() {
        let tools = builtin_tools();
        assert_eq!(tools.len(), 11, "should have 11 built-in tools");
        let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
        assert!(names.contains(&"current_time"));
        assert!(names.contains(&"web_fetch"));
        assert!(names.contains(&"media_analyze"));
        assert!(names.contains(&"memory_read"));
        assert!(names.contains(&"memory_write"));
        assert!(names.contains(&"memory_list"));
        assert!(names.contains(&"message_send"));
        assert!(names.contains(&"session_list"));
        assert!(names.contains(&"session_read"));
        assert!(names.contains(&"config_read"));
        assert!(names.contains(&"math_eval"));
    }

    #[test]
    fn test_all_tools_have_valid_schemas() {
        let tools = builtin_tools();
        for tool in &tools {
            assert!(!tool.name.is_empty(), "tool name should not be empty");
            assert!(
                !tool.description.is_empty(),
                "tool description should not be empty: {}",
                tool.name
            );
            let schema = &tool.input_schema;
            assert_eq!(
                schema["type"], "object",
                "tool {} schema should have type: object",
                tool.name
            );
        }
    }
}
