use std::env;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::{Value, json};
use walkdir::WalkDir;

use crate::config::{
    TierClassifier, effective_config, is_doc_file, is_reserved_dir_name, load_config,
    suggest_categorization,
};

// ---------------------------------------------------------------------------
// Project resolution
// ---------------------------------------------------------------------------

pub struct ResolvedProject {
    pub name: String,
    pub detected_via: &'static str,
    pub repo_path: Option<PathBuf>,
}

/// Resolve the active project name using this priority:
///   1. MCP_PROJECT_NAME env var (explicit override)
///   2. CWD-based auto-detection (walk up path components, match against docs_root)
pub fn resolve_project(docs_root: &Path) -> Option<ResolvedProject> {
    // 1. Explicit env override
    if let Ok(name) = env::var("MCP_PROJECT_NAME") {
        let name = name.trim().to_string();
        let is_single_component = std::path::Path::new(&name)
            .components()
            .all(|c| matches!(c, std::path::Component::Normal(_)))
            && std::path::Path::new(&name).components().count() == 1;
        if !name.is_empty() && is_single_component && docs_root.join(&name).is_dir() {
            let repo_path = detect_repo_path(&name);
            return Some(ResolvedProject {
                name,
                detected_via: "env",
                repo_path,
            });
        }
    }

    // 2. Auto-detect from CWD
    if let Ok(cwd) = env::current_dir() {
        let available: Vec<String> = std::fs::read_dir(docs_root)
            .ok()?
            .filter_map(std::result::Result::ok)
            .filter(|e| e.path().is_dir())
            .filter_map(|e| {
                let name = e.file_name().to_string_lossy().to_string();
                if is_reserved_dir_name(&name) {
                    None
                } else {
                    Some(name)
                }
            })
            .collect();

        let mut path = cwd.as_path();
        loop {
            if let Some(dirname) = path.file_name().and_then(|f| f.to_str())
                && available.iter().any(|p| p == dirname)
            {
                let repo_path = Some(path.to_path_buf());
                return Some(ResolvedProject {
                    name: dirname.to_string(),
                    detected_via: "cwd",
                    repo_path,
                });
            }
            match path.parent() {
                Some(parent) if parent != path => path = parent,
                _ => break,
            }
        }
    }

    None
}

fn detect_repo_path(project_name: &str) -> Option<PathBuf> {
    let cwd = env::current_dir().ok()?;
    let mut path = cwd.as_path();
    loop {
        if let Some(dirname) = path.file_name().and_then(|f| f.to_str())
            && dirname == project_name
        {
            return Some(path.to_path_buf());
        }
        match path.parent() {
            Some(parent) if parent != path => path = parent,
            _ => break,
        }
    }
    None
}

// ---------------------------------------------------------------------------
// Tool: check_doc_changes
// ---------------------------------------------------------------------------

pub fn tool_check_doc_changes(docs_root: &Path, args: Value) -> Result<Value> {
    let mut result = crate::index::check_doc_changes(docs_root);

    let auto_rebuild = args
        .get("auto_rebuild")
        .and_then(serde_json::Value::as_bool)
        .unwrap_or(false);

    if auto_rebuild && result["is_stale"].as_bool().unwrap_or(false) {
        let rebuild_result = crate::index::build_index(docs_root)?;
        result["rebuild"] = rebuild_result;
    }

    Ok(result)
}

// ---------------------------------------------------------------------------
// Tool: get_project_docs_overview
// ---------------------------------------------------------------------------

pub fn tool_overview(
    project_root: &Path,
    project_name: &str,
    detected_via: &str,
    repo_path: Option<&Path>,
) -> Result<Value> {
    let cfg = repo_path
        .map(effective_config)
        .unwrap_or_else(|| load_config().clone());
    let classifier = TierClassifier::new(&cfg);

    let mut bridge_files = Vec::new();

    for entry in WalkDir::new(project_root)
        .into_iter()
        .filter_map(Result::ok)
        .filter(|e| e.file_type().is_file())
    {
        let path = entry.path();
        if !is_doc_file(path) {
            continue;
        }

        let rel = path
            .strip_prefix(project_root)
            .unwrap_or(path)
            .to_string_lossy()
            .to_string();

        let meta = entry.metadata()?;
        bridge_files.push(json!({
            "path": rel,
            "size_bytes": meta.len(),
            "tier": classifier.classify(&rel),
        }));
    }

    // Scan project repo (root + docs/) if available
    let mut repo_files = Vec::new();
    if let Some(rp) = repo_path {
        for entry in std::fs::read_dir(rp).into_iter().flatten().flatten() {
            let path = entry.path();
            if path.is_file() && is_doc_file(&path) {
                let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
                let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
                repo_files.push(json!({
                    "path": filename,
                    "size_bytes": size,
                    "tier": classifier.classify(filename),
                }));
            }
        }
        let docs_dir = rp.join("docs");
        if docs_dir.is_dir() {
            for entry in WalkDir::new(&docs_dir)
                .into_iter()
                .filter_map(Result::ok)
                .filter(|e| e.file_type().is_file())
            {
                let path = entry.path();
                if !is_doc_file(path) {
                    continue;
                }
                let rel = path
                    .strip_prefix(rp)
                    .unwrap_or(path)
                    .to_string_lossy()
                    .to_string();
                let size = entry.metadata().ok().map(|m| m.len()).unwrap_or(0);
                repo_files.push(json!({
                    "path": rel,
                    "size_bytes": size,
                    "tier": classifier.classify(&rel),
                }));
            }
        }
    }

    let total = bridge_files.len() + repo_files.len();

    let mut result = json!({
        "project_name": project_name,
        "detected_via": detected_via,
        "total_files": total,
        "doc_repo": {
            "path": project_root.to_string_lossy(),
            "files": bridge_files,
            "count": bridge_files.len(),
        },
        "diagram_format": cfg.diagram_format(),
        "hint": "Start with PRD.md (what/why), ARCHITECTURE.md (how), PROGRESS.md (status)",
        "diagram_hint": format!("Use {} syntax when creating or updating diagrams in docs.", cfg.diagram_format()),
    });

    if let Some(rp) = repo_path {
        result["project_repo"] = json!({
            "path": rp.to_string_lossy(),
            "files": repo_files,
            "count": repo_files.len(),
        });
    }

    Ok(result)
}

// ---------------------------------------------------------------------------
// Tool: search_project_docs
// ---------------------------------------------------------------------------

#[derive(Debug, Deserialize)]
struct SearchArgs {
    query: String,
    #[serde(default = "default_search_limit")]
    limit: usize,
    /// Hidden override: when set to `"grep"`, skip ranked/indexed search and
    /// fall through to pure grep even when an index is available.
    #[serde(default)]
    mode: Option<String>,
}

fn default_search_limit() -> usize {
    20
}

fn search_dir_for_query(
    dir: &Path,
    base: &Path,
    query_lower: &str,
    source: &str,
    limit: usize,
    matches: &mut Vec<Value>,
) {
    if !dir.exists() || matches.len() >= limit {
        return;
    }
    for entry in WalkDir::new(dir)
        .into_iter()
        .filter_map(Result::ok)
        .filter(|e| e.file_type().is_file())
    {
        if matches.len() >= limit {
            return;
        }
        let path = entry.path();
        if !is_doc_file(path) {
            continue;
        }
        let content = match std::fs::read_to_string(path) {
            Ok(c) => c,
            Err(e) => {
                eprintln!("[alcove] Failed to read {}: {}", path.display(), e);
                continue;
            }
        };
        let rel = path
            .strip_prefix(base)
            .unwrap_or(path)
            .to_string_lossy()
            .to_string();

        for (idx, line) in content.lines().enumerate() {
            if line.to_lowercase().contains(query_lower) {
                matches.push(json!({
                    "file": rel,
                    "line": idx + 1,
                    "snippet": line.trim(),
                    "source": source,
                }));
                if matches.len() >= limit {
                    return;
                }
            }
        }
    }
}

/// query(이미 trim된 값)가 비어있거나 limit이 0이면 조기 반환 JSON을 Some으로 반환한다.
fn validate_search_args(query_trimmed: &str, limit: usize) -> Option<Value> {
    if query_trimmed.is_empty() {
        return Some(
            json!({ "query": query_trimmed, "matches": [], "truncated": false, "error": "empty query" }),
        );
    }
    if limit == 0 {
        return Some(json!({ "query": query_trimmed, "matches": [], "truncated": false }));
    }
    None
}

/// BM25 결과 항목을 MCP 응답 형식으로 변환한다.
/// include_project가 true이면 "project" 필드를 포함한다.
fn map_bm25_match(m: &Value, include_project: bool) -> Value {
    let base = json!({
        "file": m["file"].as_str().unwrap_or("?"),
        "line": m["line_start"].as_u64().unwrap_or(0),
        "snippet": m["snippet"].as_str().unwrap_or(""),
        "source": "ranked",
        "score": m["score"].as_f64().unwrap_or(0.0),
    });
    if include_project {
        let mut obj = base.as_object().unwrap().clone();
        obj.insert(
            "project".to_string(),
            Value::String(m["project"].as_str().unwrap_or("?").to_string()),
        );
        Value::Object(obj)
    } else {
        base
    }
}

pub fn tool_search(
    project_root: &Path,
    args_value: Value,
    repo_path: Option<&Path>,
) -> Result<Value> {
    let args: SearchArgs = serde_json::from_value(args_value)
        .context("search_project_docs requires { query, limit? }")?;

    let query_trimmed = args.query.trim();
    if let Some(early) = validate_search_args(query_trimmed, args.limit) {
        return Ok(early);
    }

    // Extract project name and docs_root
    let project_name = project_root
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("");
    let docs_root = project_root.parent().unwrap_or(project_root);

    // Try indexed search first (faster and ranked)
    #[cfg(feature = "alcove-full")]
    {
        use crate::config::load_config;
        use crate::embedding::{EmbeddingModelChoice, EmbeddingService};
        use crate::index::search_hybrid;

        if crate::index::index_exists(docs_root) {
            // Get embedding config
            let cfg = load_config();
            let emb_cfg = cfg.embedding_config_with_defaults();

            // Create embedding service if enabled
            if emb_cfg.enabled {
                let model = EmbeddingModelChoice::parse(&emb_cfg.model).unwrap_or_default();
                let service = EmbeddingService::new(crate::config::EmbeddingConfig {
                    model: model.as_str().to_string(),
                    auto_download: emb_cfg.auto_download,
                    cache_dir: emb_cfg
                        .cache_dir
                        .starts_with('~')
                        .then(|| std::env::var("HOME").ok())
                        .flatten()
                        .map(|h| emb_cfg.cache_dir.replacen('~', &h, 1))
                        .unwrap_or_else(|| emb_cfg.cache_dir.clone()),
                    enabled: true,
                    query_cache_size: emb_cfg.query_cache_size,
                });

                // Use hybrid search
                let result = search_hybrid(
                    docs_root,
                    query_trimmed,
                    &service,
                    args.limit,
                    Some(project_name),
                );

                if let Ok(json) = result {
                    // Transform to expected format
                    let matches: Vec<Value> = json["matches"]
                        .as_array()
                        .map(|arr| {
                            arr.iter()
                                .map(|m| {
                                    json!({
                                        "file": m["file"].as_str().unwrap_or("?"),
                                        "line": m["line_start"].as_u64().unwrap_or(0),
                                        "snippet": m["snippet"].as_str().unwrap_or(""),
                                        "source": "hybrid",
                                        "score": m["score"].as_f64().unwrap_or(0.0),
                                    })
                                })
                                .collect()
                        })
                        .unwrap_or_default();

                    return Ok(json!({
                        "query": args.query,
                        "matches": matches,
                        "truncated": json["truncated"].as_bool().unwrap_or(false),
                        "mode": json["mode"].as_str().unwrap_or("hybrid"),
                    }));
                }
            }
        }
    }

    // Fall back to indexed search (BM25 only) if index exists
    if crate::index::index_exists(docs_root) {
        let result =
            crate::index::search_indexed(docs_root, query_trimmed, args.limit, Some(project_name));
        if let Ok(json) = result {
            let matches: Vec<Value> = json["matches"]
                .as_array()
                .map(|arr| arr.iter().map(|m| map_bm25_match(m, false)).collect())
                .unwrap_or_default();

            return Ok(json!({
                "query": args.query,
                "matches": matches,
                "truncated": json["truncated"].as_bool().unwrap_or(false),
                "mode": "ranked",
            }));
        }
    }

    // Fall back to grep search if no index
    let query_lower = query_trimmed.to_lowercase();
    let mut matches = Vec::new();

    // 1. Search alcove folder
    search_dir_for_query(
        project_root,
        project_root,
        &query_lower,
        "alcove",
        args.limit,
        &mut matches,
    );

    // 2. Search project repo (root-level + docs/) if available
    if let Some(rp) = repo_path {
        for entry in std::fs::read_dir(rp).into_iter().flatten().flatten() {
            if matches.len() >= args.limit {
                break;
            }
            let path = entry.path();
            if !path.is_file() || !is_doc_file(&path) {
                continue;
            }
            let content = match std::fs::read_to_string(&path) {
                Ok(c) => c,
                Err(e) => {
                    eprintln!("[alcove] Failed to read {}: {}", path.display(), e);
                    continue;
                }
            };
            let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
            for (idx, line) in content.lines().enumerate() {
                if line.to_lowercase().contains(&query_lower) {
                    matches.push(json!({
                        "file": filename,
                        "line": idx + 1,
                        "snippet": line.trim(),
                        "source": "project-repo",
                    }));
                    if matches.len() >= args.limit {
                        break;
                    }
                }
            }
        }
        search_dir_for_query(
            &rp.join("docs"),
            rp,
            &query_lower,
            "project-repo",
            args.limit,
            &mut matches,
        );
    }

    let truncated = matches.len() >= args.limit;
    Ok(json!({ "query": args.query, "matches": matches, "truncated": truncated, "mode": "grep" }))
}

/// Global search across all projects in docs_root.
pub fn tool_search_global(docs_root: &Path, args_value: Value) -> Result<Value> {
    let args: SearchArgs = serde_json::from_value(args_value)
        .context("search_project_docs requires { query, scope?, limit? }")?;

    let query_trimmed = args.query.trim();
    if let Some(early) = validate_search_args(query_trimmed, args.limit) {
        return Ok(early);
    }

    // Try indexed search first (faster and ranked), unless grep is forced.
    let force_grep = args.mode.as_deref() == Some("grep");
    if !force_grep && crate::index::index_exists(docs_root) {
        let result = crate::index::search_indexed(docs_root, query_trimmed, args.limit, None);
        if let Ok(json) = result {
            let matches: Vec<Value> = json["matches"]
                .as_array()
                .map(|arr| arr.iter().map(|m| map_bm25_match(m, true)).collect())
                .unwrap_or_default();

            return Ok(json!({
                "query": args.query,
                "matches": matches,
                "truncated": json["truncated"].as_bool().unwrap_or(false),
                "mode": "ranked",
            }));
        }
    }

    // Fall back to grep search if no index
    let query_lower = query_trimmed.to_lowercase();
    let mut matches = Vec::new();

    let entries = std::fs::read_dir(docs_root).context("Failed to read DOCS_ROOT directory")?;

    for entry in entries.flatten() {
        if matches.len() >= args.limit {
            break;
        }
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let name = path
            .file_name()
            .unwrap_or_default()
            .to_string_lossy()
            .to_string();
        if is_reserved_dir_name(&name) {
            continue;
        }

        for walk_entry in WalkDir::new(&path)
            .into_iter()
            .filter_map(Result::ok)
            .filter(|e| e.file_type().is_file() && is_doc_file(e.path()))
        {
            if matches.len() >= args.limit {
                break;
            }
            let file_path = walk_entry.path();
            let content = match std::fs::read_to_string(file_path) {
                Ok(c) => c,
                Err(e) => {
                    eprintln!("[alcove] Failed to read {}: {}", file_path.display(), e);
                    continue;
                }
            };
            let rel = file_path
                .strip_prefix(&path)
                .unwrap_or(file_path)
                .to_string_lossy()
                .to_string();

            for (idx, line) in content.lines().enumerate() {
                if line.to_lowercase().contains(&query_lower) {
                    matches.push(json!({
                        "project": name,
                        "file": rel,
                        "line": idx + 1,
                        "snippet": line.trim(),
                    }));
                    if matches.len() >= args.limit {
                        break;
                    }
                }
            }
        }
    }

    let truncated = matches.len() >= args.limit;
    Ok(
        json!({ "query": args.query, "scope": "global", "matches": matches, "truncated": truncated, "mode": "grep" }),
    )
}

// ---------------------------------------------------------------------------
// Tool: get_doc_file
// ---------------------------------------------------------------------------

#[derive(Debug, Deserialize)]
struct GetFileArgs {
    relative_path: String,
    #[serde(default)]
    offset: Option<usize>,
    #[serde(default)]
    limit: Option<usize>,
}

pub fn tool_get_file(project_root: &Path, args_value: Value) -> Result<Value> {
    let args: GetFileArgs = serde_json::from_value(args_value)
        .context("get_doc_file requires { relative_path, offset?, limit? }")?;

    let safe_rel = Path::new(&args.relative_path);
    if safe_rel
        .components()
        .any(|c| matches!(c, std::path::Component::ParentDir))
    {
        anyhow::bail!("Path traversal is not allowed");
    }

    let full_path = project_root.join(safe_rel);
    if !full_path.exists() || !full_path.is_file() {
        anyhow::bail!("File does not exist: {}", args.relative_path);
    }
    if !is_doc_file(&full_path) {
        anyhow::bail!("File type not allowed: {}", args.relative_path);
    }

    // Symlink escape guard: resolve all symlinks and verify the canonical path
    // stays within the project root.
    let canonical_path = full_path
        .canonicalize()
        .map_err(|_| anyhow::anyhow!("access denied: file not found"))?;
    let canonical_root = project_root
        .canonicalize()
        .map_err(|_| anyhow::anyhow!("access denied: project root not found"))?;
    if !canonical_path.starts_with(&canonical_root) {
        anyhow::bail!("access denied: path outside project root");
    }

    let content = std::fs::read_to_string(&full_path)?;
    let sliced = slice_content(&content, args.offset, args.limit);

    Ok(json!({
        "path": args.relative_path,
        "content": sliced,
        "total_chars": content.len(),
    }))
}

// ---------------------------------------------------------------------------
// Tool: list_projects
// ---------------------------------------------------------------------------

pub fn tool_list_projects(docs_root: &Path) -> Result<Value> {
    let mut projects = Vec::new();

    let entries = std::fs::read_dir(docs_root).context("Failed to read DOCS_ROOT directory")?;
    let core_files = load_config().core_files();

    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }

        let name = path
            .file_name()
            .unwrap_or_default()
            .to_string_lossy()
            .to_string();

        if is_reserved_dir_name(&name) {
            continue;
        }

        let doc_count = WalkDir::new(&path)
            .into_iter()
            .filter_map(Result::ok)
            .filter(|e| e.file_type().is_file() && is_doc_file(e.path()))
            .count();

        let internal_present: Vec<String> = core_files
            .iter()
            .filter(|f| path.join(f).exists())
            .cloned()
            .collect();

        let internal_missing: Vec<String> = core_files
            .iter()
            .filter(|f| !path.join(f).exists())
            .cloned()
            .collect();

        projects.push(json!({
            "name": name,
            "total_docs": doc_count,
            "internal_required_present": internal_present,
            "internal_required_missing": internal_missing,
        }));
    }

    Ok(json!({ "projects": projects }))
}

// ---------------------------------------------------------------------------
// Tool: configure_project
// ---------------------------------------------------------------------------

#[derive(Debug, Deserialize)]
struct ConfigureProjectArgs {
    project_name: String,
    #[serde(default)]
    diagram_format: Option<String>,
    #[serde(default)]
    core_files: Option<Vec<String>>,
    #[serde(default)]
    team_files: Option<Vec<String>>,
    #[serde(default)]
    public_files: Option<Vec<String>>,
}

/// Create or update `{repo_path}/alcove.toml` with the provided settings.
/// `repo_path` is the project's repository root (CWD-detected or explicit).
/// Existing settings not mentioned in the call are preserved.
pub fn tool_configure_project(repo_path: &Path, args_value: Value) -> Result<Value> {
    use crate::config::{CategoryConfig, DiagramConfig, load_project_config, project_config_path};

    let args: ConfigureProjectArgs = serde_json::from_value(args_value)
        .context("configure_project requires { project_name, ... }")?;

    // Load existing project config (empty if none)
    let mut cfg = load_project_config(repo_path);

    // Apply updates for each provided field
    if let Some(fmt) = args.diagram_format {
        cfg.diagram = Some(DiagramConfig { format: fmt });
    }
    if let Some(files) = args.core_files {
        cfg.core = Some(CategoryConfig { files });
    }
    if let Some(files) = args.team_files {
        cfg.team = Some(CategoryConfig { files });
    }
    if let Some(files) = args.public_files {
        cfg.public = Some(CategoryConfig { files });
    }

    // Serialize and write
    let toml_str = toml::to_string_pretty(&cfg).context("Failed to serialize config")?;
    let config_path = project_config_path(repo_path);
    std::fs::write(&config_path, &toml_str).context("Failed to write alcove.toml")?;

    Ok(json!({
        "project_name": args.project_name,
        "config_path": config_path.to_string_lossy(),
        "written": toml_str,
    }))
}

// ---------------------------------------------------------------------------
// Tool: init_project
// ---------------------------------------------------------------------------

#[derive(Debug, Deserialize)]
struct InitProjectArgs {
    project_name: String,
    #[serde(default)]
    project_path: Option<String>,
    #[serde(default)]
    overwrite: Option<bool>,
    #[serde(default)]
    files: Option<Vec<String>>,
}

/// Create user-facing docs (README, CHANGELOG, QUICKSTART) in the project repository.
fn create_repo_docs(
    name: &str,
    project_path: &Option<String>,
    file_filter: &Option<Vec<String>>,
    overwrite: bool,
) -> Result<(Vec<String>, Vec<String>, String)> {
    let mut repo_created = Vec::new();
    let mut repo_skipped = Vec::new();
    let mut repo_path_used = String::new();

    let Some(project_path) = project_path else {
        return Ok((repo_created, repo_skipped, repo_path_used));
    };

    let project_dir = PathBuf::from(project_path);
    if !project_dir.exists() || !project_dir.is_dir() {
        anyhow::bail!("project_path does not exist or is not a directory: {project_path}");
    }

    repo_path_used = project_path.clone();

    let mut create_file = |filename: &str, content: String| -> Result<()> {
        if let Some(filter) = file_filter
            && !filter.iter().any(|f| f == filename)
        {
            return Ok(());
        }
        let dest = project_dir.join(filename);
        if dest.exists() && !overwrite {
            repo_skipped.push(filename.to_string());
        } else {
            if let Some(parent) = dest.parent() {
                std::fs::create_dir_all(parent)?;
            }
            std::fs::write(&dest, content)?;
            repo_created.push(filename.to_string());
        }
        Ok(())
    };

    create_file(
        "README.md",
        format!(
            r"# {name}

> TODO: Brief project description

[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)

## Quick Start

```bash
# TODO: Quick start steps
```

## Installation

```bash
# TODO: Installation steps
```

## Usage

```bash
# TODO: Usage examples
```

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines.

## License

This project is licensed under the Apache License 2.0 — see the [LICENSE](LICENSE) file for details.
",
        ),
    )?;

    create_file(
        "CHANGELOG.md",
        format!(
            r"# Changelog

All notable changes to {name} will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Initial project setup
",
        ),
    )?;

    create_file(
        "QUICKSTART.md",
        format!(
            r"# {name} — Quick Start

Get up and running in under 5 minutes.

## Prerequisites

- TODO: List required tools and versions

## Steps

```bash
# 1. Clone the repository
git clone <repo-url>
cd {name}

# 2. Install dependencies
# TODO: install command

# 3. Configure environment
# TODO: env setup

# 4. Run
# TODO: run command
```

## Verify

```bash
# TODO: verification command
```

## Next Steps

- Read the full [README](README.md) for detailed usage
- Check [CONTRIBUTING.md](CONTRIBUTING.md) for development setup
",
        ),
    )?;

    create_file(
        "CONTRIBUTING.md",
        format!(
            r#"# Contributing to {name}

Thank you for your interest in contributing to {name}! This document provides guidelines and instructions for contributing.

## Development Setup

### Prerequisites

- TODO: List required tools and versions

### Getting Started

```bash
# Clone the repository
git clone <repo-url>
cd {name}

# Install dependencies
# TODO: install command

# Run tests
# TODO: test command
```

## How to Contribute

### Branch Naming

- `feat/<short-description>` — new features
- `fix/<short-description>` — bug fixes
- `docs/<short-description>` — documentation changes
- `refactor/<short-description>` — code refactoring

### Pull Request Process

1. Fork the repository and create a feature branch
2. Make your changes with clear, descriptive commit messages
3. Add or update tests for your changes
4. Ensure all tests pass: `# TODO: test command`
5. Update documentation if needed
6. Submit a pull request with a description of your changes

### Code Style

- TODO: Describe code style conventions
- TODO: Link to linter/formatter configuration

### Commit Messages

Use [Conventional Commits](https://www.conventionalcommits.org/):

```
<type>(<scope>): <description>

[optional body]
```

Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`

## Reporting Issues

- Use GitHub Issues to report bugs or request features
- Include steps to reproduce for bug reports
- Search existing issues before creating a new one

## Questions?

Feel free to open an issue with the "question" label.
"#,
        ),
    )?;

    create_file(
        "SECURITY.md",
        format!(
            r"# Security Policy for {name}

## Supported Versions

| Version | Supported          |
| ------- | ------------------ |
| TODO    | :white_check_mark: |

## Reporting a Vulnerability

**Do not report security vulnerabilities through public GitHub issues.**

Instead, please report them via:

- **GitHub Security Advisories**: Use the [Security Advisories](../../security/advisories/new) feature to privately report a vulnerability.
- **Email**: TODO: Add security contact email

### What to Include

- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)

### Response Timeline

- **Acknowledgment**: Within 48 hours
- **Initial Assessment**: Within 5 business days
- **Status Updates**: Weekly until resolved

### Disclosure Policy

- Vulnerabilities will be disclosed after a fix is released
- We follow [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure) practices
- Credit will be given to reporters (unless anonymity is requested)

Thank you for helping keep {name} secure.
",
        ),
    )?;

    create_file(
        "CODE_OF_CONDUCT.md",
        r#"# Contributor Covenant Code of Conduct

## Our Pledge

We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.

We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.

## Our Standards

Examples of behavior that contributes to a positive environment for our
community include:

* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
  and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
  community

Examples of unacceptable behavior include:

* The use of sexualized language or imagery, and sexual attention or advances of
  any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
  without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
  professional setting

## Enforcement Responsibilities

Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.

Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.

## Scope

This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.

## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
TODO: Add contact email or URL.
All complaints will be reviewed and investigated promptly and fairly.

All community leaders are obligated to respect the privacy and security of the
reporter of any incident.

## Enforcement Guidelines

Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:

### 1. Correction

**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.

**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.

### 2. Warning

**Community Impact**: A violation through a single incident or series of
actions.

**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.

### 3. Temporary Ban

**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.

**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.

### 4. Permanent Ban

**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.

**Consequence**: A permanent ban from any sort of public interaction within the
community.

## Attribution

This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct/][v2.1].

Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].

For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].

[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
"#
        .to_string(),
    )?;

    create_file(
        "LICENSE",
        r#"                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to the Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by the Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

   END OF TERMS AND CONDITIONS

   Copyright 2024 TODO: Copyright holder

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.
"#
        .to_string(),
    )?;

    create_file(
        ".github/ISSUE_TEMPLATE/bug_report.md",
        r#"---
name: Bug report
about: Report a bug to help us improve
title: "[BUG] "
labels: bug
assignees: ''
---

## Description

A clear and concise description of what the bug is.

## Steps to Reproduce

1. ...
2. ...
3. ...

## Expected Behavior

What you expected to happen.

## Actual Behavior

What actually happened.

## Environment

- OS:
- Version/Commit:
- Runtime/Toolchain:

## Additional Context

Logs, screenshots, or any other context.
"#
        .to_string(),
    )?;

    create_file(
        ".github/ISSUE_TEMPLATE/feature_request.md",
        r#"---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE] "
labels: enhancement
assignees: ''
---

## Problem

A clear description of the problem this feature would solve.

## Proposed Solution

A clear description of what you want to happen.

## Alternatives Considered

A description of any alternative solutions or features you've considered.

## Additional Context

Any other context, screenshots, or references.
"#
        .to_string(),
    )?;

    create_file(
        ".github/PULL_REQUEST_TEMPLATE.md",
        r#"## Description

Brief description of the changes in this PR.

## Type of Change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
- [ ] Refactoring (no functional changes)

## How Has This Been Tested?

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual testing

Describe the tests you ran to verify your changes.

## Checklist

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes

## Related Issues

Closes #
"#.to_string(),
    )?;

    Ok((repo_created, repo_skipped, repo_path_used))
}

pub fn tool_init_project(docs_root: &Path, args_value: Value) -> Result<Value> {
    let args: InitProjectArgs = serde_json::from_value(args_value)
        .context("init_project requires { project_name, project_path?, overwrite? }")?;

    let name = args.project_name.trim();
    if name.is_empty()
        || name.contains("..")
        || name.contains('/')
        || name.contains('\\')
        || is_reserved_dir_name(name)
    {
        anyhow::bail!(
            "Invalid project name: `{name}`. Must not contain path separators, start with . or _, or be empty."
        );
    }

    let template_root = docs_root.join("_template");
    if !template_root.exists() {
        anyhow::bail!(
            "Template directory not found. Expected at: {}",
            template_root.display()
        );
    }

    let project_root = docs_root.join(name);
    let overwrite = args.overwrite.unwrap_or(false);

    std::fs::create_dir_all(project_root.join("reports"))?;

    let mut bridge_created = Vec::new();
    let mut bridge_skipped = Vec::new();

    let file_filter: Option<Vec<String>> = args
        .files
        .map(|f| f.iter().map(|s| s.trim().to_string()).collect());

    for entry in WalkDir::new(&template_root)
        .into_iter()
        .filter_map(Result::ok)
        .filter(|e| e.file_type().is_file())
    {
        let rel = entry
            .path()
            .strip_prefix(&template_root)
            .unwrap_or(entry.path());

        let filename = rel.file_name().and_then(|f| f.to_str()).unwrap_or("");

        if rel == Path::new("README.md") || filename == ".gitkeep" {
            continue;
        }

        if let Some(ref filter) = file_filter {
            let rel_str = rel.to_string_lossy();
            if !filter
                .iter()
                .any(|f| f == filename || f == rel_str.as_ref())
            {
                continue;
            }
        }

        let dest = project_root.join(rel);

        if dest.exists() && !overwrite {
            bridge_skipped.push(rel.to_string_lossy().to_string());
            continue;
        }

        if let Some(parent) = dest.parent() {
            std::fs::create_dir_all(parent)?;
        }

        let content = std::fs::read_to_string(entry.path())?;
        let content = content.replace("ProjectName", name);
        std::fs::write(&dest, content)?;

        bridge_created.push(rel.to_string_lossy().to_string());
    }

    // External: Create user-facing docs in project repository
    let (repo_created, repo_skipped, repo_path_used) =
        create_repo_docs(name, &args.project_path, &file_filter, overwrite)?;

    Ok(json!({
        "project_name": name,
        "internal_docs": {
            "location": "alcove (private)",
            "path": project_root.to_string_lossy(),
            "created": bridge_created,
            "skipped": bridge_skipped,
        },
        "external_docs": {
            "location": "project repo (public)",
            "path": repo_path_used,
            "created": repo_created,
            "skipped": repo_skipped,
        },
        "next_steps": [
            format!("Edit {name}/PRD.md — define requirements and goals"),
            format!("Edit {name}/ARCHITECTURE.md — define tech stack and structure"),
            format!("Edit {name}/CONVENTIONS.md — define coding rules"),
            format!("Edit {name}/SECRETS_MAP.md — map environment variables"),
        ]
    }))
}

// ---------------------------------------------------------------------------
// Helpers for audit
// ---------------------------------------------------------------------------

/// Scan a project repository (root + docs/) for documentation files.
fn scan_repo_docs(
    repo_path: Option<&Path>,
    cfg: &crate::config::DocConfig,
) -> (Vec<Value>, String) {
    let mut repo_docs = Vec::new();
    let mut repo_path_str = String::new();

    let Some(rp) = repo_path else {
        return (repo_docs, repo_path_str);
    };

    let classifier = TierClassifier::new(cfg);
    repo_path_str = rp.to_string_lossy().to_string();

    for entry in std::fs::read_dir(rp).into_iter().flatten().flatten() {
        let path = entry.path();
        if path.is_file() && is_doc_file(&path) {
            let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
            let rel = filename.to_string();
            let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
            let tier = classifier.classify(&rel);
            repo_docs.push(json!({
                "path": rel,
                "filename": filename,
                "size_bytes": size,
                "tier": tier,
            }));
        }
    }

    let docs_dir = rp.join("docs");
    if docs_dir.is_dir() {
        for entry in WalkDir::new(&docs_dir)
            .into_iter()
            .filter_map(Result::ok)
            .filter(|e| e.file_type().is_file())
        {
            let path = entry.path();
            if !is_doc_file(path) {
                continue;
            }
            let rel = path
                .strip_prefix(rp)
                .unwrap_or(path)
                .to_string_lossy()
                .to_string();
            let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
            let size = entry.metadata().ok().map(|m| m.len()).unwrap_or(0);
            let tier = classifier.classify(&rel);
            repo_docs.push(json!({
                "path": rel,
                "filename": filename,
                "size_bytes": size,
                "tier": tier,
            }));
        }
    }

    (repo_docs, repo_path_str)
}

// ---------------------------------------------------------------------------
// Tool: audit_project
// ---------------------------------------------------------------------------

pub fn tool_audit(
    project_root: &Path,
    project_name: &str,
    repo_path: Option<&Path>,
) -> Result<Value> {
    let cfg = repo_path
        .map(effective_config)
        .unwrap_or_else(|| load_config().clone());

    let mut tier1_status = Vec::new();
    let core_files = cfg.core_files();

    for file in &core_files {
        let path = project_root.join(file);
        let exists = path.exists();
        let size = if exists {
            std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0)
        } else {
            0
        };

        let has_placeholder = if exists {
            std::fs::read_to_string(&path)
                .map(|c| c.contains("ProjectName"))
                .unwrap_or(false)
        } else {
            false
        };

        let status = if !exists {
            "missing"
        } else if has_placeholder {
            "template-unfilled"
        } else if size < 100 {
            "minimal"
        } else {
            "populated"
        };

        tier1_status.push(json!({
            "file": file,
            "exists": exists,
            "size_bytes": size,
            "status": status,
        }));
    }

    let mut supplementary_files = Vec::new();
    let mut unrecognized_files = Vec::new();
    let classifier = TierClassifier::new(&cfg);

    for entry in WalkDir::new(project_root)
        .into_iter()
        .filter_map(Result::ok)
        .filter(|e| e.file_type().is_file())
    {
        let path = entry.path();
        if !is_doc_file(path) {
            continue;
        }

        let rel = path
            .strip_prefix(project_root)
            .unwrap_or(path)
            .to_string_lossy()
            .to_string();

        let filename = Path::new(&rel)
            .file_name()
            .and_then(|f| f.to_str())
            .unwrap_or("");

        if core_files.iter().any(|f| f == filename) || rel.starts_with("reports/") {
            continue;
        }

        let meta = entry.metadata()?;
        let tier = classifier.classify(&rel);

        match tier {
            "doc-repo-supplementary" | "project-repo" => {
                supplementary_files.push(json!({
                    "path": rel,
                    "size_bytes": meta.len(),
                }));
            }
            _ => {
                let hint = suggest_categorization(filename);
                unrecognized_files.push(json!({
                    "path": rel,
                    "size_bytes": meta.len(),
                    "hint": hint,
                }));
            }
        }
    }

    // Scan project repository
    let (repo_docs, repo_path_str) = scan_repo_docs(repo_path, &cfg);

    let missing_files: Vec<String> = tier1_status
        .iter()
        .filter(|s| s["status"] == "missing")
        .filter_map(|s| s["file"].as_str().map(String::from))
        .collect();
    let unfilled_files: Vec<String> = tier1_status
        .iter()
        .filter(|s| s["status"] == "template-unfilled")
        .filter_map(|s| s["file"].as_str().map(String::from))
        .collect();
    let minimal_files: Vec<String> = tier1_status
        .iter()
        .filter(|s| s["status"] == "minimal")
        .filter_map(|s| s["file"].as_str().map(String::from))
        .collect();
    let populated =
        core_files.len() - missing_files.len() - unfilled_files.len() - minimal_files.len();

    let mut suggested_actions: Vec<Value> = Vec::new();

    if !missing_files.is_empty() {
        suggested_actions.push(json!({
            "action": "create_missing_internal",
            "description": format!("Create {} missing internal doc(s): {}", missing_files.len(), missing_files.join(", ")),
            "tool": "init_project",
            "params": {
                "project_name": project_name,
                "files": missing_files,
            }
        }));
    }

    if !unfilled_files.is_empty() {
        suggested_actions.push(json!({
            "action": "fill_templates",
            "description": format!(
                "Fill {} template doc(s) with actual project content: {}",
                unfilled_files.len(),
                unfilled_files.join(", ")
            ),
            "tool": "agent_edit",
            "params": {
                "files": unfilled_files,
            },
            "note": "Read each file, analyze the project source, and replace placeholder content."
        }));
    }

    if !minimal_files.is_empty() {
        suggested_actions.push(json!({
            "action": "expand_minimal",
            "description": format!(
                "Expand {} minimal doc(s) with more detail: {}",
                minimal_files.len(),
                minimal_files.join(", ")
            ),
            "tool": "agent_edit",
            "params": {
                "files": minimal_files,
            },
            "note": "These files exist but have very little content (<100 bytes)."
        }));
    }

    if !unrecognized_files.is_empty() {
        suggested_actions.push(json!({
            "action": "review_unrecognized",
            "description": format!(
                "{} unrecognized file(s) in alcove",
                unrecognized_files.len()
            ),
            "files": &unrecognized_files,
            "note": "Present the list to the user for review."
        }));
    }

    // Cross-repo suggestions
    if repo_path.is_some() {
        let repo_filenames: Vec<String> = repo_docs
            .iter()
            .filter_map(|d| d["filename"].as_str().map(String::from))
            .collect();

        let public_files = cfg.public_files();
        let missing_public: Vec<&str> = public_files
            .iter()
            .filter(|f| {
                !repo_filenames
                    .iter()
                    .any(|r| r.to_lowercase() == f.to_lowercase())
            })
            .map(std::string::String::as_str)
            .collect();
        if !missing_public.is_empty() {
            suggested_actions.push(json!({
                "action": "generate_public_docs",
                "description": format!(
                    "Project repo is missing {} public doc(s): {}",
                    missing_public.len(),
                    missing_public.join(", ")
                ),
                "note": "Can generate these from alcove content, formatted for public consumption. Internal details will NOT be exposed."
            }));
        }

        let mut duplicate_in_repo = Vec::new();
        for doc in &repo_docs {
            let fname = doc["filename"].as_str().unwrap_or("");
            let fname_lower = fname.to_lowercase();
            if core_files.iter().any(|f| f.to_lowercase() == fname_lower) {
                duplicate_in_repo.push(fname.to_string());
            }
        }
        if !duplicate_in_repo.is_empty() {
            suggested_actions.push(json!({
                "action": "resolve_exposed_internal_docs",
                "severity": "warning",
                "description": format!(
                    "Project repo contains {} internal doc(s) that should only exist in alcove: {}",
                    duplicate_in_repo.len(),
                    duplicate_in_repo.join(", ")
                ),
                "note": concat!(
                    "These are private docs (PRD, ARCHITECTURE, etc.) exposed in the public repo. ",
                    "BEFORE removing from project repo, compare content with alcove version. ",
                    "If the project repo version has additional content, merge it into alcove first, ",
                    "then remove from the project repo."
                )
            }));
        }

        let mut repo_reports = Vec::new();
        let mut repo_incorporable = Vec::new();
        for doc in &repo_docs {
            let fname = doc["filename"].as_str().unwrap_or("");
            let fname_lower = fname.to_lowercase();
            let tier = doc["tier"].as_str().unwrap_or("");

            if tier == "project-repo" {
                continue;
            }
            if duplicate_in_repo
                .iter()
                .any(|d| d.to_lowercase() == fname_lower)
            {
                continue;
            }

            let is_report = fname_lower.contains("audit")
                || fname_lower.contains("benchmark")
                || fname_lower.contains("analysis")
                || fname_lower.contains("competitive")
                || fname_lower.contains("comprehensive")
                || fname_lower.contains("feasibility")
                || fname_lower.contains("report")
                || fname_lower.contains("session");

            if is_report {
                repo_reports.push(doc["path"].as_str().unwrap_or(fname).to_string());
            } else if tier == "doc-repo-supplementary" || tier == "unrecognized" {
                repo_incorporable.push(doc["path"].as_str().unwrap_or(fname).to_string());
            }
        }

        if !repo_reports.is_empty() {
            suggested_actions.push(json!({
                "action": "move_reports_to_doc_repo",
                "description": format!(
                    "Project repo has {} analysis/report file(s) that belong in alcove reports/: {}",
                    repo_reports.len(),
                    repo_reports.join(", ")
                ),
                "note": "Analysis, benchmark, and audit documents are internal reference material. Move to alcove reports/ folder, not the public project repo."
            }));
        }

        if !repo_incorporable.is_empty() {
            suggested_actions.push(json!({
                "action": "incorporate_to_doc_repo",
                "description": format!(
                    "Project repo has {} file(s) that could be restructured into alcove: {}",
                    repo_incorporable.len(),
                    repo_incorporable.join(", ")
                ),
                "note": "These project repo files contain reference material that could be restructured and incorporated into alcove internal docs."
            }));
        }
    }

    if suggested_actions.is_empty() {
        suggested_actions.push(json!({
            "action": "none",
            "description": "All docs present and well-organized. No action needed."
        }));
    }

    let mut result = json!({
        "project_name": project_name,
        "doc_repo": {
            "path": project_root.to_string_lossy(),
            "required": tier1_status,
            "supplementary": supplementary_files,
            "reference": "reports/",
            "unrecognized": unrecognized_files,
        },
        "summary": {
            "doc_repo_required_total": core_files.len(),
            "doc_repo_required_populated": populated,
            "doc_repo_required_missing": missing_files.len(),
            "doc_repo_required_unfilled": unfilled_files.len(),
            "doc_repo_required_minimal": minimal_files.len(),
            "doc_repo_supplementary_count": supplementary_files.len(),
            "unrecognized_count": unrecognized_files.len(),
            "repo_docs_count": repo_docs.len(),
        },
        "diagram_format": cfg.diagram_format(),
        "suggested_actions": suggested_actions,
        "agent_instruction": concat!(
            "Present the audit findings to the user. Ask which actions to proceed with. ",
            "RULES: ",
            "1) alcove → project repo: Generate public docs (README, etc.) DERIVED from internal content. NEVER copy internal docs as-is. ",
            "2) project repo → alcove: Restructure/incorporate reference materials into internal docs. ",
            "3) NEVER expose raw internal docs (PRD, ARCHITECTURE, SECRETS_MAP, etc.) to the project repo. ",
            "4) Analysis/report/benchmark/audit files belong in alcove reports/, NOT in the project repo. ",
            "5) Before removing a file from project repo that also exists in alcove, DIFF the two versions. ",
            "   If the project repo version has additional content, MERGE it into alcove first, then remove from project repo. ",
            "6) NEVER move or delete files without explicit user confirmation."
        ),
    });

    if repo_path.is_some() {
        result["project_repo"] = json!({
            "path": repo_path_str,
            "docs": repo_docs,
            "scanned": ["root-level docs", "docs/ folder"],
        });
    } else {
        result["project_repo"] = json!({
            "status": "not_detected",
            "note": "Could not detect project repo path from CWD. Run from within the project directory for full audit.",
        });
    }

    Ok(result)
}

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

pub fn slice_content(content: &str, offset: Option<usize>, limit: Option<usize>) -> String {
    let start = offset.unwrap_or(0);
    let chars: Vec<char> = content.chars().collect();
    let total = chars.len();

    if start >= total {
        return String::new();
    }

    let end = limit.map(|l| (start + l).min(total)).unwrap_or(total);

    chars[start..end].iter().collect()
}

// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// Tool: lint_project
// ---------------------------------------------------------------------------

pub fn tool_lint_project(docs_root: &Path, args: Value) -> Result<Value> {
    let project_filter = args
        .get("project")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    let report = crate::lint::lint(docs_root, project_filter.as_deref());
    Ok(crate::lint::lint_to_json(&report))
}

// ---------------------------------------------------------------------------
// Tool: promote_document
// ---------------------------------------------------------------------------

pub fn tool_promote_document(docs_root: &Path, args: Value) -> Result<Value> {
    let source_str = args
        .get("source")
        .and_then(|v| v.as_str())
        .ok_or_else(|| anyhow::anyhow!("Missing required argument: source"))?;

    let project = args
        .get("project")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    let copy = args.get("copy").and_then(|v| v.as_bool()).unwrap_or(true); // default: copy (safe)

    let opts = crate::promote::PromoteOptions {
        source: PathBuf::from(source_str),
        project,
        copy,
    };

    let result = crate::promote::promote(docs_root, opts)?;

    Ok(json!({
        "source": result.source.to_string_lossy(),
        "destination": result.destination.to_string_lossy(),
        "project": result.project,
        "action": result.action,
    }))
}

// ---------------------------------------------------------------------------
// Tool: backup_vault
// ---------------------------------------------------------------------------

pub fn tool_backup_vault(args: Value) -> Result<Value> {
    let vault_name = args
        .get("vault_name")
        .and_then(|v| v.as_str())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty());

    // Validate vault_name is a single path component
    if let Some(ref name) = vault_name {
        let components: Vec<_> = Path::new(name.as_str()).components().collect();
        if components.len() != 1 || !matches!(components[0], std::path::Component::Normal(_)) {
            anyhow::bail!("Invalid vault name: '{name}'");
        }
    }

    // Determine vaults root
    let vaults_root = crate::vault::vaults_root();
    if !vaults_root.is_dir() {
        anyhow::bail!("Vaults directory does not exist: {}", vaults_root.display());
    }

    let timestamp = format_timestamp();

    if let Some(name) = vault_name {
        // Back up a single vault
        let vault_path = vaults_root.join(&name);
        if !vault_path.is_dir() {
            anyhow::bail!("Vault '{}' does not exist", name);
        }
        run_git_backup(&vault_path, &name, &timestamp.to_string())
    } else {
        // Back up all vaults
        let entries = std::fs::read_dir(&vaults_root).context("Failed to read vaults directory")?;
        let mut results = Vec::new();

        for entry in entries.flatten() {
            let path = entry.path();
            if !path.is_dir() {
                continue;
            }
            let dir_name = entry.file_name().to_string_lossy().to_string();
            if crate::config::is_reserved_dir_name(&dir_name) {
                continue;
            }
            match run_git_backup(&path, &dir_name, &timestamp.to_string()) {
                Ok(v) => results.push(v),
                Err(e) => results.push(json!({
                    "vault": dir_name,
                    "status": "error",
                    "error": e.to_string(),
                })),
            }
        }

        Ok(json!({
            "status": "completed",
            "vaults": results,
        }))
    }
}

/// Run `git add -A && git commit` in the vault directory.
/// Returns a JSON object with the vault name, status, and optional commit hash.
fn run_git_backup(vault_path: &Path, vault_name: &str, timestamp: &str) -> Result<Value> {
    let commit_message = format!("chore(vault): auto-sync {timestamp}");

    // git add -A
    let add_output = std::process::Command::new("git")
        .args(["add", "-A"])
        .current_dir(vault_path)
        .output()
        .context("Failed to run git add")?;

    if !add_output.status.success() {
        let stderr = String::from_utf8_lossy(&add_output.stderr);
        // If git is not initialized, init and retry
        if stderr.contains("not a git repository") {
            let init_output = std::process::Command::new("git")
                .args(["init"])
                .current_dir(vault_path)
                .output()
                .context("Failed to run git init")?;

            if !init_output.status.success() {
                anyhow::bail!(
                    "git init failed in {}: {}",
                    vault_path.display(),
                    String::from_utf8_lossy(&init_output.stderr)
                );
            }

            // Retry git add
            let retry_add = std::process::Command::new("git")
                .args(["add", "-A"])
                .current_dir(vault_path)
                .output()
                .context("Failed to run git add after init")?;

            if !retry_add.status.success() {
                anyhow::bail!(
                    "git add failed in {}: {}",
                    vault_path.display(),
                    String::from_utf8_lossy(&retry_add.stderr)
                );
            }
        } else {
            anyhow::bail!("git add failed in {}: {}", vault_path.display(), stderr);
        }
    }

    // git commit
    let commit_output = std::process::Command::new("git")
        .args(["commit", "-m", &commit_message])
        .current_dir(vault_path)
        .output()
        .context("Failed to run git commit")?;

    if commit_output.status.success() {
        // Get commit hash
        let hash_output = std::process::Command::new("git")
            .args(["rev-parse", "HEAD"])
            .current_dir(vault_path)
            .output()
            .context("Failed to get commit hash")?;

        let hash = String::from_utf8_lossy(&hash_output.stdout)
            .trim()
            .to_string();
        Ok(json!({
            "vault": vault_name,
            "status": "committed",
            "commit": hash,
            "message": commit_message,
        }))
    } else {
        let stderr = String::from_utf8_lossy(&commit_output.stderr);
        // "nothing to commit" is a normal case -- not an error
        if stderr.contains("nothing to commit")
            || stderr.contains("no changes added to commit")
            || stderr.contains("nothing added to commit")
        {
            // Try to get the current HEAD hash anyway
            let hash_output = std::process::Command::new("git")
                .args(["rev-parse", "HEAD"])
                .current_dir(vault_path)
                .output()
                .ok();

            let current_hash = hash_output.and_then(|o| {
                if o.status.success() {
                    Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
                } else {
                    None
                }
            });

            let mut result = json!({
                "vault": vault_name,
                "status": "no_changes",
                "message": "Nothing to commit. Vault is already up to date.",
            });
            if let Some(hash) = current_hash {
                result["commit"] = json!(hash);
            }
            Ok(result)
        } else {
            anyhow::bail!("git commit failed in {}: {}", vault_path.display(), stderr);
        }
    }
}

/// Generate a compact UTC timestamp like `20260508T143025Z`.
fn format_timestamp() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};

    let duration = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default();
    let secs = duration.as_secs();

    // Calculate date/time components from unix timestamp
    let days = secs / 86400;
    let time_secs = secs % 86400;
    let hours = time_secs / 3600;
    let minutes = (time_secs % 3600) / 60;
    let seconds = time_secs % 60;

    // Convert days since epoch to year/month/day
    let (year, month, day) = days_to_ymd(days);

    format!(
        "{:04}{:02}{:02}T{:02}{:02}{:02}Z",
        year, month, day, hours, minutes, seconds
    )
}

/// Convert days since Unix epoch to (year, month, day).
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
    // Algorithm based on the civil date algorithms by Howard Hinnant
    let z = days + 719468;
    let era = z / 146097;
    let doe = z - era * 146097;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    (y, m, d)
}
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    /// Create a minimal docs_root with one project folder containing a few doc files.
    fn setup_docs_root() -> TempDir {
        let tmp = TempDir::new().unwrap();
        let project = tmp.path().join("testproj");
        fs::create_dir_all(project.join("reports")).unwrap();
        fs::write(
            project.join("PRD.md"),
            "# Product Requirements\n\nReal content here about the product.",
        )
        .unwrap();
        fs::write(
            project.join("ARCHITECTURE.md"),
            "# Architecture\n\nSystem design overview.",
        )
        .unwrap();
        fs::write(
            project.join("reports/audit.md"),
            "# Audit Report\n\nFindings.",
        )
        .unwrap();
        // Template dir for init_project
        let template = tmp.path().join("_template");
        fs::create_dir_all(template.join("reports")).unwrap();
        fs::write(template.join("PRD.md"), "# ProjectName PRD\n\nTODO").unwrap();
        fs::write(
            template.join("ARCHITECTURE.md"),
            "# ProjectName Architecture\n\nTODO",
        )
        .unwrap();
        fs::write(template.join("README.md"), "Template readme (skipped)").unwrap();
        fs::write(template.join("reports/.gitkeep"), "").unwrap();
        tmp
    }

    // -- slice_content --

    #[test]
    fn slice_content_full() {
        assert_eq!(slice_content("hello world", None, None), "hello world");
    }

    #[test]
    fn slice_content_with_offset() {
        assert_eq!(slice_content("hello world", Some(6), None), "world");
    }

    #[test]
    fn slice_content_with_limit() {
        assert_eq!(slice_content("hello world", None, Some(5)), "hello");
    }

    #[test]
    fn slice_content_offset_and_limit() {
        assert_eq!(slice_content("hello world", Some(6), Some(3)), "wor");
    }

    #[test]
    fn slice_content_offset_beyond_length() {
        assert_eq!(slice_content("hello", Some(100), None), "");
    }

    #[test]
    fn slice_content_limit_beyond_length() {
        assert_eq!(slice_content("hi", Some(0), Some(999)), "hi");
    }

    #[test]
    fn slice_content_unicode() {
        let s = "안녕하세요";
        assert_eq!(slice_content(s, Some(2), Some(2)), "하세");
    }

    // -- tool_list_projects --

    #[test]
    fn list_projects_finds_project() {
        let tmp = setup_docs_root();
        let result = tool_list_projects(tmp.path()).unwrap();
        let projects = result["projects"].as_array().unwrap();
        assert!(projects.iter().any(|p| p["name"] == "testproj"));
    }

    #[test]
    fn list_projects_skips_hidden_and_template() {
        let tmp = setup_docs_root();
        fs::create_dir_all(tmp.path().join(".hidden")).unwrap();
        // _template already exists
        let result = tool_list_projects(tmp.path()).unwrap();
        let names: Vec<&str> = result["projects"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|p| p["name"].as_str())
            .collect();
        assert!(!names.contains(&".hidden"));
        assert!(!names.contains(&"_template"));
    }

    #[test]
    fn list_projects_counts_docs() {
        let tmp = setup_docs_root();
        let result = tool_list_projects(tmp.path()).unwrap();
        let proj = result["projects"]
            .as_array()
            .unwrap()
            .iter()
            .find(|p| p["name"] == "testproj")
            .unwrap();
        // PRD.md + ARCHITECTURE.md + reports/audit.md = 3
        assert_eq!(proj["total_docs"].as_u64().unwrap(), 3);
    }

    // -- tool_overview --

    #[test]
    fn overview_lists_files() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let result = tool_overview(&project_root, "testproj", "test", None).unwrap();
        assert_eq!(result["project_name"], "testproj");
        assert_eq!(result["detected_via"], "test");
        let doc_repo = &result["doc_repo"];
        assert_eq!(doc_repo["count"].as_u64().unwrap(), 3);
    }

    #[test]
    fn overview_with_repo_path() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        // Create a fake repo with a README
        let repo = TempDir::new().unwrap();
        fs::write(repo.path().join("README.md"), "# Test").unwrap();

        let result = tool_overview(&project_root, "testproj", "test", Some(repo.path())).unwrap();
        assert!(result["project_repo"].is_object());
        assert_eq!(result["project_repo"]["count"].as_u64().unwrap(), 1);
    }

    // -- tool_search --

    #[test]
    fn search_finds_matching_lines() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let args = json!({"query": "Architecture"});
        let result = tool_search(&project_root, args, None).unwrap();
        let matches = result["matches"].as_array().unwrap();
        assert!(!matches.is_empty());
        assert!(
            matches
                .iter()
                .any(|m| m["file"].as_str().unwrap().contains("ARCHITECTURE"))
        );
    }

    #[test]
    fn search_case_insensitive() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let args = json!({"query": "architecture"});
        let result = tool_search(&project_root, args, None).unwrap();
        assert!(!result["matches"].as_array().unwrap().is_empty());
    }

    #[test]
    fn search_respects_limit() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let args = json!({"query": "#", "limit": 1});
        let result = tool_search(&project_root, args, None).unwrap();
        assert_eq!(result["matches"].as_array().unwrap().len(), 1);
        assert_eq!(result["truncated"], true);
    }

    #[test]
    fn search_no_results() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let args = json!({"query": "zzz_nonexistent_zzz"});
        let result = tool_search(&project_root, args, None).unwrap();
        assert!(result["matches"].as_array().unwrap().is_empty());
        assert_eq!(result["truncated"], false);
    }

    #[test]
    fn search_includes_repo_path() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let repo = TempDir::new().unwrap();
        fs::write(repo.path().join("README.md"), "# Unique marker xyz123").unwrap();

        let args = json!({"query": "xyz123"});
        let result = tool_search(&project_root, args, Some(repo.path())).unwrap();
        let matches = result["matches"].as_array().unwrap();
        assert!(matches.iter().any(|m| m["source"] == "project-repo"));
    }

    // -- tool_get_file --

    #[test]
    fn get_file_reads_content() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let args = json!({"relative_path": "PRD.md"});
        let result = tool_get_file(&project_root, args).unwrap();
        assert_eq!(result["path"], "PRD.md");
        assert!(
            result["content"]
                .as_str()
                .unwrap()
                .contains("Product Requirements")
        );
        assert!(result["total_chars"].as_u64().unwrap() > 0);
    }

    #[test]
    fn get_file_with_offset_limit() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let args = json!({"relative_path": "PRD.md", "offset": 0, "limit": 5});
        let result = tool_get_file(&project_root, args).unwrap();
        assert_eq!(result["content"].as_str().unwrap(), "# Pro");
    }

    #[test]
    fn get_file_rejects_traversal() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let args = json!({"relative_path": "../_template/PRD.md"});
        let result = tool_get_file(&project_root, args);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("traversal"));
    }

    #[test]
    fn get_file_rejects_nonexistent() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let args = json!({"relative_path": "NOPE.md"});
        let result = tool_get_file(&project_root, args);
        assert!(result.is_err());
    }

    #[test]
    fn get_file_rejects_non_doc() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        fs::write(project_root.join("code.rs"), "fn main() {}").unwrap();
        let args = json!({"relative_path": "code.rs"});
        let result = tool_get_file(&project_root, args);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("not allowed"));
    }

    #[test]
    fn get_file_reads_nested() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let args = json!({"relative_path": "reports/audit.md"});
        let result = tool_get_file(&project_root, args).unwrap();
        assert!(result["content"].as_str().unwrap().contains("Audit Report"));
    }

    // -- tool_init_project --

    #[test]
    fn init_project_creates_docs() {
        let tmp = setup_docs_root();
        let args = json!({"project_name": "newproj"});
        let result = tool_init_project(tmp.path(), args).unwrap();
        assert_eq!(result["project_name"], "newproj");
        let created: Vec<&str> = result["internal_docs"]["created"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|v| v.as_str())
            .collect();
        assert!(created.contains(&"PRD.md"));
        assert!(created.contains(&"ARCHITECTURE.md"));
        // Verify files on disk
        assert!(tmp.path().join("newproj/PRD.md").exists());
        // Verify template substitution
        let content = fs::read_to_string(tmp.path().join("newproj/PRD.md")).unwrap();
        assert!(content.contains("newproj"));
        assert!(!content.contains("ProjectName"));
    }

    #[test]
    fn init_project_skips_existing() {
        let tmp = setup_docs_root();
        // First init
        tool_init_project(tmp.path(), json!({"project_name": "skiptest"})).unwrap();
        // Second init without overwrite
        let result = tool_init_project(tmp.path(), json!({"project_name": "skiptest"})).unwrap();
        let skipped = result["internal_docs"]["skipped"].as_array().unwrap();
        assert!(!skipped.is_empty());
        let created = result["internal_docs"]["created"].as_array().unwrap();
        assert!(created.is_empty());
    }

    #[test]
    fn init_project_overwrite() {
        let tmp = setup_docs_root();
        tool_init_project(tmp.path(), json!({"project_name": "overtest"})).unwrap();
        let result = tool_init_project(
            tmp.path(),
            json!({"project_name": "overtest", "overwrite": true}),
        )
        .unwrap();
        let created: Vec<&str> = result["internal_docs"]["created"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|v| v.as_str())
            .collect();
        assert!(created.contains(&"PRD.md"));
    }

    #[test]
    fn init_project_with_file_filter() {
        let tmp = setup_docs_root();
        let args = json!({"project_name": "filtered", "files": ["PRD.md"]});
        let result = tool_init_project(tmp.path(), args).unwrap();
        let created: Vec<&str> = result["internal_docs"]["created"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|v| v.as_str())
            .collect();
        assert!(created.contains(&"PRD.md"));
        assert!(!created.contains(&"ARCHITECTURE.md"));
    }

    #[test]
    fn init_project_creates_repo_docs() {
        let tmp = setup_docs_root();
        let repo = TempDir::new().unwrap();
        let args = json!({
            "project_name": "repoproj",
            "project_path": repo.path().to_string_lossy()
        });
        let result = tool_init_project(tmp.path(), args).unwrap();
        let repo_created: Vec<&str> = result["external_docs"]["created"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|v| v.as_str())
            .collect();
        assert!(repo_created.contains(&"README.md"));
        assert!(repo_created.contains(&"CHANGELOG.md"));
        assert!(repo_created.contains(&"QUICKSTART.md"));
        assert!(repo.path().join("README.md").exists());
    }

    #[test]
    fn init_project_rejects_invalid_names() {
        let tmp = setup_docs_root();
        for name in ["", ".hidden", "_private", "a/b", "a\\b", "a..b"] {
            let result = tool_init_project(tmp.path(), json!({"project_name": name}));
            assert!(result.is_err(), "should reject name: {name}");
        }
    }

    #[test]
    fn init_project_no_template_fails() {
        let tmp = TempDir::new().unwrap(); // no _template dir
        let result = tool_init_project(tmp.path(), json!({"project_name": "test"}));
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("Template"));
    }

    // -- tool_audit --

    #[test]
    fn audit_reports_file_status() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let result = tool_audit(&project_root, "testproj", None).unwrap();
        assert_eq!(result["project_name"], "testproj");

        let required = result["doc_repo"]["required"].as_array().unwrap();
        // PRD.md should be populated (>100 bytes? check)
        let prd = required.iter().find(|r| r["file"] == "PRD.md").unwrap();
        assert_eq!(prd["exists"], true);

        // PROGRESS.md should be missing
        let progress = required.iter().find(|r| r["file"] == "PROGRESS.md");
        if let Some(p) = progress {
            assert_eq!(p["status"], "missing");
        }
    }

    #[test]
    fn audit_detects_template_unfilled() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        // Write a file with ProjectName placeholder
        fs::write(
            project_root.join("CONVENTIONS.md"),
            "# ProjectName Conventions\n\nPlaceholder.",
        )
        .unwrap();
        let result = tool_audit(&project_root, "testproj", None).unwrap();
        let required = result["doc_repo"]["required"].as_array().unwrap();
        let conv = required
            .iter()
            .find(|r| r["file"] == "CONVENTIONS.md")
            .unwrap();
        assert_eq!(conv["status"], "template-unfilled");
    }

    #[test]
    fn audit_detects_minimal() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        fs::write(project_root.join("DEBT.md"), "# Debt\n").unwrap(); // <100 bytes, no placeholder
        let result = tool_audit(&project_root, "testproj", None).unwrap();
        let required = result["doc_repo"]["required"].as_array().unwrap();
        let debt = required.iter().find(|r| r["file"] == "DEBT.md").unwrap();
        assert_eq!(debt["status"], "minimal");
    }

    #[test]
    fn audit_with_repo_path() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let repo = TempDir::new().unwrap();
        fs::write(repo.path().join("README.md"), "# Test Project").unwrap();

        let result = tool_audit(&project_root, "testproj", Some(repo.path())).unwrap();
        assert!(result["project_repo"].is_object());
        let repo_docs = result["project_repo"]["docs"].as_array().unwrap();
        assert!(!repo_docs.is_empty());
    }

    #[test]
    fn audit_suggests_missing_internal() {
        let tmp = TempDir::new().unwrap();
        let project_root = tmp.path().join("emptyproj");
        fs::create_dir_all(&project_root).unwrap();

        let result = tool_audit(&project_root, "emptyproj", None).unwrap();
        let actions = result["suggested_actions"].as_array().unwrap();
        assert!(
            actions
                .iter()
                .any(|a| a["action"] == "create_missing_internal")
        );
    }

    #[test]
    fn audit_no_actions_when_complete() {
        let tmp = TempDir::new().unwrap();
        let project_root = tmp.path().join("completeproj");
        fs::create_dir_all(&project_root).unwrap();

        // Create all core files with enough content
        let core = load_config().core_files();
        for f in &core {
            let content = format!("# {f}\n\n{}", "x".repeat(200));
            fs::write(project_root.join(f), content).unwrap();
        }

        let result = tool_audit(&project_root, "completeproj", None).unwrap();
        let actions = result["suggested_actions"].as_array().unwrap();
        assert!(actions.iter().any(|a| a["action"] == "none"));
    }

    // =====================================================================
    // Edge-case & coverage tests
    // =====================================================================

    // -- tool_search: offset/limit in arguments --

    #[test]
    fn search_with_offset_limit_args() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        // "#" appears in headings of multiple files; limit to 2
        let args = json!({"query": "#", "limit": 2});
        let result = tool_search(&project_root, args, None).unwrap();
        let matches = result["matches"].as_array().unwrap();
        assert_eq!(matches.len(), 2);
        assert_eq!(result["truncated"], true);
    }

    // -- tool_search: empty query string --

    #[test]
    fn search_with_empty_query() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let args = json!({"query": ""});
        let result = tool_search(&project_root, args, None).unwrap();
        let matches = result["matches"].as_array().unwrap();
        assert!(matches.is_empty(), "empty query should return no matches");
        assert_eq!(result["error"].as_str(), Some("empty query"));
    }

    // -- tool_get_file: read a .txt file --

    #[test]
    fn get_file_reads_txt() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        fs::write(project_root.join("notes.txt"), "Plain text notes here.").unwrap();

        let args = json!({"relative_path": "notes.txt"});
        let result = tool_get_file(&project_root, args).unwrap();
        assert_eq!(result["path"], "notes.txt");
        assert!(
            result["content"]
                .as_str()
                .unwrap()
                .contains("Plain text notes")
        );
    }

    // -- tool_get_file: read a file in a nested subdirectory (3 levels deep) --

    #[test]
    fn get_file_reads_deeply_nested() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let deep_dir = project_root.join("level1/level2/level3");
        fs::create_dir_all(&deep_dir).unwrap();
        fs::write(
            deep_dir.join("deep.md"),
            "# Deep File\n\nContent at depth 3.",
        )
        .unwrap();

        let args = json!({"relative_path": "level1/level2/level3/deep.md"});
        let result = tool_get_file(&project_root, args).unwrap();
        assert_eq!(result["path"], "level1/level2/level3/deep.md");
        assert!(result["content"].as_str().unwrap().contains("Deep File"));
    }

    // -- tool_get_file: symlink outside project root is rejected --

    #[test]
    #[cfg(unix)]
    fn get_file_rejects_symlink_escape() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");

        // Create a file outside the project root
        let outside = tmp.path().join("secret.md");
        fs::write(&outside, "# Secret").unwrap();

        // Create a symlink inside the project root pointing outside
        let link = project_root.join("escape.md");
        std::os::unix::fs::symlink(&outside, &link).unwrap();

        let args = json!({"relative_path": "escape.md"});
        let result = tool_get_file(&project_root, args);
        assert!(result.is_err(), "symlink escape should be rejected");
        let msg = result.unwrap_err().to_string();
        assert!(
            msg.contains("access denied") || msg.contains("traversal") || msg.contains("outside"),
            "unexpected error: {msg}"
        );
    }

    // -- tool_init_project: invalid name with path separators --

    #[test]
    fn init_project_rejects_path_separator_names() {
        let tmp = setup_docs_root();

        let slash = tool_init_project(tmp.path(), json!({"project_name": "foo/bar"}));
        assert!(slash.is_err());
        let err_msg = slash.unwrap_err().to_string();
        assert!(err_msg.contains("Invalid project name"), "got: {err_msg}");

        let backslash = tool_init_project(tmp.path(), json!({"project_name": "foo\\bar"}));
        assert!(backslash.is_err());
        let err_msg = backslash.unwrap_err().to_string();
        assert!(err_msg.contains("Invalid project name"), "got: {err_msg}");

        let dotdot = tool_init_project(tmp.path(), json!({"project_name": "foo..bar"}));
        assert!(dotdot.is_err());
    }

    // -- tool_init_project: project_path creates README and CHANGELOG --

    #[test]
    fn init_project_creates_repo_readme_and_changelog() {
        let tmp = setup_docs_root();
        let repo = TempDir::new().unwrap();
        let args = json!({
            "project_name": "repodocs",
            "project_path": repo.path().to_string_lossy()
        });
        let result = tool_init_project(tmp.path(), args).unwrap();

        let repo_created: Vec<&str> = result["external_docs"]["created"]
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|v| v.as_str())
            .collect();
        assert!(
            repo_created.contains(&"README.md"),
            "README.md should be created"
        );
        assert!(
            repo_created.contains(&"CHANGELOG.md"),
            "CHANGELOG.md should be created"
        );

        // Verify content on disk contains project name substitution
        let readme = fs::read_to_string(repo.path().join("README.md")).unwrap();
        assert!(
            readme.contains("repodocs"),
            "README should contain project name"
        );

        let changelog = fs::read_to_string(repo.path().join("CHANGELOG.md")).unwrap();
        assert!(
            changelog.contains("repodocs"),
            "CHANGELOG should contain project name"
        );
    }

    // -- tool_overview: empty project directory --

    #[test]
    fn overview_empty_project_dir() {
        let tmp = TempDir::new().unwrap();
        let empty_project = tmp.path().join("emptyproj");
        fs::create_dir_all(&empty_project).unwrap();

        let result = tool_overview(&empty_project, "emptyproj", "test", None).unwrap();
        assert_eq!(result["project_name"], "emptyproj");
        assert_eq!(result["total_files"].as_u64().unwrap(), 0);
        assert_eq!(result["doc_repo"]["count"].as_u64().unwrap(), 0);
    }

    // -- tool_overview: mixed file types (.md, .txt, non-doc files) --

    #[test]
    fn overview_mixed_file_types() {
        let tmp = TempDir::new().unwrap();
        let project = tmp.path().join("mixedproj");
        fs::create_dir_all(&project).unwrap();

        fs::write(project.join("README.md"), "# Readme").unwrap();
        fs::write(project.join("notes.txt"), "some notes").unwrap();
        fs::write(project.join("main.rs"), "fn main() {}").unwrap();
        fs::write(project.join("style.css"), "body {}").unwrap();
        fs::write(project.join("data.json"), "{}").unwrap(); // json not matching openapi/swagger

        let result = tool_overview(&project, "mixedproj", "test", None).unwrap();
        let files = result["doc_repo"]["files"].as_array().unwrap();
        let file_paths: Vec<&str> = files.iter().filter_map(|f| f["path"].as_str()).collect();

        // .md and .txt are doc files
        assert!(file_paths.contains(&"README.md"), "should include .md");
        assert!(file_paths.contains(&"notes.txt"), "should include .txt");
        // .rs, .css, plain .json are not doc files
        assert!(!file_paths.contains(&"main.rs"), "should exclude .rs");
        assert!(!file_paths.contains(&"style.css"), "should exclude .css");
        assert!(
            !file_paths.contains(&"data.json"),
            "should exclude non-openapi .json"
        );
        // Total should be 2
        assert_eq!(result["doc_repo"]["count"].as_u64().unwrap(), 2);
    }

    // -- tool_audit: repo_path with docs/ subdirectory --

    #[test]
    fn audit_with_repo_docs_subdirectory() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");

        let repo = TempDir::new().unwrap();
        fs::write(repo.path().join("README.md"), "# Project Readme").unwrap();
        let docs_dir = repo.path().join("docs");
        fs::create_dir_all(&docs_dir).unwrap();
        fs::write(
            docs_dir.join("guide.md"),
            "# User Guide\n\nDetailed usage instructions.",
        )
        .unwrap();
        fs::write(
            docs_dir.join("api.md"),
            "# API Reference\n\nEndpoint documentation.",
        )
        .unwrap();

        let result = tool_audit(&project_root, "testproj", Some(repo.path())).unwrap();
        let repo_docs = result["project_repo"]["docs"].as_array().unwrap();

        // Should find README.md (root) + guide.md + api.md (docs/)
        assert!(
            repo_docs.len() >= 3,
            "expected at least 3 repo docs, got {}",
            repo_docs.len()
        );

        let paths: Vec<&str> = repo_docs
            .iter()
            .filter_map(|d| d["path"].as_str())
            .collect();
        assert!(paths.contains(&"README.md"));
        assert!(paths.iter().any(|p| p.contains("guide.md")));
        assert!(paths.iter().any(|p| p.contains("api.md")));
    }

    // -- tool_list_projects: empty docs_root --

    #[test]
    fn list_projects_empty_docs_root() {
        let tmp = TempDir::new().unwrap();
        // No project directories, just an empty root
        let result = tool_list_projects(tmp.path()).unwrap();
        let projects = result["projects"].as_array().unwrap();
        assert!(
            projects.is_empty(),
            "empty docs root should yield no projects"
        );
    }

    // -- resolve_project: MCP_PROJECT_NAME env var --

    #[test]
    fn resolve_project_with_env_var() {
        let tmp = TempDir::new().unwrap();
        let project_dir = tmp.path().join("envproj");
        fs::create_dir_all(&project_dir).unwrap();

        // SAFETY: This test is single-threaded and we restore the env var immediately.
        unsafe {
            env::set_var("MCP_PROJECT_NAME", "envproj");
        }
        let resolved = resolve_project(tmp.path());
        // Clean up env var immediately to avoid test pollution
        unsafe {
            env::remove_var("MCP_PROJECT_NAME");
        }

        let resolved = resolved.expect("should resolve project from env var");
        assert_eq!(resolved.name, "envproj");
        assert_eq!(resolved.detected_via, "env");
    }

    // -- detect_repo_path: repo detection logic --

    #[test]
    fn detect_repo_path_returns_none_for_unrelated_name() {
        // detect_repo_path walks up CWD looking for a directory component
        // matching the project name. A random name should not match.
        let result = detect_repo_path("__nonexistent_project_name_xyz__");
        assert!(
            result.is_none(),
            "should return None for unrelated project name"
        );
    }

    // -- tool_search_global --

    fn setup_multi_project_root() -> TempDir {
        let tmp = TempDir::new().unwrap();
        // Project 1: backend
        let backend = tmp.path().join("backend");
        fs::create_dir_all(&backend).unwrap();
        fs::write(
            backend.join("PRD.md"),
            "# Backend PRD\n\nAuthentication flow using OAuth.",
        )
        .unwrap();
        fs::write(
            backend.join("ARCHITECTURE.md"),
            "# Backend Architecture\n\nMicroservices design.",
        )
        .unwrap();
        // Project 2: frontend
        let frontend = tmp.path().join("frontend");
        fs::create_dir_all(&frontend).unwrap();
        fs::write(
            frontend.join("PRD.md"),
            "# Frontend PRD\n\nLogin page with OAuth integration.",
        )
        .unwrap();
        // Project 3: notes (knowledge base)
        let notes = tmp.path().join("notes");
        fs::create_dir_all(&notes).unwrap();
        fs::write(
            notes.join("k8s-tips.md"),
            "# K8s Tips\n\nTroubleshooting CrashLoopBackOff.",
        )
        .unwrap();
        fs::write(
            notes.join("oauth-memo.md"),
            "# OAuth Memo\n\nOAuth 2.0 refresh token flow.",
        )
        .unwrap();
        // Hidden/template dirs (should be skipped)
        fs::create_dir_all(tmp.path().join(".hidden")).unwrap();
        fs::write(tmp.path().join(".hidden/secret.md"), "# Secret").unwrap();
        fs::create_dir_all(tmp.path().join("_template")).unwrap();
        fs::write(tmp.path().join("_template/TEMPLATE.md"), "# Template").unwrap();
        tmp
    }

    #[test]
    fn global_search_finds_across_projects() {
        let tmp = setup_multi_project_root();
        let args = json!({"query": "OAuth", "scope": "global"});
        let result = tool_search_global(tmp.path(), args).unwrap();
        let matches = result["matches"].as_array().unwrap();
        // OAuth appears in backend, frontend, and notes
        let projects: Vec<&str> = matches
            .iter()
            .filter_map(|m| m["project"].as_str())
            .collect();
        assert!(projects.contains(&"backend"), "should find in backend");
        assert!(projects.contains(&"frontend"), "should find in frontend");
        assert!(projects.contains(&"notes"), "should find in notes");
    }

    #[test]
    fn global_search_returns_scope_field() {
        let tmp = setup_multi_project_root();
        let args = json!({"query": "OAuth", "scope": "global"});
        let result = tool_search_global(tmp.path(), args).unwrap();
        assert_eq!(result["scope"], "global");
    }

    #[test]
    fn global_search_includes_project_field() {
        let tmp = setup_multi_project_root();
        let args = json!({"query": "K8s", "scope": "global"});
        let result = tool_search_global(tmp.path(), args).unwrap();
        let matches = result["matches"].as_array().unwrap();
        assert!(!matches.is_empty());
        // Every match must have a project field
        for m in matches {
            assert!(
                m["project"].is_string(),
                "each match must have project field"
            );
        }
    }

    #[test]
    fn global_search_respects_limit() {
        let tmp = setup_multi_project_root();
        let args = json!({"query": "#", "scope": "global", "limit": 2});
        let result = tool_search_global(tmp.path(), args).unwrap();
        let matches = result["matches"].as_array().unwrap();
        assert_eq!(matches.len(), 2);
        assert_eq!(result["truncated"], true);
    }

    #[test]
    fn global_search_skips_hidden_and_template() {
        let tmp = setup_multi_project_root();
        let args = json!({"query": "Secret", "scope": "global"});
        let result = tool_search_global(tmp.path(), args).unwrap();
        let matches = result["matches"].as_array().unwrap();
        let projects: Vec<&str> = matches
            .iter()
            .filter_map(|m| m["project"].as_str())
            .collect();
        assert!(!projects.contains(&".hidden"), "should skip hidden dirs");
        assert!(
            !projects.contains(&"_template"),
            "should skip template dirs"
        );
    }

    #[test]
    fn global_search_no_results() {
        let tmp = setup_multi_project_root();
        let args = json!({"query": "zzz_nonexistent_zzz", "scope": "global"});
        let result = tool_search_global(tmp.path(), args).unwrap();
        assert!(result["matches"].as_array().unwrap().is_empty());
        assert_eq!(result["truncated"], false);
    }

    #[test]
    fn global_search_case_insensitive() {
        let tmp = setup_multi_project_root();
        let args = json!({"query": "oauth", "scope": "global"});
        let result = tool_search_global(tmp.path(), args).unwrap();
        let matches = result["matches"].as_array().unwrap();
        assert!(
            matches.len() >= 3,
            "case-insensitive should find OAuth matches"
        );
    }

    // -- audit: cross-repo duplicate detection --

    #[test]
    fn audit_detects_internal_docs_in_repo() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let repo = TempDir::new().unwrap();
        // Put an internal doc (PRD.md) in the project repo — this is a warning
        fs::write(repo.path().join("PRD.md"), "# PRD exposed publicly").unwrap();
        fs::write(repo.path().join("README.md"), "# README").unwrap();

        let result = tool_audit(&project_root, "testproj", Some(repo.path())).unwrap();
        let actions = result["suggested_actions"].as_array().unwrap();
        let has_exposed_warning = actions
            .iter()
            .any(|a| a["action"] == "resolve_exposed_internal_docs");
        assert!(
            has_exposed_warning,
            "should warn about internal docs in project repo"
        );
    }

    #[test]
    fn audit_without_repo_shows_not_detected() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let result = tool_audit(&project_root, "testproj", None).unwrap();
        assert_eq!(result["project_repo"]["status"], "not_detected");
    }

    // -- search: repo docs/ subdirectory --

    #[test]
    fn search_includes_repo_docs_subdir() {
        let tmp = setup_docs_root();
        let project_root = tmp.path().join("testproj");
        let repo = TempDir::new().unwrap();
        let docs_dir = repo.path().join("docs");
        fs::create_dir_all(&docs_dir).unwrap();
        fs::write(
            docs_dir.join("guide.md"),
            "# Guide\n\nUnique marker zxcvbn.",
        )
        .unwrap();

        let args = json!({"query": "zxcvbn"});
        let result = tool_search(&project_root, args, Some(repo.path())).unwrap();
        let matches = result["matches"].as_array().unwrap();
        assert!(
            matches
                .iter()
                .any(|m| m["source"] == "project-repo"
                    && m["file"].as_str().unwrap().contains("guide")),
            "should find file in repo's docs/ subdirectory"
        );
    }

    // -- tool_search_global: empty query --

    #[test]
    fn global_search_empty_query() {
        let tmp = setup_multi_project_root();
        let args = json!({"query": ""});
        let result = tool_search_global(tmp.path(), args).unwrap();
        let matches = result["matches"].as_array().unwrap();
        assert!(matches.is_empty(), "empty query should return no matches");
        assert_eq!(result["error"].as_str(), Some("empty query"));
    }

    // -- tool_configure_project --

    #[test]
    fn configure_project_creates_alcove_toml_in_repo_root() {
        let repo = TempDir::new().unwrap();
        let args = json!({
            "project_name": "myproj",
            "diagram_format": "plantuml"
        });
        let result = tool_configure_project(repo.path(), args).unwrap();
        assert_eq!(result["project_name"], "myproj");

        // alcove.toml must be written in the repo root
        let toml_path = repo.path().join("alcove.toml");
        assert!(toml_path.exists());
        let contents = fs::read_to_string(&toml_path).unwrap();
        assert!(contents.contains("plantuml"));
    }

    #[test]
    fn configure_project_updates_existing_preserves_other_fields() {
        let repo = TempDir::new().unwrap();
        // First call: set core_files
        tool_configure_project(
            repo.path(),
            json!({"project_name": "myproj", "core_files": ["SPEC.md"]}),
        )
        .unwrap();
        // Second call: set diagram_format only
        tool_configure_project(
            repo.path(),
            json!({"project_name": "myproj", "diagram_format": "plantuml"}),
        )
        .unwrap();

        // Both settings must be present
        let cfg = crate::config::load_project_config(repo.path());
        assert_eq!(cfg.diagram_format(), "plantuml");
        assert_eq!(cfg.core_files(), vec!["SPEC.md"]);
    }
}
