use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::fs::OpenOptions;
use std::future::Future;
use std::hash::{Hash, Hasher};
use std::io::{Read, Seek, SeekFrom};
use std::path::{Component, Path, PathBuf};
use std::pin::Pin;
use std::process::Stdio;
use std::time::SystemTime;

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use tokio::task::JoinHandle;
use tokio::time::{timeout, Duration};
use walkdir::WalkDir;

use crate::commands::external_search::file_url_for_path;
use crate::commands::search::{self, SearchEmbeddingConfig};

use super::types::{AgentKnowledgeContext, AgentReference, AgentVersionSummary};
use super::workspace::{agent_workspace_path, AGENT_WORKSPACE_DIR};

// Tool I/O limits are backend security boundaries. Do not relax them only in
// the UI: API and MCP callers can invoke the same tools without going through
// React components.
const MAX_READ_PAGE_BYTES: usize = 2 * 1024 * 1024;
const MAX_WRITE_PAGE_BYTES: usize = 2 * 1024 * 1024;
const MAX_WORKSPACE_WRITE_BYTES: usize = 2 * 1024 * 1024;
// Rollback snapshots are sent to the trusted desktop UI only for the current
// process lifetime. Bound them independently from write size so a large Agent
// artifact cannot multiply IPC and in-memory chat costs merely to enable Undo.
const MAX_WORKSPACE_ROLLBACK_BYTES: u64 = 512 * 1024;
const MAX_SOURCE_SEARCH_FILES: usize = 10_000;
const MAX_SOURCE_SNIPPET_CHARS: usize = 500;
const MAX_GRAPH_SEARCH_FILES: usize = 10_000;
const MAX_KNOWLEDGE_CONTEXT_ITEMS: usize = 20;
const WEB_SEARCH_TIMEOUT_SECS: u64 = 30;
const SHELL_EXEC_TIMEOUT_SECS: u64 = 30;
const MAX_SHELL_COMMAND_CHARS: usize = 4_000;
const MAX_SHELL_OUTPUT_CHARS: usize = 20_000;
const MAX_SHELL_GENERATED_FILES: usize = 50;
const SHELL_OUTPUT_DRAIN_TIMEOUT_SECS: u64 = 1;
const DEFAULT_ANYTXT_ENDPOINT: &str = "http://127.0.0.1:9920";
const DEFAULT_ANYTXT_LIMIT: usize = 20;
const ANYTXT_LAST_MODIFY_END: i64 = 2_147_483_647;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ToolEffect {
    Read,
    Write,
    Network,
    Process,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ToolSpec {
    pub name: String,
    pub description: String,
    pub effects: Vec<ToolEffect>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub parameters: Option<serde_json::Value>,
}

#[allow(dead_code)]
pub trait AgentTool: Send + Sync {
    fn spec(&self) -> ToolSpec;
    fn execute<'a>(
        &'a self,
        input: Value,
    ) -> Pin<Box<dyn Future<Output = Result<Value, String>> + Send + 'a>>;
}

pub trait ToolRegistry {
    #[allow(dead_code)]
    fn specs(&self) -> Vec<ToolSpec>;
    fn execute<'a>(
        &'a self,
        name: &'a str,
        input: Value,
        context: ToolContext<'a>,
    ) -> Pin<Box<dyn Future<Output = Result<Value, String>> + Send + 'a>>;
}

#[derive(Debug, Clone, Default)]
pub struct BuiltinToolRegistry;

#[derive(Clone)]
pub struct ToolContext<'a> {
    pub project_path: &'a str,
    pub embedding_config: Option<SearchEmbeddingConfig>,
    pub web_search_config: Option<WebSearchConfig>,
    pub anytxt_config: Option<AnyTxtConfig>,
}

impl ToolRegistry for BuiltinToolRegistry {
    fn specs(&self) -> Vec<ToolSpec> {
        builtin_tool_specs()
    }

    fn execute<'a>(
        &'a self,
        name: &'a str,
        input: Value,
        context: ToolContext<'a>,
    ) -> Pin<Box<dyn Future<Output = Result<Value, String>> + Send + 'a>> {
        Box::pin(async move {
            match name {
                "wiki.write_page" => {
                    let path = input
                        .get("path")
                        .and_then(Value::as_str)
                        .ok_or_else(|| "wiki.write_page requires path".to_string())?;
                    let content = input
                        .get("content")
                        .and_then(Value::as_str)
                        .ok_or_else(|| "wiki.write_page requires content".to_string())?;
                    let allow_overwrite = input
                        .get("allowOverwrite")
                        .or_else(|| input.get("allow_overwrite"))
                        .and_then(Value::as_bool)
                        .unwrap_or(false);
                    serde_json::to_value(write_wiki_page_with_activity(
                        context.project_path,
                        path,
                        content,
                        allow_overwrite,
                    )?)
                    .map_err(|err| format!("Failed to serialize wiki.write_page result: {err}"))
                }
                "wiki.search" => {
                    let query = tool_query(&input, "wiki.search")?;
                    let top_k = tool_top_k(&input);
                    let include_content = input
                        .get("includeContent")
                        .and_then(Value::as_bool)
                        .unwrap_or(false);
                    serde_json::to_value(
                        run_wiki_search(
                            context.project_path.to_string(),
                            query,
                            top_k,
                            include_content,
                            context.embedding_config,
                        )
                        .await?,
                    )
                    .map_err(|err| format!("Failed to serialize wiki.search result: {err}"))
                }
                "wiki.read_page" => {
                    let path = input
                        .get("path")
                        .and_then(Value::as_str)
                        .map(str::trim)
                        .filter(|path| !path.is_empty())
                        .ok_or_else(|| "wiki.read_page requires path".to_string())?;
                    let content = read_wiki_page(context.project_path, path)?;
                    let normalized_path = normalize_rel_path(path);
                    let mut knowledge_context = build_knowledge_context_index(context.project_path)
                        .remove(&normalized_path);
                    attach_latest_version(
                        context.project_path,
                        &normalized_path,
                        &mut knowledge_context,
                    );
                    serde_json::to_value(json!({
                        "path": path,
                        "content": content,
                        "knowledgeContext": knowledge_context,
                    }))
                    .map_err(|err| format!("Failed to serialize wiki.read_page result: {err}"))
                }
                "workspace.write_file" => {
                    let path = input
                        .get("path")
                        .and_then(Value::as_str)
                        .ok_or_else(|| "workspace.write_file requires path".to_string())?;
                    let content = input
                        .get("content")
                        .and_then(Value::as_str)
                        .ok_or_else(|| "workspace.write_file requires content".to_string())?;
                    serde_json::to_value(write_workspace_file(context.project_path, path, content)?)
                        .map_err(|err| {
                            format!("Failed to serialize workspace.write_file result: {err}")
                        })
                }
                "workspace.append_file" => {
                    let path = input
                        .get("path")
                        .and_then(Value::as_str)
                        .ok_or_else(|| "workspace.append_file requires path".to_string())?;
                    let content = input
                        .get("content")
                        .and_then(Value::as_str)
                        .ok_or_else(|| "workspace.append_file requires content".to_string())?;
                    serde_json::to_value(append_workspace_file(
                        context.project_path,
                        path,
                        content,
                    )?)
                    .map_err(|err| {
                        format!("Failed to serialize workspace.append_file result: {err}")
                    })
                }
                "source.search" => {
                    let query = tool_query(&input, "source.search")?.to_string();
                    let project_path = context.project_path.to_string();
                    let top_k = tool_top_k(&input);
                    // `search_sources` walks the filesystem synchronously.
                    // Keep it off Tokio worker threads so a large source tree
                    // cannot stall unrelated Agent/API work.
                    let references = tokio::task::spawn_blocking(move || {
                        search_sources(&project_path, &query, top_k)
                    })
                    .await
                    .map_err(|err| format!("source.search worker failed: {err}"))??;
                    serde_json::to_value(references)
                        .map_err(|err| format!("Failed to serialize source.search result: {err}"))
                }
                "graph.search" => {
                    let query = tool_query(&input, "graph.search")?.to_string();
                    let project_path = context.project_path.to_string();
                    let top_k = tool_top_k(&input);
                    // Graph search also performs synchronous markdown walks.
                    // Run it in the blocking pool for the same reason as
                    // `source.search`.
                    let references = tokio::task::spawn_blocking(move || {
                        search_graph(&project_path, &query, top_k)
                    })
                    .await
                    .map_err(|err| format!("graph.search worker failed: {err}"))??;
                    serde_json::to_value(references)
                        .map_err(|err| format!("Failed to serialize graph.search result: {err}"))
                }
                "web.search" => {
                    let query = tool_query(&input, "web.search")?;
                    serde_json::to_value(
                        run_web_search(query, context.web_search_config, tool_top_k(&input))
                            .await?,
                    )
                    .map_err(|err| format!("Failed to serialize web.search result: {err}"))
                }
                "anytxt.search" => {
                    let query = tool_query(&input, "anytxt.search")?;
                    serde_json::to_value(
                        run_anytxt_search(query, context.anytxt_config, tool_top_k(&input)).await?,
                    )
                    .map_err(|err| format!("Failed to serialize anytxt.search result: {err}"))
                }
                "deep_research.run" => {
                    let query = tool_query(&input, "deep_research.run")?;
                    serde_json::to_value(json!({
                        "query": query,
                        "status": "orchestrated_by_agent_runtime",
                    }))
                    .map_err(|err| format!("Failed to serialize deep_research.run result: {err}"))
                }
                "shell.exec" => {
                    let command = input
                        .get("command")
                        .or_else(|| input.get("query"))
                        .and_then(Value::as_str)
                        .ok_or_else(|| "shell.exec requires command".to_string())?;
                    let timeout_secs = input
                        .get("timeoutSeconds")
                        .or_else(|| input.get("timeout_seconds"))
                        .and_then(Value::as_u64)
                        .unwrap_or(SHELL_EXEC_TIMEOUT_SECS)
                        .clamp(1, SHELL_EXEC_TIMEOUT_SECS);
                    serde_json::to_value(
                        run_shell_exec(context.project_path, command, timeout_secs).await?,
                    )
                    .map_err(|err| format!("Failed to serialize shell.exec result: {err}"))
                }
                other => Err(format!("Unknown Agent tool: {other}")),
            }
        })
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WikiSearchToolOutput {
    pub mode: String,
    pub token_hits: usize,
    pub vector_hits: usize,
    pub graph_hits: usize,
    pub references: Vec<AgentReference>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ShellExecToolOutput {
    pub command: String,
    pub exit_code: Option<i32>,
    pub stdout: String,
    pub stderr: String,
    pub timed_out: bool,
    #[serde(default)]
    pub generated_files: Vec<WorkspaceWriteOutput>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceWriteOutput {
    pub path: String,
    pub bytes: usize,
    #[serde(default)]
    pub existed_before: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub previous_content: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WikiWriteOutput {
    #[serde(flatten)]
    pub reference: AgentReference,
    #[serde(default)]
    pub existed_before: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub previous_content: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct WebSearchConfig {
    pub provider: String,
    #[serde(default)]
    pub api_key: String,
    #[serde(default)]
    pub ollama_url: Option<String>,
    #[serde(default)]
    pub sear_xng_url: Option<String>,
    #[serde(default)]
    pub sear_xng_categories: Option<Vec<String>>,
    #[serde(default)]
    pub serp_api_engine: Option<String>,
    #[serde(default)]
    pub provider_configs: Option<BTreeMap<String, WebSearchProviderOverride>>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct WebSearchProviderOverride {
    #[serde(default)]
    pub api_key: Option<String>,
    #[serde(default)]
    pub base_url: Option<String>,
    #[serde(default)]
    pub ollama_url: Option<String>,
    #[serde(default)]
    pub sear_xng_url: Option<String>,
    #[serde(default)]
    pub sear_xng_categories: Option<Vec<String>>,
    #[serde(default)]
    pub serp_api_engine: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AnyTxtConfig {
    #[serde(default)]
    pub enabled: Option<bool>,
    #[serde(default)]
    pub endpoint: Option<String>,
    #[serde(default)]
    pub filter_dir: Option<String>,
    #[serde(default)]
    pub filter_ext: Option<String>,
    #[serde(default)]
    pub limit: Option<usize>,
}

impl WebSearchConfig {
    fn resolved(&self) -> Self {
        let provider = self.provider.trim().to_ascii_lowercase();
        let Some(override_cfg) = self
            .provider_configs
            .as_ref()
            .and_then(|configs| configs.get(&provider))
        else {
            return self.clone();
        };
        Self {
            provider: self.provider.clone(),
            api_key: override_cfg
                .api_key
                .clone()
                .unwrap_or_else(|| self.api_key.clone()),
            ollama_url: override_cfg
                .ollama_url
                .clone()
                .or_else(|| self.ollama_url.clone()),
            sear_xng_url: override_cfg
                .sear_xng_url
                .clone()
                .or_else(|| self.sear_xng_url.clone()),
            sear_xng_categories: override_cfg
                .sear_xng_categories
                .clone()
                .or_else(|| self.sear_xng_categories.clone()),
            serp_api_engine: override_cfg
                .serp_api_engine
                .clone()
                .or_else(|| self.serp_api_engine.clone()),
            provider_configs: self.provider_configs.clone(),
        }
    }
}

// Keep the spec list close to the executor even though the current planner
// still uses fixed tool names. API/MCP tool discovery and future native
// tool-calling should use this list instead of duplicating tool metadata.
#[allow(dead_code)]
pub fn builtin_tool_specs() -> Vec<ToolSpec> {
    vec![
        ToolSpec {
            name: "wiki.search".to_string(),
            description: "Search generated LLM Wiki pages using backend keyword/vector retrieval."
                .to_string(),
            effects: vec![ToolEffect::Read],
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "query": { "type": "string" },
                    "topK": { "type": "integer", "minimum": 1, "maximum": 10 }
                },
                "required": ["query"]
            })),
        },
        ToolSpec {
            name: "wiki.read_page".to_string(),
            description: "Read a project wiki markdown page by project-relative path.".to_string(),
            effects: vec![ToolEffect::Read],
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": { "path": { "type": "string" } },
                "required": ["path"]
            })),
        },
        ToolSpec {
            name: "source.search".to_string(),
            description:
                "Search raw source files stored under raw/sources for exact keyword snippets."
                    .to_string(),
            effects: vec![ToolEffect::Read],
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "query": { "type": "string" },
                    "topK": { "type": "integer", "minimum": 1, "maximum": 10 }
                },
                "required": ["query"]
            })),
        },
        ToolSpec {
            name: "web.search".to_string(),
            description: "Search external web sources when the user enables web search."
                .to_string(),
            effects: vec![ToolEffect::Network],
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "query": { "type": "string" },
                    "topK": { "type": "integer", "minimum": 1, "maximum": 10 }
                },
                "required": ["query"]
            })),
        },
        ToolSpec {
            name: "graph.search".to_string(),
            description: "Retrieve graph relationships, neighbors, backlinks, dependencies, and connections between project entities. Use concise entity or concept names rather than a full question."
                .to_string(),
            effects: vec![ToolEffect::Read],
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "query": { "type": "string" },
                    "topK": { "type": "integer", "minimum": 1, "maximum": 10 }
                },
                "required": ["query"]
            })),
        },
        ToolSpec {
            name: "anytxt.search".to_string(),
            description: "Search files indexed by an AnyTXT JSON-RPC service.".to_string(),
            effects: vec![ToolEffect::Network, ToolEffect::Read],
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "query": { "type": "string" },
                    "topK": { "type": "integer", "minimum": 1, "maximum": 10 }
                },
                "required": ["query"]
            })),
        },
        ToolSpec {
            name: "deep_research.run".to_string(),
            description:
                "Collect broader external/local evidence for deep research turns before synthesis."
                    .to_string(),
            effects: vec![ToolEffect::Network, ToolEffect::Read],
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "query": { "type": "string" },
                    "sources": {
                        "type": "array",
                        "items": { "enum": ["web", "anytxt", "wiki", "source"] }
                    }
                },
                "required": ["query"]
            })),
        },
        ToolSpec {
            name: "wiki.write_page".to_string(),
            description:
                "Create a Markdown wiki page under wiki/ with project-bound path checks. Existing files require allowOverwrite=true."
                    .to_string(),
            effects: vec![ToolEffect::Write],
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "Project-relative path such as wiki/queries/new-page.md"
                    },
                    "content": { "type": "string" },
                    "allowOverwrite": {
                        "type": "boolean",
                        "description": "Defaults to false. Set true only when the user explicitly asks to overwrite an existing wiki page."
                    }
                },
                "required": ["path", "content"]
            })),
        },
        ToolSpec {
            name: "llm.generate".to_string(),
            description: "Generate a final assistant answer from retrieved context.".to_string(),
            effects: vec![ToolEffect::Network],
            parameters: None,
        },
        ToolSpec {
            name: "skills.load".to_string(),
            description: "Load instruction-only project skills from .llm-wiki/skills.".to_string(),
            effects: vec![ToolEffect::Read],
            parameters: None,
        },
        ToolSpec {
            name: "skill.read_file".to_string(),
            description:
                "Read a text reference file from an active skill directory by relative path."
                    .to_string(),
            effects: vec![ToolEffect::Read],
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "skill": {
                        "type": "string",
                        "description": "Optional active skill name; required when multiple skills are active."
                    },
                    "path": {
                        "type": "string",
                        "description": "Relative path inside the active skill directory, such as references/types.md."
                    }
                },
                "required": ["path"]
            })),
        },
        ToolSpec {
            name: "workspace.write_file".to_string(),
            description:
                "Write a generated artifact file under the visible agent-workspace directory."
                    .to_string(),
            effects: vec![ToolEffect::Write],
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "Relative path under agent-workspace, such as cover-image/cover.svg."
                    },
                    "content": { "type": "string" }
                },
                "required": ["path", "content"]
            })),
        },
        ToolSpec {
            name: "workspace.append_file".to_string(),
            description:
                "Append generated artifact content under agent-workspace. Use after workspace.write_file for large HTML/PPT files."
                    .to_string(),
            effects: vec![ToolEffect::Write],
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "Relative path under agent-workspace, matching the file being appended."
                    },
                    "content": { "type": "string" }
                },
                "required": ["path", "content"]
            })),
        },
        ToolSpec {
            name: "shell.exec".to_string(),
            description:
                "Run a project-scoped shell command requested by an active skill instruction."
                    .to_string(),
            effects: vec![ToolEffect::Read, ToolEffect::Process],
            parameters: Some(serde_json::json!({
                "type": "object",
                "properties": {
                    "command": { "type": "string" },
                    "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": SHELL_EXEC_TIMEOUT_SECS }
                },
                "required": ["command"]
            })),
        },
    ]
}

fn tool_query<'a>(input: &'a Value, tool: &str) -> Result<&'a str, String> {
    input
        .get("query")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|query| !query.is_empty())
        .ok_or_else(|| format!("{tool} requires query"))
}

fn tool_top_k(input: &Value) -> usize {
    input
        .get("topK")
        .or_else(|| input.get("top_k"))
        .and_then(Value::as_u64)
        .map(|value| value as usize)
        .unwrap_or(5)
        .clamp(1, 10)
}

#[cfg(test)]
pub fn write_wiki_page_with_options(
    project_path: &str,
    rel_path: &str,
    content: &str,
    allow_overwrite: bool,
) -> Result<AgentReference, String> {
    Ok(write_wiki_page_with_activity(project_path, rel_path, content, allow_overwrite)?.reference)
}

fn write_wiki_page_with_activity(
    project_path: &str,
    rel_path: &str,
    content: &str,
    allow_overwrite: bool,
) -> Result<WikiWriteOutput, String> {
    if content.len() > MAX_WRITE_PAGE_BYTES {
        return Err("wiki.write_page content is too large".to_string());
    }
    let rel = normalize_wiki_write_path(rel_path)?;
    let path = safe_project_join(project_path, &rel)?;
    if let Some(parent) = path.parent() {
        // Check the deepest existing ancestor before creating directories. If a
        // project already contains a symlink under `wiki/`, this prevents even
        // empty intermediate directories from being created outside the project.
        ensure_existing_ancestor_bound(project_path, parent)?;
        fs::create_dir_all(parent)
            .map_err(|err| format!("Failed to create wiki page directory: {err}"))?;
        ensure_project_bound_path(project_path, parent)?;
    }
    // Create-only by default. Prompt injection in retrieved context must not be
    // able to silently truncate an existing wiki page.
    if path.exists() && !allow_overwrite {
        return Err(
            "wiki.write_page refuses to overwrite an existing page without allowOverwrite=true"
                .to_string(),
        );
    }
    let existed_before = path.is_file();
    let previous_content = workspace_rollback_snapshot(&path);
    crate::commands::file_history::record_file_version(&path, "baseline", "before.wiki.write_page");
    fs::write(&path, content).map_err(|err| format!("Failed to write wiki page: {err}"))?;
    crate::commands::file_history::record_file_version(&path, "agent", "wiki.write_page");
    Ok(WikiWriteOutput {
        reference: AgentReference {
            title: extract_markdown_title(content).unwrap_or_else(|| {
                Path::new(&rel)
                    .file_stem()
                    .and_then(|s| s.to_str())
                    .unwrap_or("Wiki page")
                    .replace('-', " ")
            }),
            path: rel.clone(),
            kind: "wiki".to_string(),
            snippet: Some(trim_text(&collapse_markdown_preview(content), 500))
                .filter(|value| !value.trim().is_empty()),
            score: None,
            knowledge_context: None,
        },
        existed_before,
        previous_content,
    })
}

fn write_workspace_file(
    project_path: &str,
    rel_path: &str,
    content: &str,
) -> Result<WorkspaceWriteOutput, String> {
    if content.len() > MAX_WORKSPACE_WRITE_BYTES {
        return Err("workspace.write_file content is too large".to_string());
    }
    let (rel, path) =
        resolve_workspace_write_target(project_path, rel_path, "workspace.write_file")?;
    if path
        .symlink_metadata()
        .map(|metadata| metadata.file_type().is_symlink())
        .unwrap_or(false)
    {
        return Err("workspace.write_file refuses to overwrite a symlink".to_string());
    }
    let existed_before = path.is_file();
    let previous_content = workspace_rollback_snapshot(&path);
    crate::commands::file_history::record_file_version(
        &path,
        "baseline",
        "before.workspace.write_file",
    );
    fs::write(&path, content).map_err(|err| format!("workspace.write_file failed: {err}"))?;
    crate::commands::file_history::record_file_version(&path, "agent", "workspace.write_file");
    Ok(WorkspaceWriteOutput {
        path: format!("{AGENT_WORKSPACE_DIR}/{rel}"),
        bytes: content.len(),
        existed_before,
        previous_content,
    })
}

fn append_workspace_file(
    project_path: &str,
    rel_path: &str,
    content: &str,
) -> Result<WorkspaceWriteOutput, String> {
    if content.len() > MAX_WORKSPACE_WRITE_BYTES {
        return Err("workspace.append_file content is too large".to_string());
    }
    let (rel, path) =
        resolve_workspace_write_target(project_path, rel_path, "workspace.append_file")?;
    if path
        .symlink_metadata()
        .map(|metadata| metadata.file_type().is_symlink())
        .unwrap_or(false)
    {
        return Err("workspace.append_file refuses to overwrite a symlink".to_string());
    }
    let existed_before = path.is_file();
    let previous_content = workspace_rollback_snapshot(&path);
    crate::commands::file_history::record_file_version(
        &path,
        "baseline",
        "before.workspace.append_file",
    );
    OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
        .and_then(|mut file| {
            use std::io::Write;
            file.write_all(content.as_bytes())
        })
        .map_err(|err| format!("workspace.append_file failed: {err}"))?;
    crate::commands::file_history::record_file_version(&path, "agent", "workspace.append_file");
    let bytes = fs::metadata(&path)
        .map(|metadata| metadata.len() as usize)
        .unwrap_or(content.len());
    Ok(WorkspaceWriteOutput {
        path: format!("{AGENT_WORKSPACE_DIR}/{rel}"),
        bytes,
        existed_before,
        previous_content,
    })
}

fn workspace_rollback_snapshot(path: &Path) -> Option<String> {
    let metadata = fs::metadata(path).ok()?;
    if !metadata.is_file() || metadata.len() > MAX_WORKSPACE_ROLLBACK_BYTES {
        return None;
    }
    fs::read_to_string(path).ok()
}

fn resolve_workspace_write_target(
    project_path: &str,
    rel_path: &str,
    tool_name: &str,
) -> Result<(String, PathBuf), String> {
    let rel = normalize_workspace_write_path(rel_path)
        .map_err(|err| err.replace("workspace.write_file", tool_name))?;
    let project = Path::new(project_path);
    if !project.is_dir() {
        return Err(format!("{tool_name} project directory is not available"));
    }
    let workspace = agent_workspace_path(project);
    fs::create_dir_all(&workspace)
        .map_err(|err| format!("{tool_name} failed to create workspace: {err}"))?;
    ensure_project_bound_path(project_path, &workspace)?;
    let path = workspace.join(&rel);
    if let Some(parent) = path.parent() {
        ensure_existing_ancestor_bound(project_path, parent)?;
        fs::create_dir_all(parent)
            .map_err(|err| format!("{tool_name} failed to create directory: {err}"))?;
        ensure_project_bound_path(project_path, parent)?;
    }
    Ok((rel, path))
}

pub async fn run_wiki_search(
    project_path: String,
    query: &str,
    top_k: usize,
    include_content: bool,
    embedding_config: Option<SearchEmbeddingConfig>,
) -> Result<WikiSearchToolOutput, String> {
    let query_embedding = search::resolve_query_embedding(query, None, embedding_config).await?;
    let search = search::search_project_inner(
        project_path.clone(),
        query.to_string(),
        top_k,
        include_content,
        query_embedding,
    )
    .await?;
    let project_for_context = project_path.clone();
    let context_index =
        tokio::task::spawn_blocking(move || build_knowledge_context_index(&project_for_context))
            .await
            .map_err(|err| format!("wiki.search graph context worker failed: {err}"));
    // Retrieval must remain useful if optional graph enrichment fails. The
    // search result is the source of truth; context is a bounded enhancement.
    let mut context_index = context_index.unwrap_or_default();
    let references = search
        .results
        .iter()
        .map(|result| {
            let normalized_path = normalize_rel_path(&result.path);
            let mut knowledge_context = context_index.remove(&normalized_path);
            if let Some(context) = knowledge_context.as_mut() {
                context.related_to = result.graph_related_to.clone();
            }
            attach_latest_version(&project_path, &normalized_path, &mut knowledge_context);
            AgentReference {
                title: result.title.clone(),
                path: result.path.clone(),
                kind: if result.graph_related_to.is_empty() {
                    "wiki".to_string()
                } else {
                    "graph".to_string()
                },
                snippet: Some(result.snippet.clone()).filter(|s| !s.trim().is_empty()),
                score: Some(result.score),
                knowledge_context,
            }
        })
        .collect();
    Ok(WikiSearchToolOutput {
        mode: search.mode,
        token_hits: search.token_hits,
        vector_hits: search.vector_hits,
        graph_hits: search.graph_hits,
        references,
    })
}

async fn run_shell_exec(
    project_path: &str,
    command: &str,
    timeout_secs: u64,
) -> Result<ShellExecToolOutput, String> {
    let command = command.trim();
    if command.is_empty() {
        return Err("shell.exec command is empty".to_string());
    }
    if command.chars().count() > MAX_SHELL_COMMAND_CHARS {
        return Err("shell.exec command is too long".to_string());
    }
    let cwd = Path::new(project_path);
    if !cwd.is_dir() {
        return Err("shell.exec project directory is not available".to_string());
    }
    let workspace = agent_workspace_path(cwd);
    fs::create_dir_all(&workspace)
        .map_err(|err| format!("shell.exec failed to create {AGENT_WORKSPACE_DIR}: {err}"))?;
    ensure_project_bound_path(project_path, &workspace)?;
    let before_files = snapshot_workspace_files(&workspace);
    #[cfg(windows)]
    let mut child = {
        let shell = std::env::var_os("ComSpec").unwrap_or_else(|| "cmd".into());
        let mut cmd = Command::new(shell);
        cmd.args(["/C", command]);
        cmd
    };
    #[cfg(not(windows))]
    let mut child = {
        let mut cmd = Command::new("/bin/sh");
        cmd.args(["-c", command]);
        cmd
    };
    apply_sanitized_shell_env(&mut child, project_path, &workspace);
    child
        .current_dir(&workspace)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .kill_on_drop(true);
    let mut child = child
        .spawn()
        .map_err(|err| format!("shell.exec failed to start: {err}"))?;
    let stdout = child
        .stdout
        .take()
        .ok_or_else(|| "shell.exec failed to capture stdout".to_string())?;
    let stderr = child
        .stderr
        .take()
        .ok_or_else(|| "shell.exec failed to capture stderr".to_string())?;
    let stdout_task = tokio::spawn(read_limited_output(stdout, MAX_SHELL_OUTPUT_CHARS));
    let stderr_task = tokio::spawn(read_limited_output(stderr, MAX_SHELL_OUTPUT_CHARS));
    let status = timeout(Duration::from_secs(timeout_secs), child.wait()).await;
    let (exit_code, timed_out, timeout_message) = match status {
        Ok(Ok(status)) => (status.code(), false, None),
        Ok(Err(err)) => return Err(format!("shell.exec failed while waiting: {err}")),
        Err(_) => {
            let _ = child.start_kill();
            let _ = child.wait().await;
            (
                None,
                true,
                Some(format!("Command timed out after {timeout_secs}s")),
            )
        }
    };
    let stdout = await_shell_output(stdout_task, "stdout").await?;
    let mut stderr = await_shell_output(stderr_task, "stderr").await?;
    if let Some(message) = timeout_message {
        if !stderr.is_empty() {
            stderr.push('\n');
        }
        stderr.push_str(&message);
    }
    let generated_files = changed_workspace_files(&workspace, before_files);
    for output in &generated_files {
        if let Some(relative) = output.path.strip_prefix(&format!("{AGENT_WORKSPACE_DIR}/")) {
            crate::commands::file_history::record_file_version(
                &workspace.join(relative),
                "agent",
                "shell.exec",
            );
        }
    }
    Ok(ShellExecToolOutput {
        command: command.to_string(),
        exit_code,
        stdout,
        stderr,
        timed_out,
        generated_files,
    })
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct WorkspaceFileSnapshot {
    len: u64,
    modified: Option<SystemTime>,
    content_hash: Option<u64>,
}

fn snapshot_workspace_files(workspace: &Path) -> BTreeMap<String, WorkspaceFileSnapshot> {
    let mut files = BTreeMap::new();
    for entry in WalkDir::new(workspace).into_iter().filter_map(Result::ok) {
        if !entry.file_type().is_file() {
            continue;
        }
        let Ok(rel) = entry.path().strip_prefix(workspace) else {
            continue;
        };
        let Some(rel) = rel.to_str().map(|value| value.replace('\\', "/")) else {
            continue;
        };
        let Ok(metadata) = entry.metadata() else {
            continue;
        };
        files.insert(
            rel,
            WorkspaceFileSnapshot {
                len: metadata.len(),
                modified: metadata.modified().ok(),
                content_hash: workspace_file_content_hash(entry.path(), metadata.len()),
            },
        );
    }
    files
}

fn workspace_file_content_hash(path: &Path, len: u64) -> Option<u64> {
    // Shell-generated artifacts can be rewritten faster than some filesystems
    // update mtimes, especially on Windows/external/network volumes. A bounded
    // content signature prevents same-size rewrites from disappearing from the
    // generated-output list without turning every shell command into an
    // unbounded full-workspace read.
    const FULL_HASH_LIMIT_BYTES: u64 = 8 * 1024 * 1024;
    const EDGE_SAMPLE_BYTES: u64 = 64 * 1024;

    let mut file = fs::File::open(path).ok()?;
    let mut hasher = DefaultHasher::new();
    len.hash(&mut hasher);
    if len <= FULL_HASH_LIMIT_BYTES {
        let mut bytes = Vec::new();
        file.read_to_end(&mut bytes).ok()?;
        bytes.hash(&mut hasher);
    } else {
        let sample = EDGE_SAMPLE_BYTES as usize;
        let mut head = vec![0_u8; sample];
        file.read_exact(&mut head).ok()?;
        head.hash(&mut hasher);
        file.seek(SeekFrom::End(-(EDGE_SAMPLE_BYTES as i64))).ok()?;
        let mut tail = vec![0_u8; sample];
        file.read_exact(&mut tail).ok()?;
        tail.hash(&mut hasher);
    }
    Some(hasher.finish())
}

fn changed_workspace_files(
    workspace: &Path,
    before: BTreeMap<String, WorkspaceFileSnapshot>,
) -> Vec<WorkspaceWriteOutput> {
    let after = snapshot_workspace_files(workspace);
    after
        .into_iter()
        .filter_map(|(rel, snapshot)| {
            if before.get(&rel) == Some(&snapshot) {
                return None;
            }
            Some(WorkspaceWriteOutput {
                path: format!("{AGENT_WORKSPACE_DIR}/{rel}"),
                bytes: snapshot.len as usize,
                existed_before: before.contains_key(&rel),
                previous_content: None,
            })
        })
        .take(MAX_SHELL_GENERATED_FILES)
        .collect()
}

async fn await_shell_output(mut handle: JoinHandle<String>, label: &str) -> Result<String, String> {
    match timeout(
        Duration::from_secs(SHELL_OUTPUT_DRAIN_TIMEOUT_SECS),
        &mut handle,
    )
    .await
    {
        Ok(Ok(output)) => Ok(output),
        Ok(Err(err)) => Err(format!("shell.exec {label} task failed: {err}")),
        Err(_) => {
            handle.abort();
            Ok(format!(
                "[{label} output was still open after command exit and was truncated]"
            ))
        }
    }
}

fn apply_sanitized_shell_env(command: &mut Command, project_path: &str, workspace: &Path) {
    // This is environment minimization, not an OS sandbox. `shell.exec` is only
    // reachable after an exact user approval in the Agent runtime, and approved
    // commands can still access the user's normal filesystem through the shell.
    // Keep generated artifacts in `LLM_WIKI_AGENT_WORKSPACE`, but do not imply
    // stronger process isolation here without adding a real sandbox layer.
    command.env_clear();
    preserve_shell_env(command, &["PATH", "LANG", "LC_ALL", "LC_CTYPE"]);
    #[cfg(not(windows))]
    {
        preserve_shell_env(
            command,
            &[
                "HOME",
                "USER",
                "LOGNAME",
                "SHELL",
                "TMPDIR",
                "XDG_CONFIG_HOME",
                "XDG_CACHE_HOME",
                "XDG_DATA_HOME",
            ],
        );
    }
    #[cfg(windows)]
    {
        preserve_shell_env(
            command,
            &[
                "ComSpec",
                "SystemRoot",
                "WINDIR",
                "PATHEXT",
                "USERPROFILE",
                "USERNAME",
                "HOMEDRIVE",
                "HOMEPATH",
                "TEMP",
                "TMP",
                "APPDATA",
                "LOCALAPPDATA",
                "ProgramData",
            ],
        );
    }
    command.env("LLM_WIKI_PROJECT", project_path);
    command.env("LLM_WIKI_PROJECT_PATH", project_path);
    command.env("LLM_WIKI_AGENT_WORKSPACE", workspace);
}

fn preserve_shell_env(command: &mut Command, keys: &[&str]) {
    for key in keys {
        if let Some(value) = std::env::var_os(key) {
            command.env(key, value);
        }
    }
}

async fn read_limited_output<R>(mut reader: R, max_chars: usize) -> String
where
    R: tokio::io::AsyncRead + Unpin,
{
    let mut kept = Vec::new();
    let max_bytes = max_chars.saturating_mul(4);
    let mut buffer = [0_u8; 8192];
    loop {
        let Ok(n) = reader.read(&mut buffer).await else {
            break;
        };
        if n == 0 {
            break;
        }
        if kept.len() < max_bytes {
            let remaining = max_bytes - kept.len();
            kept.extend_from_slice(&buffer[..n.min(remaining)]);
        }
    }
    let mut text = String::from_utf8_lossy(&kept).to_string();
    if text.chars().count() > max_chars {
        text = trim_text(&text, max_chars);
    }
    text
}

pub async fn run_web_search(
    query: &str,
    config: Option<WebSearchConfig>,
    top_k: usize,
) -> Result<Vec<AgentReference>, String> {
    let query = query.trim();
    if query.is_empty() {
        return Ok(Vec::new());
    }
    let Some(config) = config else {
        return Err(
            "Web search is enabled for this turn but no search provider is configured.".to_string(),
        );
    };
    let config = config.resolved();
    let provider = config.provider.trim().to_ascii_lowercase();
    if provider.is_empty() || provider == "none" {
        return Err("Web search provider is not configured.".to_string());
    }
    let max_results = web_search_result_limit(&provider, top_k);
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(WEB_SEARCH_TIMEOUT_SECS))
        .build()
        .map_err(|err| format!("Failed to build web search client: {err}"))?;
    let raw = match provider.as_str() {
        "firecrawl" => firecrawl_search(&client, query, &config, max_results).await?,
        "searxng" => searxng_search(&client, query, &config, max_results).await?,
        "tavily" => tavily_search(&client, query, &config, max_results).await?,
        "ollama" => ollama_search(&client, query, &config, max_results).await?,
        "brave" => brave_search(&client, query, &config, max_results).await?,
        "bocha" => bocha_search(&client, query, &config, max_results).await?,
        "serpapi" => serpapi_search(&client, query, &config, max_results).await?,
        other => {
            return Err(format!(
                "Web search provider '{other}' is not supported by the Rust Agent yet"
            ))
        }
    };
    Ok(web_items_to_references(raw, max_results))
}

fn web_search_result_limit(provider: &str, requested: usize) -> usize {
    // Bocha documents a 1-50 range. Existing providers retain the historical
    // 20-result ceiling so adding Bocha cannot increase their request cost.
    let provider_max = if provider == "bocha" { 50 } else { 20 };
    requested.clamp(1, provider_max)
}

pub async fn run_anytxt_search(
    query: &str,
    config: Option<AnyTxtConfig>,
    top_k: usize,
) -> Result<Vec<AgentReference>, String> {
    let query = query.trim();
    if query.is_empty() {
        return Ok(Vec::new());
    }
    let config = config.unwrap_or_default();
    if config.enabled == Some(false) {
        return Ok(Vec::new());
    }
    let endpoint = config
        .endpoint
        .as_deref()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or(DEFAULT_ANYTXT_ENDPOINT)
        .trim()
        .trim_end_matches('/');
    let endpoint = normalize_anytxt_endpoint(endpoint);
    let limit = top_k
        .clamp(1, 100)
        .min(config.limit.unwrap_or(DEFAULT_ANYTXT_LIMIT).clamp(1, 100));
    // AnyTXT has its own query syntax. The caller may already have rewritten
    // natural language into keyword form, so do not run the source-search
    // tokenizer here; pass the pattern through unchanged.
    let pattern = query.to_string();
    let filter_dir = config.filter_dir.unwrap_or_default();
    let filter_ext = config
        .filter_ext
        .filter(|value| !value.trim().is_empty())
        .unwrap_or_else(|| "*".to_string());
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(WEB_SEARCH_TIMEOUT_SECS))
        .build()
        .map_err(|err| format!("Failed to build AnyTXT client: {err}"))?;
    let mut input = json!({
        "pattern": pattern,
        "filterExt": filter_ext,
        "lastModifyBegin": 0,
        "lastModifyEnd": ANYTXT_LAST_MODIFY_END,
        "limit": limit.to_string(),
        "offset": 0,
        "order": 0
    });
    if !filter_dir.trim().is_empty() {
        input["filterDir"] = Value::String(filter_dir);
    }
    let response = client
        .post(&endpoint)
        .header("Accept", "application/json")
        .json(&json!({
            "id": 1,
            "jsonrpc": "2.0",
            "method": "ATRpcServer.Searcher.V1.GetResult",
            "params": { "input": input }
        }))
        .send()
        .await
        .map_err(|err| {
            format!("AnyTXT search failed. Check that ATGUI.exe or the AnyTXT service is running at {endpoint}: {err}")
        })?;
    let status = response.status();
    let text = response
        .text()
        .await
        .map_err(|err| format!("Failed to read AnyTXT response: {err}"))?;
    if !status.is_success() {
        return Err(format!("AnyTXT HTTP {status}: {}", trim_text(&text, 300)));
    }
    let value: Value = serde_json::from_str(&text)
        .map_err(|_| format!("AnyTXT returned invalid JSON: {}", trim_text(&text, 300)))?;
    if let Some(error) = value.get("error") {
        return Err(format!(
            "AnyTXT error: {}",
            trim_text(&error.to_string(), 300)
        ));
    }
    let mut references = Vec::new();
    for item in extract_anytxt_items(&value).into_iter().take(limit) {
        let fragment = if !item.fid.trim().is_empty() {
            get_anytxt_fragment(&client, &endpoint, &item.fid, &pattern)
                .await
                .unwrap_or_default()
        } else {
            String::new()
        };
        references.push(AgentReference {
            title: item.title,
            path: file_url_for_path(&item.path),
            kind: "anytxt".to_string(),
            snippet: Some(trim_text(
                if fragment.trim().is_empty() {
                    &item.snippet
                } else {
                    &fragment
                },
                1200,
            ))
            .filter(|s| !s.trim().is_empty()),
            score: None,
            knowledge_context: None,
        });
    }
    Ok(references)
}

#[derive(Debug, Clone)]
struct AnyTxtItem {
    fid: String,
    title: String,
    path: String,
    snippet: String,
}

fn extract_anytxt_items(value: &Value) -> Vec<AnyTxtItem> {
    let result = value.get("result").unwrap_or(value);
    let candidates = first_anytxt_array(
        result,
        &[
            &[][..],
            &["items"],
            &["files"],
            &["results"],
            &["list"],
            &["value"],
            &["data"],
            &["output"],
            &["output", "items"],
            &["output", "files"],
            &["output", "results"],
            &["output", "list"],
            &["output", "value"],
            &["output", "data"],
            &["data", "items"],
            &["data", "files"],
            &["data", "results"],
            &["data", "list"],
            &["data", "value"],
            &["data", "output"],
            &["data", "output", "items"],
            &["data", "output", "files"],
            &["data", "output", "results"],
            &["data", "output", "list"],
            &["data", "output", "value"],
        ],
    )
    .unwrap_or_default();
    let fields = first_anytxt_fields(
        result,
        &[
            &["field"][..],
            &["fields"],
            &["output", "field"],
            &["output", "fields"],
            &["data", "field"],
            &["data", "fields"],
            &["data", "output", "field"],
            &["data", "output", "fields"],
        ],
    )
    .unwrap_or_default();
    candidates
        .into_iter()
        .filter_map(|item| {
            let record = normalize_anytxt_record(item, &fields);
            let fid = string_field(&record, &["fid", "id", "fileId", "file_id"]);
            let raw_path = string_field(
                &record,
                &[
                    "path",
                    "file",
                    "filePath",
                    "file_path",
                    "fullPath",
                    "full_path",
                    "filename",
                    "fileName",
                    "name",
                ],
            );
            let path = if raw_path.is_empty() && !fid.is_empty() {
                format!("anytxt://{fid}")
            } else {
                raw_path
            };
            let title = string_field(&record, &["title", "name", "fileName", "filename"])
                .trim()
                .to_string();
            let title = if title.is_empty() {
                Path::new(&path)
                    .file_name()
                    .and_then(|s| s.to_str())
                    .filter(|value| !value.trim().is_empty())
                    .unwrap_or("AnyTXT result")
                    .to_string()
            } else {
                title
            };
            let snippet = string_field(
                &record,
                &[
                    "snippet",
                    "fragment",
                    "content",
                    "contents",
                    "text",
                    "summary",
                    "highlight",
                    "hitText",
                    "hit_text",
                ],
            );
            if path.is_empty() && snippet.is_empty() {
                None
            } else {
                Some(AnyTxtItem {
                    fid,
                    title,
                    path,
                    snippet,
                })
            }
        })
        .collect()
}

fn first_anytxt_array(value: &Value, paths: &[&[&str]]) -> Option<Vec<Value>> {
    for path in paths {
        let Some(candidate) = value_at_path(value, path) else {
            continue;
        };
        if let Some(items) = candidate.as_array() {
            return Some(items.clone());
        }
    }
    None
}

fn first_anytxt_fields(value: &Value, paths: &[&[&str]]) -> Option<Vec<String>> {
    for path in paths {
        let Some(candidate) = value_at_path(value, path) else {
            continue;
        };
        let Some(items) = candidate.as_array() else {
            continue;
        };
        let fields = items
            .iter()
            .filter_map(Value::as_str)
            .map(ToOwned::to_owned)
            .collect::<Vec<_>>();
        if !fields.is_empty() {
            return Some(fields);
        }
    }
    None
}

fn value_at_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
    let mut current = value;
    for key in path {
        current = current.get(*key)?;
    }
    Some(current)
}

fn normalize_anytxt_record(item: Value, fields: &[String]) -> serde_json::Map<String, Value> {
    match item {
        Value::Object(object) => object,
        Value::Array(row) if !fields.is_empty() => fields
            .iter()
            .cloned()
            .zip(row)
            .collect::<serde_json::Map<String, Value>>(),
        other => {
            let mut object = serde_json::Map::new();
            object.insert("text".to_string(), other);
            object
        }
    }
}

fn string_field(record: &serde_json::Map<String, Value>, keys: &[&str]) -> String {
    for key in keys {
        let Some(value) = record.get(*key) else {
            continue;
        };
        if let Some(text) = value.as_str().filter(|text| !text.trim().is_empty()) {
            return text.trim().to_string();
        }
        if let Some(number) = value.as_i64() {
            return number.to_string();
        }
        if let Some(number) = value.as_u64() {
            return number.to_string();
        }
    }
    String::new()
}

async fn get_anytxt_fragment(
    client: &reqwest::Client,
    endpoint: &str,
    fid: &str,
    pattern: &str,
) -> Result<String, String> {
    let response = client
        .post(endpoint)
        .header("Accept", "application/json")
        .json(&json!({
            "id": 2,
            "jsonrpc": "2.0",
            "method": "ATRpcServer.Searcher.V1.GetFragment",
            "params": { "input": { "fid": fid, "pattern": pattern } }
        }))
        .send()
        .await
        .map_err(|err| format!("AnyTXT fragment failed: {err}"))?;
    let status = response.status();
    let text = response
        .text()
        .await
        .map_err(|err| format!("Failed to read AnyTXT fragment response: {err}"))?;
    if !status.is_success() {
        return Err(format!(
            "AnyTXT fragment HTTP {status}: {}",
            trim_text(&text, 300)
        ));
    }
    let value: Value = serde_json::from_str(&text).map_err(|_| {
        format!(
            "AnyTXT fragment returned invalid JSON: {}",
            trim_text(&text, 300)
        )
    })?;
    if let Some(error) = value.get("error") {
        return Err(format!(
            "AnyTXT fragment error: {}",
            trim_text(&error.to_string(), 300)
        ));
    }
    Ok(extract_anytxt_fragment_text(
        value.get("result").unwrap_or(&Value::Null),
    ))
}

fn extract_anytxt_fragment_text(value: &Value) -> String {
    if let Some(text) = value.as_str() {
        return text.to_string();
    }
    if let Some(items) = value.as_array() {
        return items
            .iter()
            .map(extract_anytxt_fragment_text)
            .filter(|item| !item.trim().is_empty())
            .collect::<Vec<_>>()
            .join("\n\n");
    }
    let Some(object) = value.as_object() else {
        return String::new();
    };
    for key in ["text", "fragment", "content", "snippet", "html"] {
        if let Some(text) = object.get(key).and_then(Value::as_str) {
            return text.to_string();
        }
    }
    for key in ["output", "result", "data", "fragments", "items", "list"] {
        if let Some(next) = object.get(key) {
            let text = extract_anytxt_fragment_text(next);
            if !text.trim().is_empty() {
                return text;
            }
        }
    }
    String::new()
}

fn normalize_anytxt_endpoint(value: &str) -> String {
    if value.starts_with("http://") || value.starts_with("https://") {
        value.to_string()
    } else {
        format!("http://{value}")
    }
}

#[derive(Debug, Clone)]
struct WebSearchItem {
    title: String,
    url: String,
    snippet: String,
}

async fn firecrawl_search(
    client: &reqwest::Client,
    query: &str,
    config: &WebSearchConfig,
    max_results: usize,
) -> Result<Vec<WebSearchItem>, String> {
    let override_cfg = config
        .provider_configs
        .as_ref()
        .and_then(|values| values.get("firecrawl"));
    let base = override_cfg
        .and_then(|value| value.base_url.as_deref())
        .filter(|value| !value.trim().is_empty())
        .unwrap_or("https://api.firecrawl.dev")
        .trim_end_matches('/');
    let mut request = client
        .post(format!("{base}/v2/search"))
        .header("Accept", "application/json");
    if let Some(key) = override_cfg
        .and_then(|value| value.api_key.as_deref())
        .filter(|value| !value.trim().is_empty())
    {
        request = request.bearer_auth(key.trim());
    }
    let response = request
        .json(&json!({ "query": query, "limit": max_results }))
        .send()
        .await
        .map_err(|err| format!("Network error reaching Firecrawl Search: {err}"))?;
    let status = response.status();
    let text = response
        .text()
        .await
        .map_err(|err| format!("Failed to read Firecrawl response: {err}"))?;
    let parsed: Value = serde_json::from_str(&text).map_err(|_| {
        format!(
            "Firecrawl search returned invalid JSON: {}",
            trim_text(&text, 300)
        )
    })?;
    if !status.is_success() || parsed.get("success").and_then(Value::as_bool) == Some(false) {
        let msg = parsed
            .get("error")
            .and_then(Value::as_str)
            .map(friendly_firecrawl_error)
            .unwrap_or_else(|| format!("Firecrawl search failed ({status})"));
        return Err(msg);
    }
    let items = extract_web_items(&parsed, &["data", "results"]);
    Ok(items.into_iter().map(normalize_web_result).collect())
}

async fn searxng_search(
    client: &reqwest::Client,
    query: &str,
    config: &WebSearchConfig,
    max_results: usize,
) -> Result<Vec<WebSearchItem>, String> {
    let base = config
        .sear_xng_url
        .as_deref()
        .filter(|v| !v.trim().is_empty())
        .ok_or_else(|| "SearXNG URL is required for web.search".to_string())?;
    let mut url = normalize_searxng_url(base)?;
    let categories = config
        .sear_xng_categories
        .clone()
        .unwrap_or_else(|| vec!["general".to_string()]);
    url.push_str(&format!(
        "?q={}&format=json&categories={}",
        url_encode(query),
        url_encode(&categories.join(","))
    ));
    let response = client
        .get(&url)
        .header("Accept", "application/json")
        .send()
        .await
        .map_err(|err| format!("Network error reaching SearXNG: {err}"))?;
    let status = response.status();
    let text = response
        .text()
        .await
        .map_err(|err| format!("Failed to read SearXNG response: {err}"))?;
    if !status.is_success() {
        return Err(format!(
            "SearXNG search failed ({status}): {}",
            trim_text(&text, 300)
        ));
    }
    let parsed: Value = serde_json::from_str(&text)
        .map_err(|_| format!("SearXNG returned invalid JSON: {}", trim_text(&text, 300)))?;
    let items = parsed
        .get("results")
        .and_then(Value::as_array)
        .cloned()
        .unwrap_or_default();
    Ok(items
        .into_iter()
        .take(max_results)
        .map(normalize_web_result)
        .collect())
}

async fn tavily_search(
    client: &reqwest::Client,
    query: &str,
    config: &WebSearchConfig,
    max_results: usize,
) -> Result<Vec<WebSearchItem>, String> {
    let key = required_api_key(config, "Tavily")?;
    let response = client
        .post("https://api.tavily.com/search")
        .json(&json!({
            "api_key": key,
            "query": query,
            "max_results": max_results,
            "search_depth": "advanced",
            "include_answer": false
        }))
        .send()
        .await
        .map_err(|err| format!("Network error reaching Tavily: {err}"))?;
    parse_web_json_response(response, "Tavily", |value| {
        value
            .get("results")
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default()
            .into_iter()
            .map(normalize_web_result)
            .collect()
    })
    .await
}

async fn ollama_search(
    client: &reqwest::Client,
    query: &str,
    config: &WebSearchConfig,
    max_results: usize,
) -> Result<Vec<WebSearchItem>, String> {
    let key = required_api_key(config, "Ollama")?;
    let base = config
        .ollama_url
        .as_deref()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or("https://ollama.com")
        .trim()
        .trim_end_matches('/');
    let url = format!("{base}/api/web_search");
    let response = client
        .post(url)
        .header("Accept", "application/json")
        .bearer_auth(key)
        .json(&json!({
            "query": query,
            "max_results": max_results
        }))
        .send()
        .await
        .map_err(|err| format!("Network error reaching Ollama Web Search: {err}"))?;
    parse_web_json_response(response, "Ollama Web Search", |value| {
        value
            .get("results")
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default()
            .into_iter()
            .map(normalize_web_result)
            .collect()
    })
    .await
}

async fn brave_search(
    client: &reqwest::Client,
    query: &str,
    config: &WebSearchConfig,
    max_results: usize,
) -> Result<Vec<WebSearchItem>, String> {
    let key = required_api_key(config, "Brave")?;
    let url = format!(
        "https://api.search.brave.com/res/v1/web/search?q={}&count={}",
        url_encode(query),
        max_results.min(20)
    );
    let response = client
        .get(url)
        .header("Accept", "application/json")
        .header("X-Subscription-Token", key)
        .send()
        .await
        .map_err(|err| format!("Network error reaching Brave Search: {err}"))?;
    parse_web_json_response(response, "Brave Search", |value| {
        value
            .get("web")
            .and_then(|web| web.get("results"))
            .and_then(Value::as_array)
            .cloned()
            .unwrap_or_default()
            .into_iter()
            .map(normalize_web_result)
            .collect()
    })
    .await
}

async fn bocha_search(
    client: &reqwest::Client,
    query: &str,
    config: &WebSearchConfig,
    max_results: usize,
) -> Result<Vec<WebSearchItem>, String> {
    let key = required_api_key(config, "Bocha")?;
    let response = client
        .post("https://api.bocha.cn/v1/web-search")
        .header("Accept", "application/json")
        .bearer_auth(key)
        .json(&json!({
            "query": query,
            "freshness": "noLimit",
            "summary": true,
            "count": max_results.clamp(1, 50)
        }))
        .send()
        .await
        .map_err(|err| format!("Network error reaching Bocha Search: {err}"))?;
    parse_web_json_response(response, "Bocha Search", parse_bocha_results).await
}

fn parse_bocha_results(value: Value) -> Vec<WebSearchItem> {
    value
        .get("data")
        .and_then(|data| data.get("webPages"))
        .and_then(|pages| pages.get("value"))
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .map(|item| WebSearchItem {
            title: item
                .get("name")
                .and_then(Value::as_str)
                .unwrap_or("Untitled")
                .to_string(),
            url: item
                .get("url")
                .and_then(Value::as_str)
                .unwrap_or("")
                .to_string(),
            snippet: item
                .get("summary")
                .and_then(Value::as_str)
                .or_else(|| item.get("snippet").and_then(Value::as_str))
                .unwrap_or("")
                .to_string(),
        })
        .collect()
}

async fn serpapi_search(
    client: &reqwest::Client,
    query: &str,
    config: &WebSearchConfig,
    max_results: usize,
) -> Result<Vec<WebSearchItem>, String> {
    let key = required_api_key(config, "SerpApi")?;
    let engine = config.serp_api_engine.as_deref().unwrap_or("google");
    let url = format!(
        "https://serpapi.com/search?engine={}&q={}&api_key={}&num={}",
        url_encode(engine),
        url_encode(query),
        url_encode(key),
        max_results
    );
    let response = client
        .get(url)
        .header("Accept", "application/json")
        .send()
        .await
        .map_err(|err| format!("Network error reaching SerpApi: {err}"))?;
    parse_web_json_response(response, "SerpApi", |value| {
        for key in [
            "organic_results",
            "news_results",
            "images_results",
            "video_results",
            "videos_results",
            "shopping_results",
        ] {
            if let Some(items) = value.get(key).and_then(Value::as_array) {
                return items.iter().cloned().map(normalize_web_result).collect();
            }
        }
        Vec::new()
    })
    .await
}

async fn parse_web_json_response(
    response: reqwest::Response,
    provider: &str,
    parse: impl FnOnce(Value) -> Vec<WebSearchItem>,
) -> Result<Vec<WebSearchItem>, String> {
    let status = response.status();
    let text = response
        .text()
        .await
        .map_err(|err| format!("Failed to read {provider} response: {err}"))?;
    if !status.is_success() {
        return Err(format!(
            "{provider} search failed ({status}): {}",
            trim_text(&text, 300)
        ));
    }
    let value: Value = serde_json::from_str(&text).map_err(|_| {
        format!(
            "{provider} returned invalid JSON: {}",
            trim_text(&text, 300)
        )
    })?;
    if let Some(error) = value.get("error").and_then(Value::as_str) {
        return Err(format!("{provider} search failed: {error}"));
    }
    if let Some(message) = provider_payload_error(provider, &value) {
        return Err(message);
    }
    Ok(parse(value))
}

fn provider_payload_error(provider: &str, value: &Value) -> Option<String> {
    if provider == "Bocha Search" {
        let code = value.get("code").and_then(Value::as_i64);
        if code != Some(200) {
            let message = value
                .get("msg")
                .and_then(Value::as_str)
                .filter(|message| !message.trim().is_empty())
                .unwrap_or("unknown API error");
            return Some(format!(
                "{provider} failed (code {}): {message}",
                code.unwrap_or(0)
            ));
        }
    }
    if provider == "Brave Search" && value.get("web").is_none() {
        let message = value.get("message").and_then(Value::as_str)?;
        return Some(format!("{provider} search failed: {message}"));
    }
    None
}

fn web_items_to_references(raw: Vec<WebSearchItem>, max_results: usize) -> Vec<AgentReference> {
    raw.into_iter()
        .take(max_results)
        .filter(|item| !item.url.trim().is_empty())
        .map(|item| AgentReference {
            title: item.title,
            path: item.url,
            kind: "web".to_string(),
            snippet: Some(item.snippet).filter(|s| !s.trim().is_empty()),
            score: None,
            knowledge_context: None,
        })
        .collect()
}

fn normalize_web_result(value: Value) -> WebSearchItem {
    let metadata = value.get("metadata");
    let title = value
        .get("title")
        .or_else(|| metadata.and_then(|m| m.get("title")))
        .and_then(Value::as_str)
        .unwrap_or("Untitled")
        .to_string();
    let url = value
        .get("url")
        .or_else(|| value.get("link"))
        .or_else(|| metadata.and_then(|m| m.get("sourceURL")))
        .or_else(|| metadata.and_then(|m| m.get("url")))
        .or_else(|| value.get("original"))
        .or_else(|| value.get("thumbnail"))
        .and_then(Value::as_str)
        .unwrap_or("")
        .to_string();
    let snippet = value
        .get("snippet")
        .or_else(|| value.get("content"))
        .or_else(|| value.get("description"))
        .or_else(|| metadata.and_then(|m| m.get("description")))
        .or_else(|| value.get("summary"))
        .or_else(|| value.get("markdown"))
        .and_then(Value::as_str)
        .unwrap_or("")
        .to_string();
    WebSearchItem {
        title,
        url,
        snippet,
    }
}

fn extract_web_items(value: &Value, keys: &[&str]) -> Vec<Value> {
    for key in keys {
        let Some(candidate) = value.get(*key) else {
            continue;
        };
        if let Some(items) = candidate.as_array() {
            return items.clone();
        }
        if let Some(items) = extract_nested_web_items(candidate) {
            return items;
        }
    }
    Vec::new()
}

fn extract_nested_web_items(value: &Value) -> Option<Vec<Value>> {
    let object = value.as_object()?;
    for key in ["web", "results", "items"] {
        if let Some(items) = object.get(key).and_then(Value::as_array) {
            return Some(items.clone());
        }
    }
    None
}

fn required_api_key<'a>(config: &'a WebSearchConfig, provider: &str) -> Result<&'a str, String> {
    let key = config.api_key.trim();
    if key.is_empty() {
        Err(format!(
            "{provider} web.search requires an API key in Settings."
        ))
    } else {
        Ok(key)
    }
}

fn normalize_searxng_url(value: &str) -> Result<String, String> {
    let trimmed = value.trim().trim_end_matches('/');
    if trimmed.is_empty() {
        return Err("SearXNG URL is required".to_string());
    }
    let mut url = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
        trimmed.to_string()
    } else {
        format!("https://{trimmed}")
    };
    if !url.ends_with("/search") {
        url.push_str("/search");
    }
    Ok(url)
}

fn friendly_firecrawl_error(error: &str) -> String {
    if error
        .to_ascii_lowercase()
        .contains("ip address looks suspicious")
    {
        "Firecrawl Search rejected this IP for key-free access. Add a Firecrawl API key in Settings or choose another Web Search provider.".to_string()
    } else {
        format!("Firecrawl search failed: {error}")
    }
}

#[derive(Debug)]
struct KnowledgePage {
    path: String,
    title: String,
    stem: String,
    tags: Vec<String>,
    links: Vec<String>,
    matches_query: bool,
    neighbors: Vec<String>,
}

/// Build one bounded graph/provenance index for a complete retrieval tool
/// invocation. Never call this once per result: large projects would otherwise
/// turn top-k retrieval into top-k full filesystem scans.
fn build_knowledge_context_index(project_path: &str) -> BTreeMap<String, AgentKnowledgeContext> {
    build_knowledge_graph_snapshot(project_path, None).1
}

fn build_knowledge_graph_snapshot(
    project_path: &str,
    query: Option<&str>,
) -> (Vec<KnowledgePage>, BTreeMap<String, AgentKnowledgeContext>) {
    let wiki_root = Path::new(project_path).join("wiki");
    if !wiki_root.is_dir() {
        return (Vec::new(), BTreeMap::new());
    }

    let mut pages = Vec::new();
    for entry in WalkDir::new(&wiki_root).into_iter().filter_map(Result::ok) {
        if pages.len() >= MAX_GRAPH_SEARCH_FILES
            || !entry.file_type().is_file()
            || entry.path().extension().and_then(|value| value.to_str()) != Some("md")
        {
            continue;
        }
        let Ok(content) = fs::read_to_string(entry.path()) else {
            continue;
        };
        let path = relative_to_project(project_path, entry.path());
        if is_hidden_rel(&path) {
            continue;
        }
        let title = search::extract_title(&content, entry.file_name().to_string_lossy().as_ref());
        let matches_query = query.is_some_and(|query| {
            let haystack = format!("{title} {path} {content}").to_lowercase();
            haystack.contains(query)
                || graph_query_terms(query)
                    .iter()
                    .any(|term| haystack.contains(term))
        });
        pages.push(KnowledgePage {
            path,
            title,
            stem: entry
                .path()
                .file_stem()
                .and_then(|value| value.to_str())
                .unwrap_or_default()
                .to_string(),
            tags: extract_frontmatter_list(&content, "tags"),
            links: extract_wikilinks(&content),
            matches_query,
            neighbors: Vec::new(),
        });
    }

    let mut aliases: BTreeMap<String, String> = BTreeMap::new();
    for page in &pages {
        let wiki_relative_path = page.path.strip_prefix("wiki/").unwrap_or(&page.path);
        for alias in [&page.stem, &page.title, &page.path, wiki_relative_path] {
            aliases.insert(normalize_wiki_link(alias), page.path.clone());
        }
    }

    let mut backlinks: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    let mut neighbors: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    for page in &pages {
        for link in &page.links {
            if let Some(target) = aliases.get(&normalize_wiki_link(link)) {
                if target != &page.path {
                    backlinks
                        .entry(target.clone())
                        .or_default()
                        .insert(page.path.clone());
                    neighbors
                        .entry(page.path.clone())
                        .or_default()
                        .insert(target.clone());
                    neighbors
                        .entry(target.clone())
                        .or_default()
                        .insert(page.path.clone());
                }
            }
        }
    }

    for page in &mut pages {
        page.neighbors = neighbors
            .remove(&page.path)
            .unwrap_or_default()
            .into_iter()
            .take(MAX_KNOWLEDGE_CONTEXT_ITEMS)
            .collect();
    }

    let contexts = pages
        .iter()
        .map(|page| {
            let mut outgoing_links = page.links.clone();
            outgoing_links.sort();
            outgoing_links.dedup();
            let total_outgoing = outgoing_links.len();
            outgoing_links.truncate(MAX_KNOWLEDGE_CONTEXT_ITEMS);
            let mut page_backlinks: Vec<String> = backlinks
                .remove(&page.path)
                .unwrap_or_default()
                .into_iter()
                .collect();
            let total_backlinks = page_backlinks.len();
            page_backlinks.truncate(MAX_KNOWLEDGE_CONTEXT_ITEMS);
            let link_count = total_outgoing + total_backlinks;
            (
                page.path.clone(),
                AgentKnowledgeContext {
                    related_to: Vec::new(),
                    tags: page
                        .tags
                        .iter()
                        .cloned()
                        .take(MAX_KNOWLEDGE_CONTEXT_ITEMS)
                        .collect(),
                    outgoing_links,
                    backlinks: page_backlinks,
                    link_count,
                    latest_version: None,
                },
            )
        })
        .collect();
    (pages, contexts)
}

fn attach_latest_version(
    project_path: &str,
    relative_path: &str,
    context: &mut Option<AgentKnowledgeContext>,
) {
    let Some(context) = context else {
        return;
    };
    context.latest_version = crate::commands::file_history::latest_file_version(
        &Path::new(project_path).join(relative_path),
    )
    .map(|(timestamp, author, tool)| AgentVersionSummary {
        timestamp,
        author,
        tool,
    });
}

fn normalize_wiki_link(value: &str) -> String {
    value
        .split('#')
        .next()
        .unwrap_or_default()
        .trim()
        .trim_end_matches(".md")
        .replace('\\', "/")
        .to_lowercase()
        .replace(' ', "-")
}

fn graph_query_terms(query: &str) -> Vec<String> {
    query
        .split(|character: char| {
            character.is_whitespace()
                || matches!(character, ',' | '，' | ';' | '；' | ':' | '：' | '/' | '|')
        })
        .map(str::trim)
        .filter(|term| !term.is_empty())
        .map(str::to_string)
        .collect()
}

fn extract_wikilinks(content: &str) -> Vec<String> {
    let mut links = Vec::new();
    let mut rest = content;
    while let Some(start) = rest.find("[[") {
        rest = &rest[start + 2..];
        let Some(end) = rest.find("]]") else {
            break;
        };
        let target = rest[..end].split('|').next().unwrap_or_default().trim();
        if !target.is_empty() {
            links.push(target.to_string());
        }
        rest = &rest[end + 2..];
    }
    links
}

fn extract_frontmatter_list(content: &str, key: &str) -> Vec<String> {
    let normalized = content.replace("\r\n", "\n");
    let Some(rest) = normalized.strip_prefix("---\n") else {
        return Vec::new();
    };
    let Some(end) = rest.find("\n---") else {
        return Vec::new();
    };
    let prefix = format!("{key}:");
    let lines: Vec<&str> = rest[..end].lines().collect();
    for (index, line) in lines.iter().enumerate() {
        let Some(value) = line.trim().strip_prefix(&prefix) else {
            continue;
        };
        let inline = value.trim();
        if inline.starts_with('[') && inline.ends_with(']') {
            return inline[1..inline.len() - 1]
                .split(',')
                .map(|item| item.trim().trim_matches(['\'', '"']).to_string())
                .filter(|item| !item.is_empty())
                .collect();
        }
        let mut values = Vec::new();
        for next in lines.iter().skip(index + 1) {
            let trimmed = next.trim();
            let Some(item) = trimmed.strip_prefix('-') else {
                break;
            };
            let item = item.trim().trim_matches(['\'', '"']);
            if !item.is_empty() {
                values.push(item.to_string());
            }
        }
        return values;
    }
    Vec::new()
}

pub fn read_wiki_page(project_path: &str, rel_path: &str) -> Result<String, String> {
    let rel = normalize_rel_path(rel_path);
    if !is_public_read_rel(&rel) || !rel.to_ascii_lowercase().starts_with("wiki/") {
        return Err("wiki.read_page path must stay under wiki/".to_string());
    }
    let path = safe_project_join(project_path, &rel)?;
    let meta = fs::metadata(&path).map_err(|err| format!("Failed to read page metadata: {err}"))?;
    if !meta.is_file() {
        return Err("wiki.read_page path is not a file".to_string());
    }
    if meta.len() as usize > MAX_READ_PAGE_BYTES {
        return Err("wiki.read_page file is too large".to_string());
    }
    fs::read_to_string(path).map_err(|err| format!("Failed to read wiki page: {err}"))
}

pub fn search_graph(
    project_path: &str,
    query: &str,
    top_k: usize,
) -> Result<Vec<AgentReference>, String> {
    let query = query.trim().to_lowercase();
    if query.is_empty() {
        return Ok(Vec::new());
    }
    let (pages, mut context_index) = build_knowledge_graph_snapshot(project_path, Some(&query));
    let seed_paths: BTreeSet<String> = pages
        .iter()
        .filter(|page| page.matches_query)
        .map(|page| page.path.clone())
        .collect();
    if seed_paths.is_empty() {
        return Ok(Vec::new());
    }
    let mut refs = Vec::new();
    for page in pages {
        let Some(page_context) = context_index.get(&page.path) else {
            continue;
        };
        let link_count = page_context.link_count;
        let connected_to_seed = page
            .neighbors
            .iter()
            .any(|neighbor| seed_paths.contains(neighbor));
        if !page.matches_query && !connected_to_seed {
            continue;
        }
        let rel = page.path;
        let relation = if page.matches_query {
            "matched entity"
        } else {
            "direct neighbor"
        };
        refs.push(AgentReference {
            title: page.title,
            path: rel.clone(),
            kind: "graph".to_string(),
            snippet: Some(format!("{relation}; {link_count} related link(s)")),
            score: Some(
                if page.matches_query {
                    10_000.0
                } else {
                    5_000.0
                } + link_count as f64,
            ),
            knowledge_context: {
                let mut context = context_index.remove(&rel);
                attach_latest_version(project_path, &rel, &mut context);
                context
            },
        });
    }
    refs.sort_by(|a, b| {
        b.score
            .partial_cmp(&a.score)
            .unwrap_or(std::cmp::Ordering::Equal)
            .then_with(|| a.path.cmp(&b.path))
    });
    refs.truncate(top_k.clamp(1, 10));
    Ok(refs)
}

pub fn search_sources(
    project_path: &str,
    query: &str,
    top_k: usize,
) -> Result<Vec<AgentReference>, String> {
    let query = query.trim();
    if query.is_empty() {
        return Err("source.search query is required".to_string());
    }
    let root = Path::new(project_path).join("raw").join("sources");
    if !root.exists() {
        return Ok(Vec::new());
    }
    let lower_query = query.to_lowercase();
    let query_terms = source_query_terms(&lower_query);
    let mut refs = Vec::new();
    let mut seen_files = 0usize;
    for entry in WalkDir::new(&root).into_iter().filter_map(Result::ok) {
        if !entry.file_type().is_file() {
            continue;
        }
        let rel = relative_to_project(project_path, entry.path());
        if is_hidden_rel(&rel) {
            continue;
        }
        seen_files += 1;
        if seen_files > MAX_SOURCE_SEARCH_FILES {
            eprintln!(
                "[Agent] source.search stopped after {MAX_SOURCE_SEARCH_FILES} files in {project_path}"
            );
            break;
        }
        let Some(ext) = entry
            .path()
            .extension()
            .and_then(|s| s.to_str())
            .map(|s| s.to_ascii_lowercase())
        else {
            continue;
        };
        let content = if matches!(
            ext.as_str(),
            "md" | "markdown"
                | "org"
                | "txt"
                | "json"
                | "csv"
                | "tsv"
                | "yaml"
                | "yml"
                | "xml"
                | "html"
        ) {
            let Ok(content) = fs::read_to_string(entry.path()) else {
                continue;
            };
            content
        } else if matches!(
            ext.as_str(),
            "pdf"
                | "doc"
                | "docx"
                | "pptx"
                | "xls"
                | "xlsx"
                | "odt"
                | "ods"
                | "odp"
                | "epub"
                | "mobi"
        ) {
            let Some(content) = crate::commands::fs::read_preprocessed_cache(entry.path()) else {
                continue;
            };
            content
        } else {
            continue;
        };
        let lower = content.to_lowercase();
        let matched = std::iter::once(lower_query.as_str())
            .chain(query_terms.iter().map(String::as_str))
            .find_map(|term| lower.find(term).map(|idx| (idx, term.len())));
        let Some((byte_idx, _matched_len)) = matched else {
            continue;
        };
        refs.push(AgentReference {
            title: entry
                .path()
                .file_name()
                .and_then(|s| s.to_str())
                .unwrap_or(&rel)
                .to_string(),
            path: rel,
            kind: "source".to_string(),
            snippet: Some(snippet_around_byte(
                &content,
                byte_idx,
                MAX_SOURCE_SNIPPET_CHARS,
            )),
            score: None,
            knowledge_context: None,
        });
        if refs.len() >= top_k.clamp(1, 10) {
            break;
        }
    }
    Ok(refs)
}

fn source_query_terms(query: &str) -> Vec<String> {
    query
        .split(|c: char| c.is_whitespace() || matches!(c, ',' | '，' | ';' | '；' | ':' | '：'))
        .map(str::trim)
        .filter(|term| term.chars().count() >= 2)
        .filter(|term| {
            !matches!(
                *term,
                "raw"
                    | "source"
                    | "sources"
                    | "file"
                    | "files"
                    | "原始资料"
                    | "原始文件"
                    | "源文件"
            )
        })
        .map(ToString::to_string)
        .collect()
}

fn safe_project_join(project_path: &str, rel: &str) -> Result<PathBuf, String> {
    let root = Path::new(project_path);
    let rel_path = Path::new(rel);
    if rel_path.is_absolute()
        || rel_path
            .components()
            .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
    {
        return Err("path must be project-relative".to_string());
    }
    let joined = root.join(rel_path);
    if joined.exists() {
        let root_canon = root
            .canonicalize()
            .map_err(|err| format!("Failed to resolve project path: {err}"))?;
        let joined_canon = joined
            .canonicalize()
            .map_err(|err| format!("Failed to resolve requested path: {err}"))?;
        if !joined_canon.starts_with(root_canon) {
            return Err("path escapes project directory".to_string());
        }
    }
    Ok(joined)
}

fn ensure_existing_ancestor_bound(project_path: &str, path: &Path) -> Result<(), String> {
    let mut cursor = path;
    while !cursor.exists() {
        cursor = cursor
            .parent()
            .ok_or_else(|| "path must have an existing project ancestor".to_string())?;
    }
    ensure_project_bound_path(project_path, cursor)
}

fn ensure_project_bound_path(project_path: &str, path: &Path) -> Result<(), String> {
    let root_canon = Path::new(project_path)
        .canonicalize()
        .map_err(|err| format!("Failed to resolve project path: {err}"))?;
    let path_canon = path
        .canonicalize()
        .map_err(|err| format!("Failed to resolve requested path: {err}"))?;
    if !path_canon.starts_with(root_canon) {
        return Err("path escapes project directory".to_string());
    }
    Ok(())
}

fn is_public_read_rel(rel: &str) -> bool {
    let lower = rel.to_ascii_lowercase();
    if lower.split('/').any(|segment| segment.starts_with('.')) {
        return false;
    }
    lower == "purpose.md"
        || lower == "schema.md"
        || lower.starts_with("wiki/")
        || lower.starts_with("raw/sources/")
}

fn is_hidden_rel(rel: &str) -> bool {
    normalize_rel_path(rel)
        .split('/')
        .any(|segment| segment.starts_with('.'))
}

fn normalize_rel_path(path: &str) -> String {
    path.trim()
        .replace('\\', "/")
        .trim_start_matches('/')
        .to_string()
}

fn normalize_wiki_write_path(path: &str) -> Result<String, String> {
    let rel = normalize_rel_path(path);
    let lower = rel.to_ascii_lowercase();
    if !lower.starts_with("wiki/") || !lower.ends_with(".md") {
        return Err("wiki.write_page path must be a Markdown file under wiki/".to_string());
    }
    if lower.split('/').any(|segment| segment.starts_with('.')) {
        return Err("wiki.write_page cannot write hidden paths".to_string());
    }
    let rel_path = Path::new(&rel);
    if rel_path.is_absolute()
        || rel_path
            .components()
            .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
    {
        return Err("wiki.write_page path must stay inside the project".to_string());
    }
    for segment in rel.split('/') {
        validate_portable_path_segment(segment)?;
    }
    Ok(rel)
}

fn normalize_workspace_write_path(path: &str) -> Result<String, String> {
    let rel = normalize_rel_path(path);
    let lower = rel.to_ascii_lowercase();
    if rel.is_empty()
        || lower.starts_with("wiki/")
        || lower.starts_with("raw/")
        || lower.split('/').any(|segment| segment.starts_with('.'))
    {
        return Err(
            "workspace.write_file path must be a relative file under agent-workspace".to_string(),
        );
    }
    let rel_path = Path::new(&rel);
    if rel_path.is_absolute()
        || rel_path
            .components()
            .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_)))
    {
        return Err("workspace.write_file path must stay inside agent-workspace".to_string());
    }
    for segment in rel.split('/') {
        validate_workspace_path_segment(segment)?;
    }
    Ok(rel)
}

fn validate_workspace_path_segment(segment: &str) -> Result<(), String> {
    validate_portable_path_segment(segment)
        .map_err(|err| err.replace("wiki.write_page", "workspace.write_file"))
}

fn validate_portable_path_segment(segment: &str) -> Result<(), String> {
    if segment.is_empty() {
        return Err("wiki.write_page path contains an empty segment".to_string());
    }
    if segment.ends_with([' ', '.']) {
        return Err(
            "wiki.write_page path contains a segment ending with a space or dot, which is not portable to Windows"
                .to_string(),
        );
    }
    if segment
        .chars()
        .any(|ch| matches!(ch, '<' | '>' | ':' | '"' | '|' | '?' | '*') || ch <= '\u{1f}')
    {
        return Err(
            "wiki.write_page path contains characters that are invalid on Windows".to_string(),
        );
    }
    let stem = segment
        .split('.')
        .next()
        .unwrap_or(segment)
        .trim_end_matches(' ')
        .to_ascii_uppercase();
    if matches!(
        stem.as_str(),
        "CON"
            | "PRN"
            | "AUX"
            | "NUL"
            | "COM1"
            | "COM2"
            | "COM3"
            | "COM4"
            | "COM5"
            | "COM6"
            | "COM7"
            | "COM8"
            | "COM9"
            | "LPT1"
            | "LPT2"
            | "LPT3"
            | "LPT4"
            | "LPT5"
            | "LPT6"
            | "LPT7"
            | "LPT8"
            | "LPT9"
    ) {
        return Err("wiki.write_page path uses a Windows reserved device name".to_string());
    }
    Ok(())
}

fn extract_markdown_title(content: &str) -> Option<String> {
    for line in content.lines().take(80) {
        let trimmed = line.trim();
        if let Some(title) = trimmed.strip_prefix("title:") {
            let title = title.trim().trim_matches('"').trim_matches('\'');
            if !title.is_empty() {
                return Some(title.to_string());
            }
        }
        if let Some(heading) = trimmed.strip_prefix("# ") {
            let heading = heading.trim();
            if !heading.is_empty() {
                return Some(heading.to_string());
            }
        }
    }
    None
}

fn collapse_markdown_preview(content: &str) -> String {
    content
        .lines()
        .filter(|line| {
            let trimmed = line.trim();
            !trimmed.is_empty() && trimmed != "---" && !trimmed.starts_with("title:")
        })
        .take(12)
        .collect::<Vec<_>>()
        .join(" ")
}

fn relative_to_project(project_path: &str, path: &Path) -> String {
    path.strip_prefix(project_path)
        .unwrap_or(path)
        .to_string_lossy()
        .replace('\\', "/")
        .trim_start_matches('/')
        .to_string()
}

fn snippet_around_byte(content: &str, byte_idx: usize, max_chars: usize) -> String {
    let char_idx = content[..byte_idx.min(content.len())].chars().count();
    let start = char_idx.saturating_sub(max_chars / 2);
    let mut snippet = content
        .chars()
        .skip(start)
        .take(max_chars)
        .collect::<String>();
    if start > 0 {
        snippet.insert_str(0, "...");
    }
    if content.chars().count() > start + max_chars {
        snippet.push_str("...");
    }
    snippet.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn trim_text(value: &str, max_chars: usize) -> String {
    if value.chars().count() <= max_chars {
        value.to_string()
    } else {
        format!("{}...", value.chars().take(max_chars).collect::<String>())
    }
}

fn url_encode(value: &str) -> String {
    value
        .bytes()
        .flat_map(|byte| match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                vec![byte as char]
            }
            b' ' => vec!['+'],
            _ => format!("%{byte:02X}").chars().collect(),
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use std::fs;

    use uuid::Uuid;

    use super::*;

    #[test]
    fn builtin_tool_specs_include_expected_tools() {
        let names = builtin_tool_specs()
            .into_iter()
            .map(|tool| tool.name)
            .collect::<Vec<_>>();
        assert!(names.contains(&"wiki.search".to_string()));
        assert!(names.contains(&"wiki.read_page".to_string()));
        assert!(names.contains(&"source.search".to_string()));
        assert!(names.contains(&"graph.search".to_string()));
        assert!(names.contains(&"anytxt.search".to_string()));
        assert!(names.contains(&"wiki.write_page".to_string()));
        assert!(names.contains(&"llm.generate".to_string()));
        assert!(names.contains(&"skills.load".to_string()));
        assert!(names.contains(&"skill.read_file".to_string()));
        assert!(names.contains(&"workspace.write_file".to_string()));
        assert!(names.contains(&"workspace.append_file".to_string()));
        assert!(names.contains(&"shell.exec".to_string()));
    }

    #[test]
    fn read_wiki_page_rejects_traversal() {
        let err = read_wiki_page("/tmp/project", "../secret.md").unwrap_err();
        assert!(err.contains("wiki.read_page"));
    }

    #[test]
    fn knowledge_context_indexes_tags_links_backlinks_and_latest_version() {
        let root = std::env::temp_dir().join(format!("llm-wiki-context-{}", Uuid::new_v4()));
        fs::create_dir_all(root.join(".llm-wiki")).unwrap();
        fs::create_dir_all(root.join("wiki/entities")).unwrap();
        let alpha = root.join("wiki/entities/alpha.md");
        let beta = root.join("wiki/entities/beta.md");
        fs::write(
            &alpha,
            "---\r\ntitle: Alpha\r\ntags: [core, test]\r\n---\r\n# Alpha\r\n\r\n[[Beta]]",
        )
        .unwrap();
        fs::write(
            &beta,
            "---\ntitle: Beta\ntags:\n  - linked\n---\n# Beta\n\n[[entities/alpha]]",
        )
        .unwrap();
        crate::commands::file_history::record_file_version(&alpha, "agent", "test.write");

        let mut contexts = build_knowledge_context_index(root.to_str().unwrap());
        let mut alpha_context = contexts.remove("wiki/entities/alpha.md");
        attach_latest_version(
            root.to_str().unwrap(),
            "wiki/entities/alpha.md",
            &mut alpha_context,
        );
        let alpha_context = alpha_context.as_ref().unwrap();
        let beta_context = contexts.get("wiki/entities/beta.md").unwrap();

        assert_eq!(alpha_context.tags, vec!["core", "test"]);
        assert_eq!(alpha_context.outgoing_links, vec!["Beta"]);
        assert_eq!(alpha_context.backlinks, vec!["wiki/entities/beta.md"]);
        assert_eq!(alpha_context.link_count, 2);
        assert_eq!(
            alpha_context.latest_version.as_ref().unwrap().tool,
            "test.write"
        );
        assert_eq!(beta_context.tags, vec!["linked"]);
        assert_eq!(beta_context.backlinks, vec!["wiki/entities/alpha.md"]);

        let _ = fs::remove_dir_all(root);
    }

    #[tokio::test]
    async fn registry_executes_declared_read_page_and_deep_tools() {
        let root = std::env::temp_dir().join(format!("llm-wiki-tool-registry-{}", Uuid::new_v4()));
        fs::create_dir_all(root.join("wiki").join("concepts")).unwrap();
        fs::write(root.join("wiki/concepts/a.md"), "# A\n\nBody").unwrap();

        let registry = BuiltinToolRegistry;
        let context = ToolContext {
            project_path: root.to_str().unwrap(),
            embedding_config: None,
            web_search_config: None,
            anytxt_config: None,
        };
        let read = registry
            .execute(
                "wiki.read_page",
                json!({ "path": "wiki/concepts/a.md" }),
                context.clone(),
            )
            .await
            .unwrap();
        assert_eq!(read["path"], "wiki/concepts/a.md");
        assert!(read["content"].as_str().unwrap().contains("Body"));

        let deep = registry
            .execute("deep_research.run", json!({ "query": "topic" }), context)
            .await
            .unwrap();
        assert_eq!(deep["status"], "orchestrated_by_agent_runtime");
        let _ = fs::remove_dir_all(root);
    }

    #[tokio::test]
    async fn registry_executes_project_scoped_shell_command() {
        let root = std::env::temp_dir().join(format!("llm-wiki-shell-tool-{}", Uuid::new_v4()));
        fs::create_dir_all(&root).unwrap();
        let registry = BuiltinToolRegistry;
        let context = ToolContext {
            project_path: root.to_str().unwrap(),
            embedding_config: None,
            web_search_config: None,
            anytxt_config: None,
        };
        let output = registry
            .execute(
                "shell.exec",
                json!({ "command": "echo skill-ok", "timeoutSeconds": 5 }),
                context,
            )
            .await
            .unwrap();
        assert!(output["stdout"].as_str().unwrap().contains("skill-ok"));
        assert_eq!(output["timedOut"], false);
        let _ = fs::remove_dir_all(root);
    }

    #[tokio::test]
    async fn shell_exec_timeout_returns_without_unbounded_wait() {
        let root = std::env::temp_dir().join(format!("llm-wiki-shell-timeout-{}", Uuid::new_v4()));
        fs::create_dir_all(&root).unwrap();
        #[cfg(windows)]
        let command = "ping -n 6 127.0.0.1 > nul";
        #[cfg(not(windows))]
        let command = "sleep 5";
        let output = run_shell_exec(root.to_str().unwrap(), command, 1)
            .await
            .unwrap();
        assert!(output.timed_out);
        assert!(output.stderr.contains("timed out"));
        let _ = fs::remove_dir_all(root);
    }

    #[cfg(not(windows))]
    #[tokio::test]
    async fn shell_exec_does_not_wait_forever_for_background_pipe_holders() {
        let root = std::env::temp_dir().join(format!("llm-wiki-shell-bg-{}", Uuid::new_v4()));
        fs::create_dir_all(&root).unwrap();
        let output = timeout(
            Duration::from_secs(3),
            run_shell_exec(root.to_str().unwrap(), "sleep 5 &", 5),
        )
        .await
        .expect("shell.exec should not hang on background grandchildren")
        .unwrap();
        assert!(output.stdout.contains("output was still open"));
        let _ = fs::remove_dir_all(root);
    }

    #[cfg(not(windows))]
    #[tokio::test]
    async fn shell_exec_sanitizes_environment() {
        let root = std::env::temp_dir().join(format!("llm-wiki-shell-env-{}", Uuid::new_v4()));
        fs::create_dir_all(&root).unwrap();
        std::env::set_var("LLM_WIKI_SECRET_TEST_SENTINEL", "must-not-leak");
        let output = run_shell_exec(root.to_str().unwrap(), "env", 5)
            .await
            .unwrap();
        std::env::remove_var("LLM_WIKI_SECRET_TEST_SENTINEL");
        assert!(output
            .stdout
            .contains(&format!("LLM_WIKI_PROJECT={}", root.to_string_lossy())));
        assert!(output.stdout.contains(&format!(
            "LLM_WIKI_AGENT_WORKSPACE={}",
            root.join(AGENT_WORKSPACE_DIR).to_string_lossy()
        )));
        assert!(!output.stdout.contains("LLM_WIKI_SECRET_TEST_SENTINEL="));
        let _ = fs::remove_dir_all(root);
    }

    #[tokio::test]
    async fn shell_exec_runs_from_visible_agent_workspace() {
        let root =
            std::env::temp_dir().join(format!("llm-wiki-shell-workspace-{}", Uuid::new_v4()));
        fs::create_dir_all(&root).unwrap();
        #[cfg(windows)]
        let command = "cd && echo hello>generated.txt";
        #[cfg(not(windows))]
        let command = "pwd && printf hello > generated.txt";

        let output = run_shell_exec(root.to_str().unwrap(), command, 5)
            .await
            .unwrap();
        let workspace = root.join(AGENT_WORKSPACE_DIR);

        assert!(workspace.is_dir());
        assert_eq!(
            fs::read_to_string(workspace.join("generated.txt"))
                .unwrap()
                .trim(),
            "hello"
        );
        assert!(!root.join("generated.txt").exists());
        assert!(output
            .stdout
            .replace('\\', "/")
            .contains(&workspace.to_string_lossy().replace('\\', "/")));
        assert!(output
            .generated_files
            .iter()
            .any(|file| file.path == "agent-workspace/generated.txt"));
        let _ = fs::remove_dir_all(root);
    }

    #[tokio::test]
    async fn shell_exec_reports_changed_workspace_files() {
        let root =
            std::env::temp_dir().join(format!("llm-wiki-shell-generated-{}", Uuid::new_v4()));
        fs::create_dir_all(&root).unwrap();
        #[cfg(windows)]
        let command = "mkdir images && echo image>images\\cover.png";
        #[cfg(not(windows))]
        let command = "mkdir -p images && printf image > images/cover.png";

        let output = run_shell_exec(root.to_str().unwrap(), command, 5)
            .await
            .unwrap();

        assert!(output
            .generated_files
            .iter()
            .any(|file| { file.path == "agent-workspace/images/cover.png" && file.bytes > 0 }));
        let _ = fs::remove_dir_all(root);
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn shell_exec_rejects_symlinked_agent_workspace_escape() {
        use std::os::unix::fs::symlink;

        let root =
            std::env::temp_dir().join(format!("llm-wiki-shell-workspace-link-{}", Uuid::new_v4()));
        let outside = std::env::temp_dir().join(format!(
            "llm-wiki-shell-workspace-outside-{}",
            Uuid::new_v4()
        ));
        fs::create_dir_all(&root).unwrap();
        fs::create_dir_all(&outside).unwrap();
        symlink(&outside, root.join(AGENT_WORKSPACE_DIR)).unwrap();

        let err = run_shell_exec(root.to_str().unwrap(), "pwd", 5)
            .await
            .unwrap_err();

        assert!(err.contains("escapes project directory"));
        let _ = fs::remove_dir_all(root);
        let _ = fs::remove_dir_all(outside);
    }

    #[test]
    fn changed_workspace_files_detects_same_size_rewrites() {
        let root =
            std::env::temp_dir().join(format!("llm-wiki-shell-same-size-{}", Uuid::new_v4()));
        let workspace = root.join(AGENT_WORKSPACE_DIR);
        fs::create_dir_all(&workspace).unwrap();
        let file = workspace.join("artifact.html");
        fs::write(&file, "before").unwrap();

        let before = snapshot_workspace_files(&workspace);
        fs::write(&file, "after!").unwrap();
        let changed = changed_workspace_files(&workspace, before);

        assert!(changed
            .iter()
            .any(|file| file.path == "agent-workspace/artifact.html"));
        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn workspace_write_file_writes_only_visible_agent_workspace_files() {
        let root =
            std::env::temp_dir().join(format!("llm-wiki-workspace-write-{}", Uuid::new_v4()));
        fs::create_dir_all(&root).unwrap();

        let written =
            write_workspace_file(root.to_str().unwrap(), "cover-image/cover.svg", "<svg/>")
                .unwrap();
        assert_eq!(written.path, "agent-workspace/cover-image/cover.svg");
        assert!(!written.existed_before);
        assert_eq!(written.previous_content, None);
        assert_eq!(
            fs::read_to_string(root.join("agent-workspace/cover-image/cover.svg")).unwrap(),
            "<svg/>"
        );
        assert!(write_workspace_file(root.to_str().unwrap(), "../escape.txt", "x").is_err());
        assert!(write_workspace_file(root.to_str().unwrap(), "wiki/page.md", "x").is_err());
        assert!(write_workspace_file(root.to_str().unwrap(), ".hidden/file.txt", "x").is_err());
        assert!(write_workspace_file(root.to_str().unwrap(), "cover./file.txt", "x").is_err());
        assert!(write_workspace_file(root.to_str().unwrap(), "cover /file.txt", "x").is_err());
        assert!(write_workspace_file(
            root.to_str().unwrap(),
            "large.txt",
            &"x".repeat(MAX_WORKSPACE_WRITE_BYTES + 1)
        )
        .unwrap_err()
        .contains("too large"));
        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn workspace_append_file_extends_visible_workspace_files() {
        let root =
            std::env::temp_dir().join(format!("llm-wiki-workspace-append-{}", Uuid::new_v4()));
        fs::create_dir_all(&root).unwrap();

        write_workspace_file(root.to_str().unwrap(), "ppt/index.html", "<html>").unwrap();
        let appended =
            append_workspace_file(root.to_str().unwrap(), "ppt/index.html", "</html>").unwrap();

        assert_eq!(appended.path, "agent-workspace/ppt/index.html");
        assert_eq!(appended.bytes, "<html></html>".len());
        assert!(appended.existed_before);
        assert_eq!(appended.previous_content.as_deref(), Some("<html>"));
        assert_eq!(
            fs::read_to_string(root.join("agent-workspace/ppt/index.html")).unwrap(),
            "<html></html>"
        );
        assert!(append_workspace_file(root.to_str().unwrap(), "../escape.txt", "x").is_err());
        assert!(append_workspace_file(root.to_str().unwrap(), ".hidden/file.txt", "x").is_err());
        let _ = fs::remove_dir_all(root);
    }

    #[cfg(unix)]
    #[test]
    fn workspace_write_file_rejects_target_symlink_escape() {
        use std::os::unix::fs::symlink;

        let root =
            std::env::temp_dir().join(format!("llm-wiki-workspace-symlink-{}", Uuid::new_v4()));
        let outside =
            std::env::temp_dir().join(format!("llm-wiki-workspace-outside-{}", Uuid::new_v4()));
        fs::create_dir_all(root.join(AGENT_WORKSPACE_DIR)).unwrap();
        fs::write(&outside, "original").unwrap();
        symlink(&outside, root.join(AGENT_WORKSPACE_DIR).join("escape.txt")).unwrap();

        assert!(
            write_workspace_file(root.to_str().unwrap(), "escape.txt", "overwrite")
                .unwrap_err()
                .contains("symlink")
        );
        assert_eq!(fs::read_to_string(&outside).unwrap(), "original");
        let _ = fs::remove_dir_all(root);
        let _ = fs::remove_file(outside);
    }

    #[cfg(not(windows))]
    #[tokio::test]
    async fn shell_exec_caps_large_output() {
        let root = std::env::temp_dir().join(format!("llm-wiki-shell-cap-{}", Uuid::new_v4()));
        fs::create_dir_all(&root).unwrap();
        let output = run_shell_exec(root.to_str().unwrap(), "printf '%050000d' 0", 5)
            .await
            .unwrap();
        assert!(output.stdout.chars().count() <= MAX_SHELL_OUTPUT_CHARS + 3);
        assert!(output.stdout.ends_with("..."));
        let _ = fs::remove_dir_all(root);
    }

    #[tokio::test]
    async fn shell_exec_rejects_invalid_inputs() {
        let root = std::env::temp_dir().join(format!("llm-wiki-shell-invalid-{}", Uuid::new_v4()));
        fs::create_dir_all(&root).unwrap();
        assert!(run_shell_exec(root.to_str().unwrap(), "", 1)
            .await
            .unwrap_err()
            .contains("empty"));
        assert!(run_shell_exec(
            root.to_str().unwrap(),
            &"x".repeat(MAX_SHELL_COMMAND_CHARS + 1),
            1
        )
        .await
        .unwrap_err()
        .contains("too long"));
        let missing = root.join("missing");
        assert!(run_shell_exec(missing.to_str().unwrap(), "echo no", 1)
            .await
            .unwrap_err()
            .contains("not available"));
        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn write_wiki_page_rejects_unsafe_paths_and_writes_markdown() {
        let root = std::env::temp_dir().join(format!("llm-wiki-agent-write-{}", Uuid::new_v4()));
        fs::create_dir_all(root.join("wiki")).unwrap();

        assert!(write_wiki_page_with_options(
            root.to_str().unwrap(),
            "../secret.md",
            "# Secret",
            false
        )
        .is_err());
        assert!(write_wiki_page_with_options(
            root.to_str().unwrap(),
            "raw/sources/a.md",
            "# A",
            false
        )
        .is_err());
        assert!(write_wiki_page_with_options(
            root.to_str().unwrap(),
            "wiki/.hidden/a.md",
            "# A",
            false
        )
        .is_err());
        assert!(
            write_wiki_page_with_options(root.to_str().unwrap(), "wiki/aux.md", "# A", false)
                .is_err()
        );
        assert!(
            write_wiki_page_with_options(root.to_str().unwrap(), "wiki/con.md", "# A", false)
                .is_err()
        );
        assert!(
            write_wiki_page_with_options(root.to_str().unwrap(), "wiki/a:b.md", "# A", false)
                .is_err()
        );
        assert!(
            write_wiki_page_with_options(root.to_str().unwrap(), "wiki/a?b.md", "# A", false)
                .is_err()
        );
        assert!(write_wiki_page_with_options(
            root.to_str().unwrap(),
            "wiki/topic./a.md",
            "# A",
            false
        )
        .is_err());
        assert!(write_wiki_page_with_options(
            root.to_str().unwrap(),
            "wiki/topic /a.md",
            "# A",
            false
        )
        .is_err());
        assert!(write_wiki_page_with_options(
            root.to_str().unwrap(),
            "wiki/queries/huge.md",
            &"x".repeat(MAX_WRITE_PAGE_BYTES + 1),
            false,
        )
        .is_err());

        let reference = write_wiki_page_with_options(
            root.to_str().unwrap(),
            "wiki/queries/new-page.md",
            "---\ntitle: New Page\n---\n# New Page\n\nBody",
            false,
        )
        .unwrap();
        assert_eq!(reference.title, "New Page");
        assert_eq!(reference.path, "wiki/queries/new-page.md");
        assert!(root.join("wiki/queries/new-page.md").exists());
        let overwrite_err = write_wiki_page_with_options(
            root.to_str().unwrap(),
            "wiki/queries/new-page.md",
            "# Replaced",
            false,
        )
        .unwrap_err();
        assert!(overwrite_err.contains("refuses to overwrite"));
        let overwritten = write_wiki_page_with_options(
            root.to_str().unwrap(),
            "wiki/queries/new-page.md",
            "# Replaced",
            true,
        )
        .unwrap();
        assert_eq!(overwritten.title, "Replaced");
        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn wiki_write_activity_captures_create_and_overwrite_state() {
        let root =
            std::env::temp_dir().join(format!("llm-wiki-agent-write-activity-{}", Uuid::new_v4()));
        fs::create_dir_all(root.join("wiki")).unwrap();

        let created = write_wiki_page_with_activity(
            root.to_str().unwrap(),
            "wiki/page.md",
            "# Original",
            false,
        )
        .unwrap();
        assert!(!created.existed_before);
        assert_eq!(created.previous_content, None);

        let modified = write_wiki_page_with_activity(
            root.to_str().unwrap(),
            "wiki/page.md",
            "# Updated",
            true,
        )
        .unwrap();
        assert!(modified.existed_before);
        assert_eq!(modified.previous_content.as_deref(), Some("# Original"));
        assert_eq!(modified.reference.path, "wiki/page.md");
        let _ = fs::remove_dir_all(root);
    }

    #[cfg(unix)]
    #[test]
    fn write_wiki_page_rejects_symlink_parent_escape_for_new_files() {
        use std::os::unix::fs::symlink;

        let root = std::env::temp_dir().join(format!("llm-wiki-agent-symlink-{}", Uuid::new_v4()));
        let outside =
            std::env::temp_dir().join(format!("llm-wiki-agent-outside-{}", Uuid::new_v4()));
        fs::create_dir_all(root.join("wiki")).unwrap();
        fs::create_dir_all(&outside).unwrap();
        symlink(&outside, root.join("wiki").join("linked")).unwrap();

        let err = write_wiki_page_with_options(
            root.to_str().unwrap(),
            "wiki/linked/newsub/escape.md",
            "# Escape",
            false,
        )
        .unwrap_err();
        assert!(err.contains("escapes project directory"));
        assert!(!outside.join("newsub").exists());
        let _ = fs::remove_dir_all(root);
        let _ = fs::remove_dir_all(outside);
    }

    #[test]
    fn search_sources_returns_source_references() {
        let root = std::env::temp_dir().join(format!("llm-wiki-source-search-{}", Uuid::new_v4()));
        let source_dir = root.join("raw").join("sources");
        fs::create_dir_all(&source_dir).unwrap();
        fs::write(source_dir.join("paper.txt"), "Coal mine safety case study.").unwrap();

        let refs = search_sources(root.to_str().unwrap(), "safety", 5).unwrap();
        assert_eq!(refs.len(), 1);
        assert_eq!(refs[0].kind, "source");
        assert!(refs[0].snippet.as_deref().unwrap().contains("safety"));
        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn search_sources_reads_org_and_fresh_binary_extraction_cache() {
        let root = std::env::temp_dir().join(format!("llm-wiki-source-cache-{}", Uuid::new_v4()));
        let source_dir = root.join("raw").join("sources");
        fs::create_dir_all(source_dir.join(".cache")).unwrap();
        fs::write(source_dir.join("notes.org"), "* Policy\nExact org wording").unwrap();
        let pdf = source_dir.join("regulation.pdf");
        fs::write(&pdf, b"%PDF placeholder").unwrap();
        fs::write(
            source_dir.join(".cache").join("regulation.pdf.txt"),
            "Exact cached regulation wording",
        )
        .unwrap();

        let org_refs = search_sources(root.to_str().unwrap(), "org wording", 5).unwrap();
        let pdf_refs = search_sources(root.to_str().unwrap(), "cached regulation", 5).unwrap();

        assert!(org_refs
            .iter()
            .any(|reference| reference.path == "raw/sources/notes.org"));
        let pdf_ref = pdf_refs
            .iter()
            .find(|reference| reference.path == "raw/sources/regulation.pdf")
            .unwrap();
        assert!(pdf_ref
            .snippet
            .as_deref()
            .unwrap()
            .contains("cached regulation"));
        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn source_and_graph_search_skip_hidden_paths() {
        let root = std::env::temp_dir().join(format!("llm-wiki-hidden-search-{}", Uuid::new_v4()));
        fs::create_dir_all(root.join("raw/sources/.cache")).unwrap();
        fs::create_dir_all(root.join("wiki/.hidden")).unwrap();
        fs::write(root.join("raw/sources/.cache/secret.txt"), "needle secret").unwrap();
        fs::write(
            root.join("wiki/.hidden/secret.md"),
            "# Secret\n\nneedle [[A]]",
        )
        .unwrap();

        assert!(search_sources(root.to_str().unwrap(), "needle", 5)
            .unwrap()
            .is_empty());
        assert!(search_graph(root.to_str().unwrap(), "needle", 5)
            .unwrap()
            .is_empty());
        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn search_sources_uses_keyword_terms_from_natural_language() {
        let root = std::env::temp_dir().join(format!("llm-wiki-source-search-{}", Uuid::new_v4()));
        let source_dir = root.join("raw").join("sources");
        fs::create_dir_all(&source_dir).unwrap();
        fs::write(source_dir.join("煤矿.txt"), "煤矿安全治理 source details.").unwrap();

        let refs = search_sources(root.to_str().unwrap(), "原始资料 煤矿安全", 5).unwrap();
        assert_eq!(refs.len(), 1);
        assert_eq!(refs[0].kind, "source");
        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn search_graph_returns_relationship_references() {
        let root = std::env::temp_dir().join(format!("llm-wiki-graph-search-{}", Uuid::new_v4()));
        let wiki_dir = root.join("wiki").join("concepts");
        fs::create_dir_all(&wiki_dir).unwrap();
        fs::write(
            wiki_dir.join("agent.md"),
            "---\ntitle: Agent Graph\n---\n# Agent Graph\n\nLinks to [[Tool Registry]] and [[Context Builder]].",
        )
        .unwrap();
        fs::write(
            wiki_dir.join("tool-registry.md"),
            "---\ntitle: Tool Registry\n---\n# Tool Registry\n\nTool definitions.",
        )
        .unwrap();
        fs::write(
            wiki_dir.join("context-builder.md"),
            "---\ntitle: Context Builder\n---\n# Context Builder\n\nContext assembly.",
        )
        .unwrap();
        fs::write(
            wiki_dir.join("unrelated.md"),
            "---\ntitle: Unrelated Hub\n---\n# Unrelated Hub\n\n[[Missing A]] [[Missing B]] [[Missing C]].",
        )
        .unwrap();

        let refs = search_graph(root.to_str().unwrap(), "Agent Graph", 5).unwrap();
        assert_eq!(refs.len(), 3);
        assert_eq!(refs[0].title, "Agent Graph");
        assert!(refs[0]
            .snippet
            .as_deref()
            .unwrap()
            .contains("matched entity"));
        assert!(refs
            .iter()
            .skip(1)
            .all(|reference| reference.kind == "graph"
                && reference
                    .snippet
                    .as_deref()
                    .unwrap()
                    .contains("direct neighbor")));
        assert!(refs
            .iter()
            .any(|reference| reference.title == "Tool Registry"));
        assert!(refs
            .iter()
            .any(|reference| reference.title == "Context Builder"));
        assert!(!refs
            .iter()
            .any(|reference| reference.title == "Unrelated Hub"));
        let _ = fs::remove_dir_all(root);
    }

    #[test]
    fn searxng_url_normalizes_to_search_endpoint() {
        assert_eq!(
            normalize_searxng_url("search.example.com").unwrap(),
            "https://search.example.com/search"
        );
        assert_eq!(
            normalize_searxng_url("https://search.example.com/search").unwrap(),
            "https://search.example.com/search"
        );
    }

    #[test]
    fn friendly_firecrawl_error_explains_key_free_ip_rejection() {
        let msg = friendly_firecrawl_error("Unfortunately, your IP address looks suspicious");
        assert!(msg.contains("rejected this IP"));
    }

    #[test]
    fn run_web_search_drops_empty_url_results_before_mapping_references() {
        let refs = web_items_to_references(
            vec![
                WebSearchItem {
                    title: "Missing".to_string(),
                    url: String::new(),
                    snippet: "no url".to_string(),
                },
                WebSearchItem {
                    title: "Valid".to_string(),
                    url: "https://example.com".to_string(),
                    snippet: "ok".to_string(),
                },
            ],
            10,
        );
        assert_eq!(refs.len(), 1);
        assert_eq!(refs[0].title, "Valid");
        assert_eq!(refs[0].path, "https://example.com");
    }

    #[test]
    fn web_references_apply_limit_before_empty_url_filter_like_legacy_ui() {
        let refs = web_items_to_references(
            vec![
                WebSearchItem {
                    title: "Missing".to_string(),
                    url: String::new(),
                    snippet: "no url".to_string(),
                },
                WebSearchItem {
                    title: "Valid".to_string(),
                    url: "https://example.com".to_string(),
                    snippet: "ok".to_string(),
                },
            ],
            1,
        );
        assert!(refs.is_empty());
    }

    #[test]
    fn brave_message_without_web_is_treated_as_error() {
        let value = json!({ "message": "invalid subscription token" });
        assert_eq!(
            provider_payload_error("Brave Search", &value),
            Some("Brave Search search failed: invalid subscription token".to_string())
        );
    }

    #[test]
    fn bocha_payload_error_requires_success_code() {
        let failure = json!({ "code": 401, "msg": "invalid api key" });
        assert_eq!(
            provider_payload_error("Bocha Search", &failure),
            Some("Bocha Search failed (code 401): invalid api key".to_string())
        );
        assert_eq!(
            provider_payload_error("Bocha Search", &json!({ "code": 200, "data": {} })),
            None
        );
    }

    #[test]
    fn bocha_results_prefer_summary_and_fall_back_to_snippet() {
        let items = parse_bocha_results(json!({
            "code": 200,
            "data": {
                "webPages": {
                    "value": [
                        {
                            "name": "Summary result",
                            "url": "https://example.com/summary",
                            "snippet": "short",
                            "summary": "long summary"
                        },
                        {
                            "name": "Snippet result",
                            "url": "https://example.com/snippet",
                            "summary": null,
                            "snippet": "fallback snippet"
                        }
                    ]
                }
            }
        }));
        assert_eq!(items.len(), 2);
        assert_eq!(items[0].title, "Summary result");
        assert_eq!(items[0].snippet, "long summary");
        assert_eq!(items[1].snippet, "fallback snippet");
    }

    #[test]
    fn bocha_accepts_fifty_results_without_changing_other_provider_limits() {
        assert_eq!(web_search_result_limit("bocha", 100), 50);
        assert_eq!(web_search_result_limit("tavily", 100), 20);
        assert_eq!(web_search_result_limit("bocha", 0), 1);
    }

    #[test]
    fn non_brave_message_does_not_mask_valid_provider_payloads() {
        let value = json!({ "message": "FYI" });
        assert_eq!(provider_payload_error("Tavily", &value), None);
    }

    #[test]
    fn web_result_normalization_accepts_firecrawl_nested_metadata() {
        let items = extract_web_items(
            &json!({
                "data": {
                    "web": [
                        {
                            "metadata": {
                                "title": "Nested",
                                "sourceURL": "https://example.com/nested",
                                "description": "from metadata"
                            }
                        }
                    ]
                }
            }),
            &["data", "results"],
        );
        let item = normalize_web_result(items.into_iter().next().unwrap());
        assert_eq!(item.title, "Nested");
        assert_eq!(item.url, "https://example.com/nested");
        assert_eq!(item.snippet, "from metadata");
    }

    #[test]
    fn url_encode_handles_unicode_terms() {
        assert_eq!(url_encode("煤矿 safety"), "%E7%85%A4%E7%9F%BF+safety");
    }

    #[test]
    fn web_search_config_resolves_active_provider_override() {
        let mut configs = BTreeMap::new();
        configs.insert(
            "searxng".to_string(),
            WebSearchProviderOverride {
                sear_xng_url: Some("https://search.example.com".to_string()),
                ..Default::default()
            },
        );
        let cfg = WebSearchConfig {
            provider: "searxng".to_string(),
            provider_configs: Some(configs),
            ..Default::default()
        }
        .resolved();

        assert_eq!(
            cfg.sear_xng_url.as_deref(),
            Some("https://search.example.com")
        );
    }

    #[test]
    fn extract_anytxt_items_accepts_common_result_shapes() {
        let value = json!({
            "result": {
                "items": [
                    { "path": "/docs/a.pdf", "title": "A", "snippet": "coal mine" }
                ]
            }
        });
        let items = extract_anytxt_items(&value);
        assert_eq!(items.len(), 1);
        assert_eq!(items[0].title, "A");
        assert_eq!(items[0].path, "/docs/a.pdf");
        assert_eq!(items[0].snippet, "coal mine");
    }

    #[test]
    fn extract_anytxt_items_accepts_nested_output_and_field_rows() {
        let value = json!({
            "result": {
                "output": {
                    "field": ["fid", "full_path", "title", "hitText"],
                    "items": [
                        ["42", "/docs/煤矿.pdf", "煤矿资料", "煤矿安全治理片段"]
                    ]
                }
            }
        });
        let items = extract_anytxt_items(&value);
        assert_eq!(items.len(), 1);
        assert_eq!(items[0].fid, "42");
        assert_eq!(items[0].path, "/docs/煤矿.pdf");
        assert_eq!(items[0].title, "煤矿资料");
        assert_eq!(items[0].snippet, "煤矿安全治理片段");
    }

    #[test]
    fn extract_anytxt_items_keeps_fid_only_results_addressable() {
        let value = json!({
            "result": {
                "data": {
                    "results": [
                        { "fid": 99, "snippet": "fragment only" }
                    ]
                }
            }
        });
        let items = extract_anytxt_items(&value);
        assert_eq!(items.len(), 1);
        assert_eq!(items[0].path, "anytxt://99");
        assert_eq!(items[0].snippet, "fragment only");
    }

    #[test]
    fn extract_anytxt_items_accepts_value_shapes() {
        let value = json!({
            "result": {
                "output": {
                    "value": [
                        { "path": "/docs/value.txt", "snippet": "from value" }
                    ]
                }
            }
        });
        let items = extract_anytxt_items(&value);
        assert_eq!(items.len(), 1);
        assert_eq!(items[0].path, "/docs/value.txt");
        assert_eq!(items[0].snippet, "from value");
    }
}
