#[cfg(not(unix))]
compile_error!(
    "coding_agent_tools only supports Unix-like platforms (Linux/macOS). Windows is not supported."
);

pub mod agent;
pub mod glob;
pub mod grep;
pub mod just;
mod logging;
pub mod pagination;
pub mod paths;
pub mod tools;
pub mod types;
pub mod walker;

pub use tools::build_registry;

use agentic_config::types::CliToolsConfig;
use agentic_config::types::SubagentsConfig;
use agentic_tools_core::ToolError;
use claudecode::types::Result as ClaudeResult;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use types::AgentOutput;
use types::Depth;
use types::GlobOutput;
use types::GrepOutput;
use types::LsOutput;
use types::OutputMode;
use types::Show;
use types::SortOrder;

/// Select the first non-empty (after trimming) text from result.result or result.content.
/// Prefers `result.result` over `result.content`, but rejects empty/whitespace-only strings.
fn pick_non_empty_text(result: &claudecode::types::Result) -> Option<String> {
    result
        .result
        .as_ref()
        .filter(|s| !s.trim().is_empty())
        .cloned()
        .or_else(|| {
            result
                .content
                .as_ref()
                .filter(|s| !s.trim().is_empty())
                .cloned()
        })
}

const ASK_AGENT_TIMEOUT_CLEANUP_TIMEOUT_SECS: u64 = 5;

async fn wait_for_claude_result<F, C, CFn>(
    ctx: &agentic_tools_core::ToolContext,
    wait_fut: F,
    cancel_fn: CFn,
) -> Result<ClaudeResult, ToolError>
where
    F: Future<Output = claudecode::Result<ClaudeResult>> + Send,
    C: Future<Output = claudecode::Result<()>> + Send,
    CFn: Fn() -> C + Send + Sync,
{
    tokio::select! {
        () = ctx.cancelled() => {
            if let Err(e) = cancel_fn().await {
                tracing::warn!(error = %e, "Failed to cancel Claude session cleanly");
            }
            Err(ToolError::cancelled(None))
        }
        result = wait_fut => {
            result.map_err(|e| ToolError::Internal(format!("Failed to run Claude session: {e}")))
        }
    }
}

async fn wait_for_claude_result_with_timeout<F, C, CFn>(
    ctx: &agentic_tools_core::ToolContext,
    wait_fut: F,
    cancel_fn: CFn,
    timeout_secs: u64,
) -> Result<ClaudeResult, ToolError>
where
    F: Future<Output = claudecode::Result<ClaudeResult>> + Send,
    C: Future<Output = claudecode::Result<()>> + Send,
    CFn: Fn() -> C + Send + Sync,
{
    if timeout_secs == 0 {
        return wait_for_claude_result(ctx, wait_fut, &cancel_fn).await;
    }

    if let Ok(result) = tokio::time::timeout(
        Duration::from_secs(timeout_secs),
        wait_for_claude_result(ctx, wait_fut, &cancel_fn),
    )
    .await
    {
        result
    } else {
        match tokio::time::timeout(
            Duration::from_secs(ASK_AGENT_TIMEOUT_CLEANUP_TIMEOUT_SECS),
            cancel_fn(),
        )
        .await
        {
            Ok(Ok(())) => {}
            Ok(Err(e)) => {
                tracing::warn!(
                    error = %e,
                    cleanup_timeout_secs = ASK_AGENT_TIMEOUT_CLEANUP_TIMEOUT_SECS,
                    "Failed to cancel Claude session cleanly after timeout"
                );
            }
            Err(_) => {
                tracing::warn!(
                    cleanup_timeout_secs = ASK_AGENT_TIMEOUT_CLEANUP_TIMEOUT_SECS,
                    "Claude cancellation cleanup exceeded timeout after ask_agent deadline"
                );
            }
        }
        Err(ToolError::Internal(format!(
            "ask_agent timed out after {timeout_secs}s"
        )))
    }
}

async fn wait_for_just_result_with_timeout<T, F>(
    ctx: &agentic_tools_core::ToolContext,
    fut: F,
    timeout_secs: u64,
) -> Result<T, ToolError>
where
    F: Future<Output = Result<T, String>>,
{
    if timeout_secs == 0 {
        return tokio::select! {
            () = ctx.cancelled() => Err(ToolError::cancelled(None)),
            result = fut => result.map_err(ToolError::Internal),
        };
    }

    tokio::select! {
        () = ctx.cancelled() => Err(ToolError::cancelled(None)),
        result = tokio::time::timeout(Duration::from_secs(timeout_secs), fut) => {
            match result {
                Ok(result) => result.map_err(ToolError::Internal),
                Err(_) => Err(ToolError::Internal(format!("Just search timed out after {timeout_secs}s"))),
            }
        }
    }
}

#[derive(Clone)]
pub struct CodingAgentTools {
    /// Tool-specific config for subagents (model selection).
    subagents: SubagentsConfig,
    /// Tool-specific config for CLI tools (limits, ignore patterns).
    cli_tools: CliToolsConfig,
    /// Two-level pagination cache for MCP (persists across calls when Arc-wrapped)
    pager: Arc<pagination::PaginationCache>,
    /// Cache for parsed justfile recipes
    just_registry: Arc<just::JustRegistry>,
    /// Pagination cache for just search results
    just_pager: Arc<just::pager::PaginationCache>,
}

impl Default for CodingAgentTools {
    fn default() -> Self {
        Self::new()
    }
}

impl CodingAgentTools {
    pub fn new() -> Self {
        Self::with_config(SubagentsConfig::default(), CliToolsConfig::default())
    }

    pub fn with_config(subagents: SubagentsConfig, cli_tools: CliToolsConfig) -> Self {
        Self {
            subagents,
            cli_tools,
            pager: Arc::new(pagination::PaginationCache::new()),
            just_registry: Arc::new(just::JustRegistry::new()),
            just_pager: Arc::new(just::pager::PaginationCache::new()),
        }
    }
}

// Removed universal-tool-core macros; Tool impls live in tools.rs
impl CodingAgentTools {
    /// List files and directories (gitignore-aware)
    #[expect(
        clippy::unused_async,
        reason = "Must remain async for Tool trait BoxFuture dispatch pattern. \
                  Called via Box::pin(async move { tools.ls(...).await }) in tools.rs."
    )]
    pub async fn ls(
        &self,
        path: Option<String>,
        depth: Option<Depth>,
        show: Option<Show>,
        ignore: Option<Vec<String>>,
        hidden: Option<bool>,
    ) -> Result<LsOutput, ToolError> {
        use std::path::Path;

        // Start logging context
        let log_ctx = logging::ToolLogCtx::start("cli_ls");
        let req_json = serde_json::json!({
            "path": &path,
            "depth": depth.map(types::Depth::as_u8),
            "show": show.map(|s| format!("{s:?}").to_lowercase()),
            "ignore": &ignore,
            "hidden": hidden,
        });

        // Resolve path
        let path_str = path.unwrap_or_else(|| ".".into());
        let abs_root = match paths::to_abs_string(&path_str) {
            Ok(s) => s,
            Err(msg) => {
                log_ctx.finish(req_json, None, false, Some(msg.clone()), None, None, None);
                return Err(ToolError::InvalidInput(msg));
            }
        };
        let root_path = Path::new(&abs_root);

        // Validate root path exists and is a directory
        if !root_path.exists() {
            let error_msg = format!("Path does not exist: {abs_root}");
            log_ctx.finish(
                req_json,
                None,
                false,
                Some(error_msg.clone()),
                None,
                None,
                None,
            );
            return Err(ToolError::InvalidInput(error_msg));
        }

        // Handle file path: return header with warning
        if root_path.is_file() {
            let output = LsOutput {
                root: abs_root,
                entries: vec![],
                has_more: false,
                warnings: vec![
                    "Path is a file, not a directory. Use the 'read' tool to view file contents."
                        .into(),
                ],
            };
            let summary = serde_json::json!({
                "entries": 0,
                "has_more": false,
                "is_file": true,
            });
            log_ctx.finish(req_json, None, true, None, Some(summary), None, None);
            return Ok(output);
        }

        // Configure walker
        let depth_val = depth.map_or(1, types::Depth::as_u8);
        let show_val = show.unwrap_or_default();
        let include_hidden = hidden.unwrap_or(false);

        // Combine user-provided ignore patterns with config's extra_ignore_patterns
        let mut combined_ignores = ignore.unwrap_or_default();
        combined_ignores.extend(self.cli_tools.extra_ignore_patterns.iter().cloned());

        let cfg = walker::WalkConfig {
            root: root_path,
            depth: depth_val,
            show: show_val,
            user_ignores: &combined_ignores,
            include_hidden,
        };

        // Sweep expired cache entries opportunistically
        self.pager.sweep_expired();

        // Determine pagination params
        let page_size = pagination::page_size_for(show_val, depth_val);
        let query_key = pagination::make_key(
            &abs_root,
            depth_val,
            show_val,
            include_hidden,
            &combined_ignores,
        );

        // Acquire per-query lock (level 2), serialize same-param calls
        let qlock = self.pager.get_or_create(&query_key);
        let (entries, has_more, warnings, shown, total) = {
            let mut st = qlock.lock_state();

            // Fill cache if empty or expired
            if st.is_empty() || st.is_expired() {
                match walker::list(&cfg) {
                    Ok(result) => st.reset(result.entries, result.warnings, page_size),
                    Err(e) => {
                        drop(st);
                        log_ctx.finish(
                            req_json,
                            None,
                            false,
                            Some(e.to_string()),
                            None,
                            None,
                            None,
                        );
                        return Err(e);
                    }
                }
            }

            // Compute current page from cached results
            let offset = st.next_offset;
            let (page, has_more) = pagination::paginate_slice(&st.results, offset, st.page_size);

            // Advance offset for next call
            st.next_offset = st.next_offset.saturating_add(st.page_size);

            // Compute counts for truncation message
            let shown = (offset + page.len()).min(st.results.len());
            let total = st.results.len();

            (page, has_more, st.meta.clone(), shown, total)
        };

        // Prepare enhanced truncation info using sentinel
        let mut all_warnings = warnings;
        if has_more {
            let encoded = types::encode_truncation_info(shown, total, page_size);
            all_warnings.insert(0, encoded);
        }

        // If this was the last page, proactively remove cache entry
        if !has_more {
            self.pager.remove_if_same(&query_key, &qlock);
        }

        let output = LsOutput {
            root: abs_root,
            entries,
            has_more,
            warnings: all_warnings,
        };

        // Log success with summary
        let summary = serde_json::json!({
            "entries": output.entries.len(),
            "has_more": output.has_more,
            "shown": shown,
            "total": total,
        });
        log_ctx.finish(req_json, None, true, None, Some(summary), None, None);

        Ok(output)
    }

    /// Spawn an opinionated Claude subagent (locator | analyzer) in a specific location.
    pub async fn ask_agent(
        &self,
        agent_type: Option<types::AgentType>,
        location: Option<types::AgentLocation>,
        query: String,
        ctx: &agentic_tools_core::ToolContext,
    ) -> Result<AgentOutput, ToolError> {
        use claudecode::client::Client;
        use claudecode::config::SessionConfig;
        use claudecode::mcp::validate::ValidateOptions;
        use claudecode::mcp::validate::ensure_valid_mcp_config;
        use claudecode::types::OutputFormat;
        use claudecode::types::PermissionMode;

        // Start logging context
        let log_ctx = logging::ToolLogCtx::start("ask_agent");
        let agent_type = agent_type.unwrap_or_default();
        let location = location.unwrap_or_default();

        let req_json = serde_json::json!({
            "agent_type": format!("{agent_type:?}").to_lowercase(),
            "location": format!("{location:?}").to_lowercase(),
            "query": &query,
            "runtime_timeout_secs": self.subagents.runtime_timeout_secs,
        });

        if query.trim().is_empty() {
            log_ctx.finish(
                req_json,
                None,
                false,
                Some("Query cannot be empty".into()),
                None,
                None,
                None,
            );
            return Err(ToolError::InvalidInput("Query cannot be empty".into()));
        }

        // Compose configuration
        let model = agent::model_for(agent_type, &self.subagents);
        let system_prompt = agent::compose_prompt(agent_type, location);
        let enabled_tools = agent::enabled_tools_for(agent_type, location);

        // Split enabled tools into built-in vs MCP
        let (builtin_tools, _mcp_tools): (Vec<String>, Vec<String>) = enabled_tools
            .iter()
            .cloned()
            .partition(|t| !t.starts_with("mcp__"));

        // Build MCP config with --allow flag for tool filtering
        let mcp_config = agent::build_mcp_config(location, &enabled_tools);

        // Validate MCP servers before launching (spawn, handshake, tools/list)
        let opts = ValidateOptions::default();
        if let Err(e) = ensure_valid_mcp_config(&mcp_config, &opts).await {
            use std::fmt::Write;
            let mut details = String::new();
            for (name, err) in &e.errors {
                let _ = writeln!(details, "  {name}: {err}");
            }
            let error_msg =
                format!("ask_agent unavailable: MCP config validation failed.\n{details}");
            log_ctx.finish(
                req_json,
                None,
                false,
                Some(error_msg.clone()),
                None,
                Some(model.to_string()),
                None,
            );
            return Err(ToolError::Internal(error_msg));
        }

        // Build session config
        let builder = SessionConfig::builder(query)
            .model(model)
            .output_format(OutputFormat::Text)
            .permission_mode(PermissionMode::DontAsk)
            .system_prompt(system_prompt)
            .tools(builtin_tools) // controls built-in tools in schema
            .allowed_tools(enabled_tools.clone()) // auto-approve enabled tools (built-in + MCP)
            .mcp_config(mcp_config)
            .strict_mcp_config(true); // prevent inheritance of global MCP tools

        let config = match builder.build() {
            Ok(c) => c,
            Err(e) => {
                let error_msg = format!("Failed to build session config: {e}");
                log_ctx.finish(
                    req_json,
                    None,
                    false,
                    Some(error_msg.clone()),
                    None,
                    Some(model.to_string()),
                    None,
                );
                return Err(ToolError::Internal(error_msg));
            }
        };

        // Ensure 'claude' binary exists
        let client = match Client::new().await {
            Ok(c) => c,
            Err(e) => {
                let error_msg = format!(
                    "Claude CLI not found or not runnable: {e}. Ensure 'claude' is installed and available in PATH."
                );
                log_ctx.finish(
                    req_json,
                    None,
                    false,
                    Some(error_msg.clone()),
                    None,
                    Some(model.to_string()),
                    None,
                );
                return Err(ToolError::Internal(error_msg));
            }
        };

        let session = match client.launch(config).await {
            Ok(session) => session,
            Err(e) => {
                let error_msg = format!("Failed to start Claude session: {e}");
                log_ctx.finish(
                    req_json,
                    None,
                    false,
                    Some(error_msg.clone()),
                    None,
                    Some(model.to_string()),
                    None,
                );
                return Err(ToolError::Internal(error_msg));
            }
        };

        let result = match wait_for_claude_result_with_timeout(
            ctx,
            session.wait(),
            || session.cancel(),
            self.subagents.runtime_timeout_secs,
        )
        .await
        {
            Ok(result) => result,
            Err(ToolError::Cancelled { .. }) => {
                log_ctx.finish(
                    req_json,
                    None,
                    false,
                    Some("Request cancelled".into()),
                    None,
                    Some(model.to_string()),
                    None,
                );
                return Err(ToolError::cancelled(None));
            }
            Err(e) => {
                log_ctx.finish(
                    req_json,
                    None,
                    false,
                    Some(e.to_string()),
                    None,
                    Some(model.to_string()),
                    None,
                );
                return Err(e);
            }
        };

        if result.is_error {
            let error_msg = result
                .error
                .unwrap_or_else(|| "Claude session returned an error".into());
            log_ctx.finish(
                req_json,
                None,
                false,
                Some(error_msg.clone()),
                None,
                Some(model.to_string()),
                None,
            );
            return Err(ToolError::Internal(error_msg));
        }

        // Return plain text output (reject empty/whitespace-only strings)
        if let Some(text) = pick_non_empty_text(&result) {
            // Write markdown response file and capture timestamp for consistent logging
            let (response_file, completed_at) = log_ctx
                .write_markdown_response(&text)
                .map_or((None, None), |(f, ts)| (Some(f), Some(ts)));
            log_ctx.finish(
                req_json,
                response_file,
                true,
                None,
                None,
                Some(model.to_string()),
                completed_at,
            );
            return Ok(AgentOutput::new(text));
        }

        let error_msg = "Claude session produced no text output (empty or whitespace-only)";
        log_ctx.finish(
            req_json,
            None,
            false,
            Some(error_msg.into()),
            None,
            Some(model.to_string()),
            None,
        );
        Err(ToolError::Internal(error_msg.to_string()))
    }

    /// Search the codebase using a regex pattern (gitignore-aware).
    #[expect(
        clippy::unused_async,
        reason = "Must remain async for Tool trait BoxFuture dispatch pattern."
    )]
    pub async fn search_grep(
        &self,
        pattern: String,
        path: Option<String>,
        mode: Option<OutputMode>,
        globs: Option<Vec<String>>,
        ignore: Option<Vec<String>>,
        include_hidden: Option<bool>,
        case_insensitive: Option<bool>,
        multiline: Option<bool>,
        line_numbers: Option<bool>,
        context: Option<u32>,
        context_before: Option<u32>,
        context_after: Option<u32>,
        include_binary: Option<bool>,
        head_limit: Option<usize>,
        offset: Option<usize>,
    ) -> Result<GrepOutput, ToolError> {
        // Start logging context
        let log_ctx = logging::ToolLogCtx::start("cli_grep");
        let req_json = serde_json::json!({
            "pattern": &pattern,
            "path": &path,
            "mode": mode.map(|m| format!("{m:?}").to_lowercase()),
            "globs": &globs,
            "ignore": &ignore,
            "include_hidden": include_hidden,
            "case_insensitive": case_insensitive,
            "multiline": multiline,
            "line_numbers": line_numbers,
            "context": context,
            "context_before": context_before,
            "context_after": context_after,
            "include_binary": include_binary,
            "head_limit": head_limit,
            "offset": offset,
        });

        let path_str = path.unwrap_or_else(|| ".".into());
        let abs_root = match paths::to_abs_string(&path_str) {
            Ok(s) => s,
            Err(msg) => {
                log_ctx.finish(req_json, None, false, Some(msg.clone()), None, None, None);
                return Err(ToolError::InvalidInput(msg));
            }
        };
        // Combine user-provided ignore patterns with config's extra_ignore_patterns
        let mut combined_ignores = ignore.unwrap_or_default();
        combined_ignores.extend(self.cli_tools.extra_ignore_patterns.iter().cloned());

        let cfg = grep::GrepConfig {
            root: abs_root,
            pattern,
            mode: mode.unwrap_or_default(),
            include_globs: globs.unwrap_or_default(),
            ignore_globs: combined_ignores,
            include_hidden: include_hidden.unwrap_or(false),
            case_insensitive: case_insensitive.unwrap_or(false),
            multiline: multiline.unwrap_or(false),
            line_numbers: line_numbers.unwrap_or(true),
            context,
            context_before,
            context_after,
            include_binary: include_binary.unwrap_or(false),
            head_limit: head_limit.unwrap_or(self.cli_tools.grep_default_limit as usize),
            offset: offset.unwrap_or(0),
        };

        match grep::run(cfg) {
            Ok(output) => {
                let summary = serde_json::json!({
                    "lines": output.lines.len(),
                    "mode": format!("{:?}", output.mode).to_lowercase(),
                    "has_more": output.has_more,
                });
                log_ctx.finish(req_json, None, true, None, Some(summary), None, None);
                Ok(output)
            }
            Err(e) => {
                log_ctx.finish(req_json, None, false, Some(e.to_string()), None, None, None);
                Err(e)
            }
        }
    }

    /// Match files/directories by glob pattern (gitignore-aware).
    #[expect(
        clippy::unused_async,
        reason = "Must remain async for Tool trait BoxFuture dispatch pattern."
    )]
    pub async fn search_glob(
        &self,
        pattern: String,
        path: Option<String>,
        ignore: Option<Vec<String>>,
        include_hidden: Option<bool>,
        sort: Option<SortOrder>,
        head_limit: Option<usize>,
        offset: Option<usize>,
    ) -> Result<GlobOutput, ToolError> {
        // Start logging context
        let log_ctx = logging::ToolLogCtx::start("cli_glob");
        let req_json = serde_json::json!({
            "pattern": &pattern,
            "path": &path,
            "ignore": &ignore,
            "include_hidden": include_hidden,
            "sort": sort.map(|s| format!("{s:?}").to_lowercase()),
            "head_limit": head_limit,
            "offset": offset,
        });

        let path_str = path.unwrap_or_else(|| ".".into());
        let abs_root = match paths::to_abs_string(&path_str) {
            Ok(s) => s,
            Err(msg) => {
                log_ctx.finish(req_json, None, false, Some(msg.clone()), None, None, None);
                return Err(ToolError::InvalidInput(msg));
            }
        };
        // Combine user-provided ignore patterns with config's extra_ignore_patterns
        let mut combined_ignores = ignore.unwrap_or_default();
        combined_ignores.extend(self.cli_tools.extra_ignore_patterns.iter().cloned());

        let cfg = glob::GlobConfig {
            root: abs_root,
            pattern,
            ignore_globs: combined_ignores,
            include_hidden: include_hidden.unwrap_or(false),
            sort: sort.unwrap_or_default(),
            head_limit: head_limit.unwrap_or(self.cli_tools.glob_default_limit as usize),
            offset: offset.unwrap_or(0),
        };

        match glob::run(cfg) {
            Ok(output) => {
                let summary = serde_json::json!({
                    "entries": output.entries.len(),
                    "has_more": output.has_more,
                });
                log_ctx.finish(req_json, None, true, None, Some(summary), None, None);
                Ok(output)
            }
            Err(e) => {
                log_ctx.finish(req_json, None, false, Some(e.to_string()), None, None, None);
                Err(e)
            }
        }
    }

    /// Search justfile recipes by name or docs.
    pub async fn just_search(
        &self,
        query: Option<String>,
        dir: Option<String>,
        ctx: &agentic_tools_core::ToolContext,
    ) -> Result<just::SearchOutput, ToolError> {
        // Start logging context
        let log_ctx = logging::ToolLogCtx::start("cli_just_search");
        let req_json = serde_json::json!({
            "query": &query,
            "dir": &dir,
            "timeout_secs": self.cli_tools.just_search_timeout_secs,
        });

        if let Err(e) = wait_for_just_result_with_timeout(
            ctx,
            just::ensure_just_available(),
            self.cli_tools.just_search_timeout_secs,
        )
        .await
        {
            log_ctx.finish(req_json, None, false, Some(e.to_string()), None, None, None);
            return Err(e);
        }

        let repo_root = match paths::to_abs_string(".") {
            Ok(r) => r,
            Err(e) => {
                log_ctx.finish(req_json, None, false, Some(e.clone()), None, None, None);
                return Err(ToolError::Internal(e));
            }
        };
        let q = query.unwrap_or_default();
        let dir_filter = match dir.as_ref().map(|d| paths::to_abs_string(d)).transpose() {
            Ok(f) => f,
            Err(e) => {
                log_ctx.finish(req_json, None, false, Some(e.clone()), None, None, None);
                return Err(ToolError::Internal(e));
            }
        };

        self.just_pager.sweep_expired();
        let key = just::pager::make_key(dir_filter.as_deref().unwrap_or(&repo_root), &q);
        let qlock = self.just_pager.get_or_create(&key);

        // Check if we need to refresh - do this without holding lock across await
        let needs_refresh = {
            let st = qlock.lock_state();
            st.is_empty() || st.is_expired()
        };

        // Fetch recipes if needed (outside lock)
        if needs_refresh {
            let all = match wait_for_just_result_with_timeout(
                ctx,
                self.just_registry.get_all_recipes(&repo_root),
                self.cli_tools.just_search_timeout_secs,
            )
            .await
            {
                Ok(r) => r,
                Err(e) => {
                    log_ctx.finish(req_json, None, false, Some(e.to_string()), None, None, None);
                    return Err(e);
                }
            };

            let filtered: Vec<_> = all
                .into_iter()
                .filter(|(recipe_dir, r)| {
                    let dir_ok = dir_filter
                        .as_ref()
                        .is_none_or(|f| recipe_dir.starts_with(f));
                    let visible = !r.is_private && !r.is_mcp_hidden;
                    let q_ok = q.is_empty()
                        || r.name
                            .to_ascii_lowercase()
                            .contains(&q.to_ascii_lowercase())
                        || r.doc.as_ref().is_some_and(|d| {
                            d.to_ascii_lowercase().contains(&q.to_ascii_lowercase())
                        });
                    dir_ok && visible && q_ok
                })
                .map(|(d, r)| {
                    let params = r
                        .params
                        .iter()
                        .map(|p| {
                            if p.kind == just::parser::ParamKind::Star {
                                format!("{}*", p.name)
                            } else if p.has_default {
                                format!("{}?", p.name)
                            } else {
                                p.name.clone()
                            }
                        })
                        .collect();
                    just::SearchItem {
                        recipe: r.name,
                        dir: d,
                        doc: r.doc,
                        params,
                    }
                })
                .collect();

            // Reacquire lock to update state
            let mut st = qlock.lock_state();
            st.reset(filtered);
        }

        // Paginate (separate lock acquisition)
        let (items, has_more) = {
            let mut st = qlock.lock_state();
            let offset = st.next_offset;
            let end = (offset + just::pager::PAGE_SIZE).min(st.results.len());
            let page = st.results[offset..end].to_vec();
            st.next_offset = end;
            let has_more = end < st.results.len();
            (page, has_more)
        };

        if !has_more {
            self.just_pager.remove_if_same(&key, &qlock);
        }

        let output = just::SearchOutput { items, has_more };

        // Log success with summary
        let summary = serde_json::json!({
            "items": output.items.len(),
            "has_more": output.has_more,
        });
        log_ctx.finish(req_json, None, true, None, Some(summary), None, None);

        Ok(output)
    }

    /// Execute a just recipe.
    pub async fn just_execute(
        &self,
        recipe: String,
        dir: Option<String>,
        args: Option<std::collections::HashMap<String, serde_json::Value>>,
        ctx: &agentic_tools_core::ToolContext,
    ) -> Result<just::ExecuteOutput, ToolError> {
        // Start logging context
        let log_ctx = logging::ToolLogCtx::start("cli_just_execute");
        let req_json = serde_json::json!({
            "recipe": &recipe,
            "dir": &dir,
            "args": &args,
            "timeout_secs": self.cli_tools.just_execute_timeout_secs,
        });

        if let Err(e) = just::ensure_just_available().await {
            let error_msg = e;
            log_ctx.finish(
                req_json,
                None,
                false,
                Some(error_msg.clone()),
                None,
                None,
                None,
            );
            return Err(ToolError::Internal(error_msg));
        }

        let repo_root = match paths::to_abs_string(".") {
            Ok(r) => r,
            Err(e) => {
                log_ctx.finish(req_json, None, false, Some(e.clone()), None, None, None);
                return Err(ToolError::Internal(e));
            }
        };

        match just::exec::execute_recipe(
            &self.just_registry,
            &recipe,
            dir,
            args,
            &repo_root,
            self.cli_tools.just_execute_timeout_secs,
            ctx,
        )
        .await
        {
            Ok(output) => {
                let summary = serde_json::json!({
                    "exit_code": output.exit_code,
                    "stdout_lines": output.stdout.lines().count(),
                    "stderr_lines": output.stderr.lines().count(),
                });
                log_ctx.finish(req_json, None, true, None, Some(summary), None, None);
                Ok(output)
            }
            Err(e) => {
                if ctx.is_cancelled() {
                    log_ctx.finish(req_json, None, false, Some(e), None, None, None);
                    return Err(ToolError::cancelled(None));
                }

                let error_msg = e;
                log_ctx.finish(
                    req_json,
                    None,
                    false,
                    Some(error_msg.clone()),
                    None,
                    None,
                    None,
                );
                Err(ToolError::Internal(error_msg))
            }
        }
    }
}

#[cfg(test)]
mod ask_agent_filter_tests {
    use super::*;
    use claudecode::types::Result as ClaudeResult;
    use serial_test::serial;
    use std::fs;
    use std::path::Path;
    use std::path::PathBuf;
    use tempfile::TempDir;

    struct DirGuard {
        prev: PathBuf,
    }

    impl DirGuard {
        fn set(to: &Path) -> Self {
            let prev =
                std::env::current_dir().unwrap_or_else(|e| panic!("current_dir failed: {e}"));
            std::env::set_current_dir(to)
                .unwrap_or_else(|e| panic!("set_current_dir({}) failed: {e}", to.display()));
            Self { prev }
        }
    }

    impl Drop for DirGuard {
        fn drop(&mut self) {
            std::env::set_current_dir(&self.prev)
                .unwrap_or_else(|e| panic!("restore cwd failed: {e}"));
        }
    }

    #[test]
    fn prefers_content_when_result_is_empty_string() {
        let r = ClaudeResult {
            result: Some(String::new()),
            content: Some("ok".into()),
            ..Default::default()
        };
        assert_eq!(pick_non_empty_text(&r).as_deref(), Some("ok"));
    }

    #[test]
    fn returns_none_when_both_empty_or_whitespace() {
        let r1 = ClaudeResult {
            result: None,
            content: Some(String::new()),
            ..Default::default()
        };
        assert_eq!(pick_non_empty_text(&r1), None);

        let r2 = ClaudeResult {
            result: None,
            content: Some("   ".into()),
            ..Default::default()
        };
        assert_eq!(pick_non_empty_text(&r2), None);

        let r3 = ClaudeResult {
            result: Some("   ".into()),
            content: None,
            ..Default::default()
        };
        assert_eq!(pick_non_empty_text(&r3), None);
    }

    #[test]
    fn returns_text_when_present_in_content() {
        let r = ClaudeResult {
            result: None,
            content: Some("text".into()),
            ..Default::default()
        };
        assert_eq!(pick_non_empty_text(&r).as_deref(), Some("text"));
    }

    #[test]
    fn respects_precedence_of_result_over_content() {
        let r = ClaudeResult {
            result: Some("  result text  ".into()),
            content: Some("other".into()),
            ..Default::default()
        };
        // Helper uses trim().is_empty() for emptiness check, but returns original string
        assert_eq!(pick_non_empty_text(&r).as_deref(), Some("  result text  "));
    }

    #[tokio::test]
    async fn wait_for_claude_result_runs_cancel_branch() {
        let ctx = agentic_tools_core::ToolContext::default();
        let cancel = ctx.cancellation_token();
        let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
        let cancelled_flag = Arc::clone(&cancelled);

        let task = tokio::spawn(async move {
            wait_for_claude_result(
                &ctx,
                std::future::pending::<claudecode::Result<ClaudeResult>>(),
                move || {
                    let cancelled_flag = Arc::clone(&cancelled_flag);
                    async move {
                        cancelled_flag.store(true, std::sync::atomic::Ordering::SeqCst);
                        Ok(())
                    }
                },
            )
            .await
        });

        tokio::task::yield_now().await;
        cancel.cancel();

        let result = match task.await {
            Ok(result) => result,
            Err(err) => panic!("task join failed: {err}"),
        };
        assert!(matches!(result, Err(ToolError::Cancelled { .. })));
        assert!(cancelled.load(std::sync::atomic::Ordering::SeqCst));
    }

    #[tokio::test]
    async fn wait_for_claude_result_preserves_cancelled_when_cleanup_fails() {
        let ctx = agentic_tools_core::ToolContext::default();
        let cancel = ctx.cancellation_token();

        let task = tokio::spawn(async move {
            wait_for_claude_result(
                &ctx,
                std::future::pending::<claudecode::Result<ClaudeResult>>(),
                || async move {
                    Err(claudecode::ClaudeError::IoError {
                        source: std::io::Error::other("cleanup failed during cancellation"),
                    })
                },
            )
            .await
        });

        tokio::task::yield_now().await;
        cancel.cancel();

        let result = match task.await {
            Ok(result) => result,
            Err(err) => panic!("task join failed: {err}"),
        };
        assert!(matches!(result, Err(ToolError::Cancelled { .. })));
    }

    #[tokio::test(start_paused = true)]
    async fn wait_for_claude_result_with_timeout_cancels_after_deadline() {
        let ctx = agentic_tools_core::ToolContext::default();
        let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
        let cancelled_flag = Arc::clone(&cancelled);

        let handle = tokio::spawn(async move {
            wait_for_claude_result_with_timeout(
                &ctx,
                std::future::pending::<claudecode::Result<ClaudeResult>>(),
                move || {
                    let cancelled_flag = Arc::clone(&cancelled_flag);
                    async move {
                        cancelled_flag.store(true, std::sync::atomic::Ordering::SeqCst);
                        Ok(())
                    }
                },
                1,
            )
            .await
        });

        tokio::task::yield_now().await;
        tokio::time::advance(Duration::from_secs(1)).await;

        let result = match handle.await {
            Ok(result) => result,
            Err(err) => panic!("task join failed: {err}"),
        };
        let Err(err) = result else {
            panic!("timeout expected");
        };
        assert!(err.to_string().contains("ask_agent timed out after 1s"));
        assert!(cancelled.load(std::sync::atomic::Ordering::SeqCst));
    }

    #[tokio::test(start_paused = true)]
    async fn wait_for_claude_result_with_timeout_bounds_cleanup_duration() {
        let ctx = agentic_tools_core::ToolContext::default();

        let handle = tokio::spawn(async move {
            wait_for_claude_result_with_timeout(
                &ctx,
                std::future::pending::<claudecode::Result<ClaudeResult>>(),
                || async { std::future::pending::<claudecode::Result<()>>().await },
                1,
            )
            .await
        });

        tokio::task::yield_now().await;
        tokio::time::advance(Duration::from_secs(1)).await;
        tokio::task::yield_now().await;
        assert!(
            !handle.is_finished(),
            "cleanup timeout should not resolve before cleanup budget elapses"
        );

        tokio::time::advance(Duration::from_secs(ASK_AGENT_TIMEOUT_CLEANUP_TIMEOUT_SECS)).await;

        let result = match handle.await {
            Ok(result) => result,
            Err(err) => panic!("task join failed: {err}"),
        };
        let Err(err) = result else {
            panic!("timeout expected");
        };
        assert!(err.to_string().contains("ask_agent timed out after 1s"));
    }

    #[tokio::test(start_paused = true)]
    async fn wait_for_claude_result_with_timeout_zero_disables_deadline() {
        let ctx = agentic_tools_core::ToolContext::default();

        let handle = tokio::spawn(async move {
            wait_for_claude_result_with_timeout(
                &ctx,
                async {
                    tokio::time::sleep(Duration::from_secs(5)).await;
                    Ok(ClaudeResult {
                        result: Some("ok".into()),
                        ..Default::default()
                    })
                },
                || async { Ok(()) },
                0,
            )
            .await
        });

        tokio::task::yield_now().await;
        assert!(
            !handle.is_finished(),
            "timeout=0 should not time out immediately"
        );

        tokio::time::advance(Duration::from_secs(5)).await;
        let result = match handle.await {
            Ok(result) => result,
            Err(err) => panic!("task join failed: {err}"),
        };
        assert!(result.is_ok());
    }

    #[tokio::test(start_paused = true)]
    async fn wait_for_just_result_with_timeout_returns_timeout() {
        let ctx = agentic_tools_core::ToolContext::default();

        let handle = tokio::spawn(async move {
            wait_for_just_result_with_timeout(&ctx, std::future::pending::<Result<(), String>>(), 1)
                .await
        });

        tokio::task::yield_now().await;
        tokio::time::advance(Duration::from_secs(1)).await;

        let result = match handle.await {
            Ok(result) => result,
            Err(err) => panic!("task join failed: {err}"),
        };
        let Err(err) = result else {
            panic!("timeout expected");
        };
        assert!(
            matches!(err, ToolError::Internal(message) if message == "Just search timed out after 1s")
        );
    }

    #[tokio::test]
    async fn wait_for_just_result_with_timeout_returns_structured_cancelled() {
        let ctx = agentic_tools_core::ToolContext::default();
        let cancel = ctx.cancellation_token();

        let handle = tokio::spawn(async move {
            wait_for_just_result_with_timeout(&ctx, std::future::pending::<Result<(), String>>(), 1)
                .await
        });

        tokio::task::yield_now().await;
        cancel.cancel();

        let result = match handle.await {
            Ok(result) => result,
            Err(err) => panic!("task join failed: {err}"),
        };
        assert!(matches!(result, Err(ToolError::Cancelled { .. })));
    }

    #[tokio::test(start_paused = true)]
    async fn wait_for_just_result_with_timeout_zero_disables_deadline() {
        let ctx = agentic_tools_core::ToolContext::default();

        let handle = tokio::spawn(async move {
            wait_for_just_result_with_timeout(
                &ctx,
                async {
                    tokio::time::sleep(Duration::from_secs(5)).await;
                    Ok(())
                },
                0,
            )
            .await
        });

        tokio::task::yield_now().await;
        assert!(
            !handle.is_finished(),
            "timeout=0 should not time out immediately"
        );

        tokio::time::advance(Duration::from_secs(5)).await;
        let result = match handle.await {
            Ok(result) => result,
            Err(err) => panic!("task join failed: {err}"),
        };
        assert!(result.is_ok());
    }

    #[tokio::test]
    #[serial(env)]
    async fn just_execute_returns_structured_cancelled_without_string_match() {
        if tokio::process::Command::new("just")
            .arg("--version")
            .output()
            .await
            .is_err()
        {
            eprintln!("Skipping test: just not installed");
            return;
        }

        let tmp = TempDir::new().unwrap_or_else(|e| panic!("TempDir::new failed: {e}"));
        fs::write(tmp.path().join("justfile"), "hang:\n    sleep 30")
            .unwrap_or_else(|e| panic!("writing justfile failed: {e}"));
        let _dir = DirGuard::set(tmp.path());

        let tools = CodingAgentTools::new();
        let ctx = agentic_tools_core::ToolContext::default();
        let cancel = ctx.cancellation_token();

        let handle =
            tokio::spawn(async move { tools.just_execute("hang".into(), None, None, &ctx).await });

        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        cancel.cancel();

        let result = match handle.await {
            Ok(result) => result,
            Err(err) => panic!("task join failed: {err}"),
        };
        assert!(matches!(result, Err(ToolError::Cancelled { .. })));
    }
}
