//! Zero-Config workspace language detection (PRD §6.5).
//!
//! Scans the workspace root for well-known marker files to determine which
//! language servers should be started. Returns a list of [`LanguageLsp`]
//! descriptors — one per detected language. Only languages whose marker is
//! present in the workspace are returned.
//!
//! Language servers are started lazily on first use (not eagerly at detection
//! time), so this scan is cheap.
//!
//! # Binary resolution
//!
//! Each language server binary name (e.g. `"rust-analyzer"`) is resolved to an
//! absolute path via [`which::which`] at detection time. This ensures that GUI
//! launchers and desktop shortcuts — which typically inherit a stripped `$PATH`
//! that omits `~/.cargo/bin`, `~/.nvm/.../bin`, etc. — still find the correct
//! binary. If resolution fails, the language is skipped and a warning is logged.
//!
//! Users can override the resolved path per-language via `.pathfinder.toml`:
//! ```toml
//! [lsp.rust]
//! command = "/home/user/.cargo/bin/rust-analyzer"
//! ```

use std::path::Path;

/// Result of language detection.
///
/// Contains both languages that were fully detected (marker + binary found) and
/// languages that have markers but no LSP binaries on PATH.
#[derive(Debug, Clone)]
pub struct DetectionResult {
    /// Languages with markers AND binaries found.
    pub detected: Vec<LanguageLsp>,
    /// Languages with markers but no binary on PATH.
    pub missing: Vec<MissingLanguage>,
}

/// A language whose marker files were found but whose LSP binary is not on PATH.
///
/// Used to surface actionable install guidance in `lsp_health` responses.
#[derive(Debug, Clone)]
pub struct MissingLanguage {
    /// Short language identifier (e.g., "rust", "python").
    pub language_id: String,
    /// The marker file that was found (e.g., "Cargo.toml", "pyproject.toml").
    pub marker_file: String,
    /// All binaries that were tried and failed to resolve.
    pub tried_binaries: Vec<String>,
    /// Actionable install guidance for this language.
    pub install_hint: String,
}

/// Return an actionable install hint for each language.
///
/// Provides specific commands users can run to install their LSP servers.
#[must_use]
pub fn install_hint(language_id: &str) -> String {
    match language_id {
        "rust" => {
            "Install rust-analyzer: https://rust-analyzer.github.io/".to_string()
        }
        "go" => "Install gopls: go install golang.org/x/tools/gopls@latest".to_string(),
        "typescript" => {
            "Install typescript-language-server: npm install -g typescript-language-server typescript"
                .to_string()
        }
        "python" => {
            "Install pyright-langserver: npm install -g pyright\nOr install pylsp: pip install python-lsp-server"
                .to_string()
        }
        "java" => {
            "Install jdtls: https://github.com/eclipse-jdtls/eclipse.jdt.ls\nRequires JDK 21+"
                .to_string()
        }
        _ => format!("Install a language server for {language_id}"),
    }
}

/// Identifies a language and the command used to spawn its LSP server.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LanguageLsp {
    /// Short language identifier used as a map key (e.g., `"rust"`, `"go"`).
    pub language_id: String,
    /// The binary to execute — always an **absolute path** after detection.
    ///
    /// Resolved via `which::which` at startup; can be overridden in `.pathfinder.toml`
    /// via `lsp.<lang>.command` for non-standard installs (nix, asdf, volta, etc.).
    pub command: String,
    /// Arguments to pass after the binary (e.g., `["--stdio"]`).
    pub args: Vec<String>,
    /// The root directory to use for the LSP `initialize` request.
    ///
    /// In monorepos, this is the subdirectory containing the project's
    /// marker file (e.g., `apps/backend` for a Go backend with `go.mod` there)
    /// rather than the workspace root.
    pub root: std::path::PathBuf,
    /// Optional initialization timeout in seconds (overrides default).
    pub init_timeout_secs: Option<u64>,
    /// Auto-detected TypeScript plugins to load during initialization.
    /// Populated by detect.rs when scanning the workspace for Vue, Svelte, etc.
    pub auto_plugins: Vec<String>,
    /// Language-specific initialization options passed to LSP `initialize` request.
    ///
    /// Built by per-language detection functions:
    /// - Python: `{"python": {"pythonPath": "..."}}` (from venv detection)
    /// - Java: `{"java": {"import": {"gradle": ..., "maven": ...}}}` (jdtls settings)
    /// - All others: `serde_json::Value::Null`
    pub init_options: serde_json::Value,
}

// ---------------------------------------------------------------------------
// ST-2: Manifest pre-flight validation
// ---------------------------------------------------------------------------

fn strip_jsonc_comments(input: &str) -> String {
    let mut result = String::with_capacity(input.len());
    let mut chars = input.chars().peekable();
    let mut in_string = false;
    let mut in_block_comment = false;
    let mut in_line_comment = false;
    let mut is_escaping = false;

    while let Some(c) = chars.next() {
        if in_block_comment {
            if c == '*' && chars.peek() == Some(&'/') {
                chars.next();
                in_block_comment = false;
            }
            continue;
        }

        if in_line_comment {
            if c == '\n' {
                in_line_comment = false;
                result.push(c);
            }
            continue;
        }

        if in_string {
            if is_escaping {
                is_escaping = false;
                result.push(c);
                continue;
            }
            if c == '\\' {
                is_escaping = true;
                result.push(c);
                continue;
            }
            if c == '"' {
                in_string = false;
            }
            result.push(c);
            continue;
        }

        if c == '"' {
            in_string = true;
            result.push(c);
            continue;
        }

        if c == '/' && chars.peek() == Some(&'*') {
            chars.next();
            in_block_comment = true;
            continue;
        }

        if c == '/' && chars.peek() == Some(&'/') {
            chars.next();
            in_line_comment = true;
            continue;
        }

        result.push(c);
    }

    result
}

/// Validate a marker file before starting an LSP process.
///
/// Returns `Ok(())` when the file is structurally valid. Returns `Err(reason)`
/// when the file is malformed — `start_process` uses this to short-circuit
/// before spawning a process that would fail during initialize anyway.
///
/// # Supported languages
/// - **Rust**: Cargo.toml must be parseable TOML and contain `[package]` or `[workspace]`
/// - **Go**: go.mod must start with the `module` keyword
/// - **TypeScript**: tsconfig.json must be parseable JSON (supports JSONC comments)
/// - **Python**: pyproject.toml (if present) must be parseable TOML
/// - **Java**: pom.xml must contain `<project`; build.gradle[.kts] must be non-empty
pub(crate) fn validate_marker_file(
    marker_path: &std::path::Path,
    language_id: &str,
) -> Result<(), String> {
    let file_name = marker_path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("");

    let contents = match std::fs::read_to_string(marker_path) {
        Ok(c) => c,
        Err(e) => return Err(format!("cannot read {file_name}: {e}")),
    };

    match (language_id, file_name) {
        ("rust", "Cargo.toml") => match toml::from_str::<toml::Value>(&contents) {
            Ok(v) => {
                if v.get("package").is_some() || v.get("workspace").is_some() {
                    Ok(())
                } else {
                    Err("Cargo.toml has neither [package] nor [workspace] section".to_owned())
                }
            }
            Err(e) => Err(format!("Cargo.toml is not valid TOML: {e}")),
        },
        ("go", "go.mod") => {
            let first_line = contents.lines().next().unwrap_or("").trim();
            if first_line.starts_with("module ") || first_line == "module" {
                Ok(())
            } else {
                Err(format!(
                    "go.mod does not start with 'module' declaration (got: {first_line:?})"
                ))
            }
        }
        ("typescript", "tsconfig.json") => {
            // Many real-world tsconfig.json files use JSONC (JavaScript-style comments).
            // First try parsing the raw JSON, then fall back to stripping comments.
            if serde_json::from_str::<serde_json::Value>(&contents).is_ok() {
                Ok(())
            } else {
                let stripped = strip_jsonc_comments(&contents);
                serde_json::from_str::<serde_json::Value>(&stripped)
                    .map(|_| ())
                    .map_err(|e| format!("tsconfig.json is not valid JSON: {e}"))
            }
        }
        ("python", "pyproject.toml") => match toml::from_str::<toml::Value>(&contents) {
            Ok(_) => Ok(()),
            Err(e) => Err(format!("pyproject.toml is not valid TOML: {e}")),
        },
        ("java", "pom.xml") => {
            if contents.contains("<project") {
                Ok(())
            } else {
                Err("pom.xml does not contain <project element".to_owned())
            }
        }
        ("java", "build.gradle" | "build.gradle.kts") => {
            // Gradle files are Groovy/Kotlin scripts — minimal structural validation
            if contents.is_empty() {
                Err("build.gradle is empty".to_owned())
            } else {
                Ok(())
            }
        }
        // package.json, setup.py, requirements.txt, settings.gradle[.kts] — too loose to validate
        _ => Ok(()),
    }
}

// ---------------------------------------------------------------------------
// ST-5: Python virtual environment detection
// ---------------------------------------------------------------------------

/// Detect the Python interpreter from the workspace's virtual environment.
///
/// Checks well-known venv locations in priority order:
/// 1. `.venv/bin/python` (standard `python -m venv .venv`)
/// 2. `venv/bin/python` / `env/bin/python` / `.env/bin/python`
/// 3. `$CONDA_PREFIX/bin/python` (Conda/Mamba)
/// 4. `$VIRTUAL_ENV/bin/python` (activated venv in parent shell)
///
/// Returns `None` when no venv is detected — Pyright/pylsp then fall back to
/// the system interpreter, which is correct for non-venv projects.
#[must_use]
pub(crate) fn detect_venv(workspace_root: &std::path::Path) -> Option<std::path::PathBuf> {
    let candidates = [
        ".venv/bin/python",
        ".venv/Scripts/python.exe", // Windows
        "venv/bin/python",
        "venv/Scripts/python.exe",
        "env/bin/python",
        ".env/bin/python",
    ];

    for relative in &candidates {
        let path = workspace_root.join(relative);
        if path.exists() {
            tracing::debug!(path = %path.display(), "ST-5: detected Python venv interpreter");
            return Some(path);
        }
    }

    // Conda: $CONDA_PREFIX/bin/python
    if let Ok(conda_prefix) = std::env::var("CONDA_PREFIX") {
        let path = std::path::PathBuf::from(&conda_prefix)
            .join("bin")
            .join("python");
        if path.exists() {
            tracing::debug!(path = %path.display(), "ST-5: detected Conda Python interpreter");
            return Some(path);
        }
    }

    // $VIRTUAL_ENV: already-activated venv in the shell that launched Pathfinder
    if let Ok(venv) = std::env::var("VIRTUAL_ENV") {
        let path = std::path::PathBuf::from(&venv).join("bin").join("python");
        if path.exists() {
            tracing::debug!(path = %path.display(), "ST-5: detected activated venv via $VIRTUAL_ENV");
            return Some(path);
        }
    }

    None
}

/// Build Python LSP initialization options from the detected venv.
///
/// Returns a JSON value suitable for `initializationOptions.python.pythonPath`
/// (Pyright format) when a venv is found, or `Null` otherwise.
#[must_use]
pub(crate) fn detect_python_init_options(workspace_root: &std::path::Path) -> serde_json::Value {
    use serde_json::json;
    detect_venv(workspace_root).map_or(serde_json::Value::Null, |py_path| {
        json!({
            "python": {
                "pythonPath": py_path.to_string_lossy().as_ref()
            }
        })
    })
}

/// Build jdtls initialization options.
///
/// Enables Maven and Gradle import and, when `JAVA_HOME` is set, pins the
/// JDK home so jdtls uses the correct JDK across different shell environments.
#[must_use]
fn detect_java_init_options(workspace_root: &std::path::Path) -> serde_json::Value {
    use serde_json::json;
    // workspace_root is read for potential future per-project config but is
    // currently unused; the underscore prefix suppresses the lint.
    let _ = workspace_root;

    let java_home = std::env::var("JAVA_HOME").ok();

    let mut settings = json!({
        "java": {
            "import": {
                "gradle": { "enabled": true },
                "maven": { "enabled": true }
            }
        }
    });

    if let Some(home) = java_home {
        settings["java"]["jdt"] = json!({ "ls": { "java": { "home": home } } });
    }

    settings
}

/// Resolve a bare binary name to its absolute path using `which`.
///
/// If the config provides an explicit command (already an absolute path or
/// a user-provided string), that value is used directly without `which` lookup.
///
/// Returns `None` and logs a warning if the binary is not found on `PATH`.
fn resolve_command(name: &str, lang: &str) -> Option<String> {
    which::which(name)
        .map(|path| {
            tracing::debug!(
                language = lang,
                binary = %path.display(),
                "LSP: resolved binary path"
            );
            path.to_string_lossy().into_owned()
        })
        // NOTE: map_err is used here for its side effect (logging the warning) before
        // discarding the error with .ok(). This is intentional: we want to log why the
        // binary wasn't found, but we still want to return None rather than propagating
        // the error.
        .map_err(|_| {
            tracing::warn!(
                language = lang,
                binary = name,
                "LSP: binary not found on PATH — language server will not start. \
                 Install it or set `lsp.{lang}.command` in .pathfinder.toml to \
                 an absolute path (e.g. for nix, asdf, volta, or GUI launcher installs)"
            );
        })
        .ok()
}

/// Search for a marker file within `base` directory up to `max_depth` levels deep.
///
/// Returns the directory containing the marker file, or `None` if not found.
async fn find_marker(base: &Path, marker: &str, max_depth: usize) -> Option<std::path::PathBuf> {
    // Check base directory first (depth 0)
    if tokio::fs::metadata(base.join(marker)).await.is_ok() {
        return Some(base.to_path_buf());
    }
    if max_depth == 0 {
        return None;
    }
    // Scan immediate children (depth 1 and 2)
    let Ok(mut dir) = tokio::fs::read_dir(base).await else {
        return None;
    };
    while let Ok(Some(entry)) = dir.next_entry().await {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        // Check this subdirectory
        if tokio::fs::metadata(path.join(marker)).await.is_ok() {
            return Some(path);
        }
        // One more level if depth allows
        if max_depth >= 2 {
            let Ok(mut sub) = tokio::fs::read_dir(&path).await else {
                continue;
            };
            while let Ok(Some(sub_entry)) = sub.next_entry().await {
                let sub_path = sub_entry.path();
                if sub_path.is_dir() && tokio::fs::metadata(sub_path.join(marker)).await.is_ok() {
                    return Some(sub_path);
                }
            }
        }
    }
    None
}

// ---------------------------------------------------------------------------
// TypeScript plugin detection helpers
// ---------------------------------------------------------------------------

/// Check if the workspace contains Vue single-file components.
///
/// Scans for `.vue` files up to 4 levels deep from the workspace root.
///
/// Covers:
/// - Standard layouts: `src/App.vue`, `src/components/Button.vue`
/// - Monorepo layouts: `apps/frontend/src/App.vue`, `packages/web/src/views/Home.vue`
///
/// Avoids loading `@vue/typescript-plugin` unnecessarily in pure TS projects.
async fn workspace_has_vue_files(workspace_root: &Path) -> bool {
    fn should_exclude_dir(name: &std::ffi::OsStr) -> bool {
        matches!(
            name.to_str(),
            Some(
                "node_modules"
                    | ".git"
                    | "target"
                    | ".pnpm"
                    | ".venv"
                    | "__pycache__"
                    | "dist"
                    | "build"
            )
        )
    }

    fn has_vue_recursive(
        dir: &Path,
        depth: usize,
    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + '_>> {
        Box::pin(async move {
            if depth > 4 {
                return false;
            }
            let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
                return false;
            };
            while let Ok(Some(entry)) = entries.next_entry().await {
                let path = entry.path();
                if let Some(ext) = path.extension() {
                    if ext == "vue" {
                        return true;
                    }
                }
                if path.is_dir() {
                    if let Some(dir_name) = path.file_name() {
                        if should_exclude_dir(dir_name) {
                            continue;
                        }
                    }
                    if has_vue_recursive(&path, depth + 1).await {
                        return true;
                    }
                }
            }
            false
        })
    }
    has_vue_recursive(workspace_root, 0).await
}

/// Find a TypeScript plugin in the workspace's `node_modules`.
///
/// Checks standard npm/yarn location first, then pnpm's `.pnpm` store.
/// Returns the plugin *name* (not path) — tsserver resolves plugins by name.
async fn detect_ts_plugin(workspace_root: &Path, plugin_name: &str) -> Option<String> {
    // Standard npm/yarn: node_modules/@vue/typescript-plugin
    let standard = workspace_root.join("node_modules").join(plugin_name);
    if tokio::fs::metadata(&standard).await.is_ok() {
        return Some(plugin_name.to_owned());
    }

    // pnpm: node_modules/.pnpm/@vue+typescript-plugin@x.y.z/node_modules/@vue/typescript-plugin
    let pnpm_dir = workspace_root.join("node_modules").join(".pnpm");
    if let Ok(mut entries) = tokio::fs::read_dir(&pnpm_dir).await {
        while let Ok(Some(entry)) = entries.next_entry().await {
            let name = entry.file_name();
            let name_str = name.to_string_lossy();
            // pnpm uses `+` as path separator in directory names: @vue+typescript-plugin@2.0.0
            let pattern = plugin_name.replace('/', "+").replace('@', "");
            if name_str.contains(&pattern) {
                let plugin_path = entry.path().join("node_modules").join(plugin_name);
                if tokio::fs::metadata(&plugin_path).await.is_ok() {
                    return Some(plugin_name.to_owned());
                }
            }
        }
    }

    None
}

/// Detect TypeScript plugins for this workspace.
///
/// Priority: config override > auto-detection (only when `.vue` files exist).
async fn detect_typescript_plugins(
    workspace_root: &Path,
    config: &pathfinder_common::config::PathfinderConfig,
) -> Vec<String> {
    // Config override takes precedence
    let config_plugins: Vec<String> = config
        .lsp
        .get("typescript")
        .map(|c| c.typescript_plugins.clone())
        .unwrap_or_default();

    if !config_plugins.is_empty() {
        tracing::info!(
            plugins = ?config_plugins,
            "Using configured TypeScript plugins"
        );
        return config_plugins;
    }

    // Auto-detect Vue plugin when .vue files are present
    let mut plugins = Vec::new();
    if workspace_has_vue_files(workspace_root).await {
        if let Some(plugin) = detect_ts_plugin(workspace_root, "@vue/typescript-plugin").await {
            tracing::info!("Auto-detected @vue/typescript-plugin for Vue SFC support");
            plugins.push(plugin);
        }
    }

    plugins
}

// ---------------------------------------------------------------------------
// Main detection entry point
// ---------------------------------------------------------------------------

/// Detect available language servers for the given workspace root and configuration.
#[allow(clippy::missing_errors_doc)]
// The function is structured as one block per language (Rust, Go, TS, Python, Java).
// Each block is short but the five-language repetition pushes the total just
// over the 100-line clippy default. Suppressing to keep the pattern intact.
#[expect(
    clippy::too_many_lines,
    reason = "Five-language repetition block; each block is short but cumulative length exceeds threshold. Pattern is clean per-language — extraction would add indirection without clarity."
)]
pub async fn detect_languages(
    workspace_root: &Path,
    config: &pathfinder_common::config::PathfinderConfig,
) -> std::io::Result<DetectionResult> {
    let mut detected = Vec::new();
    let mut missing = Vec::new();

    // Helper macro to get the root_override path if configured
    macro_rules! get_override {
        ($lang:expr) => {
            config
                .lsp
                .get($lang)
                .and_then(|c| c.root_override.as_ref())
                .map(|r| workspace_root.join(r))
        };
    }

    // Helper macro to get a command override from config (skips `which` lookup).
    macro_rules! get_command_override {
        ($lang:expr) => {
            config
                .lsp
                .get($lang)
                .map(|c| c.command.clone())
                .filter(|c| !c.is_empty())
        };
    }

    // Get args from config (non-empty overrides language defaults; enables test mock flags).
    macro_rules! get_args {
        ($lang:expr, $default:expr) => {
            config
                .lsp
                .get($lang)
                .filter(|c| !c.args.is_empty())
                .map(|c| c.args.clone())
                .unwrap_or_else(|| $default)
        };
    }

    // Rust — Cargo.toml (root only; Rust workspaces always have it at the root)
    let rust_root = match get_override!("rust") {
        Some(r) => Some(r),
        None => find_marker(workspace_root, "Cargo.toml", 0).await,
    };
    if let Some(root) = rust_root {
        let has_override = get_command_override!("rust").is_some();
        let cmd =
            get_command_override!("rust").or_else(|| resolve_command("rust-analyzer", "rust"));
        if let Some(command) = cmd {
            // ST-2: validate Cargo.toml before spending time spawning the process
            let marker_path = root.join("Cargo.toml");
            if let Err(reason) = validate_marker_file(&marker_path, "rust") {
                tracing::warn!(language = "rust", %reason, "ST-2: invalid manifest — skipping LSP start");
                missing.push(MissingLanguage {
                    language_id: "rust".to_owned(),
                    marker_file: "Cargo.toml".to_string(),
                    tried_binaries: vec!["rust-analyzer".to_string()],
                    install_hint: format!("Fix Cargo.toml: {reason}"),
                });
            } else {
                detected.push(LanguageLsp {
                    language_id: "rust".to_owned(),
                    command,
                    args: get_args!("rust", vec![]),
                    root,
                    init_timeout_secs: None,
                    auto_plugins: vec![],
                    init_options: serde_json::Value::Null,
                });
            }
        } else if !has_override {
            // Marker found but no binary, and no custom command configured
            missing.push(MissingLanguage {
                language_id: "rust".to_owned(),
                marker_file: "Cargo.toml".to_string(),
                tried_binaries: vec!["rust-analyzer".to_string()],
                install_hint: install_hint("rust"),
            });
        }
    }

    // Go — go.mod (check root then up to depth 2 for monorepos like apps/backend)
    let go_root = match get_override!("go") {
        Some(r) => Some(r),
        None => find_marker(workspace_root, "go.mod", 2).await,
    };
    if let Some(root) = go_root {
        let has_override = get_command_override!("go").is_some();
        let cmd = get_command_override!("go").or_else(|| resolve_command("gopls", "go"));
        if let Some(command) = cmd {
            // ST-2: validate go.mod before spawning gopls
            let marker_path = root.join("go.mod");
            if let Err(reason) = validate_marker_file(&marker_path, "go") {
                tracing::warn!(language = "go", %reason, "ST-2: invalid manifest — skipping LSP start");
                missing.push(MissingLanguage {
                    language_id: "go".to_owned(),
                    marker_file: "go.mod".to_string(),
                    tried_binaries: vec!["gopls".to_string()],
                    install_hint: format!("Fix go.mod: {reason}"),
                });
            } else {
                detected.push(LanguageLsp {
                    language_id: "go".to_owned(),
                    command,
                    args: get_args!("go", vec![]),
                    root,
                    init_timeout_secs: None,
                    auto_plugins: vec![],
                    init_options: serde_json::Value::Null,
                });
            }
        } else if !has_override {
            missing.push(MissingLanguage {
                language_id: "go".to_owned(),
                marker_file: "go.mod".to_string(),
                tried_binaries: vec!["gopls".to_string()],
                install_hint: install_hint("go"),
            });
        }
    }

    // TypeScript / JavaScript — tsconfig.json or package.json (depth 2)
    let (ts_root, ts_marker) = if get_override!("typescript").is_some() {
        (get_override!("typescript"), None)
    } else if let Some(r) = find_marker(workspace_root, "tsconfig.json", 2).await {
        (Some(r), Some("tsconfig.json"))
    } else {
        (
            find_marker(workspace_root, "package.json", 2).await,
            Some("package.json"),
        )
    };
    if let Some(root) = ts_root {
        let has_override = get_command_override!("typescript").is_some();
        let cmd = get_command_override!("typescript")
            .or_else(|| resolve_command("typescript-language-server", "typescript"));
        if let Some(command) = cmd {
            let auto_plugins = detect_typescript_plugins(workspace_root, config).await;
            // ST-2: validate tsconfig.json before spawning tsserver
            if let Some(marker) = ts_marker {
                let marker_path = root.join(marker);
                if let Err(reason) = validate_marker_file(&marker_path, "typescript") {
                    tracing::warn!(language = "typescript", %reason, "ST-2: invalid manifest — skipping LSP start");
                    missing.push(MissingLanguage {
                        language_id: "typescript".to_owned(),
                        marker_file: marker.to_string(),
                        tried_binaries: vec!["typescript-language-server".to_string()],
                        install_hint: format!("Fix {marker}: {reason}"),
                    });
                } else {
                    detected.push(LanguageLsp {
                        language_id: "typescript".to_owned(),
                        command,
                        args: get_args!("typescript", vec!["--stdio".to_owned()]),
                        root,
                        init_timeout_secs: None,
                        auto_plugins,
                        init_options: serde_json::Value::Null,
                    });
                }
            } else {
                // config root_override — no marker to validate
                detected.push(LanguageLsp {
                    language_id: "typescript".to_owned(),
                    command,
                    args: get_args!("typescript", vec!["--stdio".to_owned()]),
                    root,
                    init_timeout_secs: None,
                    auto_plugins,
                    init_options: serde_json::Value::Null,
                });
            }
        } else if !has_override {
            missing.push(MissingLanguage {
                language_id: "typescript".to_owned(),
                marker_file: ts_marker
                    .unwrap_or("tsconfig.json or package.json")
                    .to_string(),
                tried_binaries: vec!["typescript-language-server".to_string()],
                install_hint: install_hint("typescript"),
            });
        }
    }

    // Python — pyproject.toml, setup.py, or requirements.txt (depth 2)
    let (py_root, py_marker) = if get_override!("python").is_some() {
        (get_override!("python"), None)
    } else if let Some(r) = find_marker(workspace_root, "pyproject.toml", 2).await {
        (Some(r), Some("pyproject.toml"))
    } else if let Some(r) = find_marker(workspace_root, "setup.py", 2).await {
        (Some(r), Some("setup.py"))
    } else {
        (
            find_marker(workspace_root, "requirements.txt", 2).await,
            Some("requirements.txt"),
        )
    };
    if let Some(root) = py_root {
        // Try Python LSP servers in order of preference.
        // pyright-langserver: Fast, strict type checking, most popular for modern Python (best quality)
        // pyright: npm package binary, pyright-langserver alternative
        // pylsp: Community standard, plugin ecosystem, good all-rounder
        // ruff: Extremely fast linter with built-in LSP server (ruff-lsp deprecated since 0.4.0)
        // jedi-language-server: Mature, lightweight, pure Python
        let python_lsp_candidates = [
            ("pyright-langserver", vec!["--stdio".to_owned()]),
            ("pyright", vec!["--stdio".to_owned()]),
            ("pylsp", vec![]),
            ("ruff", vec!["server".to_owned(), "--stdio".to_owned()]),
            ("jedi-language-server", vec![]),
        ];

        let has_override = get_command_override!("python").is_some();

        let maybe_command_and_args = if let Some(cmd_override) = get_command_override!("python") {
            // User specified custom command in config
            Some((cmd_override, vec!["--stdio".to_owned()])) // Keep backward compatibility: default to --stdio for custom commands
        } else {
            // Try each candidate in order, tracking which were tried
            let mut resolved = None;
            for (binary, args) in &python_lsp_candidates {
                if let Some(resolved_cmd) = resolve_command(binary, "python") {
                    resolved = Some((resolved_cmd, args.clone()));
                    break;
                }
            }
            resolved
        };

        if let Some((command, default_args)) = maybe_command_and_args {
            // ST-2: validate pyproject.toml before spawning
            let maybe_marker_path = py_marker
                .filter(|m| *m == "pyproject.toml")
                .map(|m| root.join(m));
            let manifest_ok = if let Some(mp) = maybe_marker_path {
                match validate_marker_file(&mp, "python") {
                    Ok(()) => true,
                    Err(reason) => {
                        tracing::warn!(language = "python", %reason, "ST-2: invalid manifest — skipping LSP start");
                        missing.push(MissingLanguage {
                            language_id: "python".to_owned(),
                            marker_file: "pyproject.toml".to_string(),
                            tried_binaries: vec![command.clone()],
                            install_hint: format!("Fix pyproject.toml: {reason}"),
                        });
                        false
                    }
                }
            } else {
                true // setup.py / requirements.txt — no structural validation
            };

            if manifest_ok {
                // ST-5: detect Python venv for Pyright's initializationOptions
                let init_options = detect_python_init_options(workspace_root);
                if !init_options.is_null() {
                    tracing::info!(
                        options = ?init_options,
                        "ST-5: Python venv detected — will pass pythonPath to LSP"
                    );
                }
                detected.push(LanguageLsp {
                    language_id: "python".to_owned(),
                    command,
                    args: get_args!("python", default_args),
                    root,
                    init_timeout_secs: None,
                    auto_plugins: vec![],
                    init_options,
                });
            }
        } else if !has_override {
            // No binary found and no custom command configured — add to missing
            missing.push(MissingLanguage {
                language_id: "python".to_owned(),
                marker_file: py_marker
                    .unwrap_or("pyproject.toml, setup.py, or requirements.txt")
                    .to_string(),
                tried_binaries: python_lsp_candidates
                    .iter()
                    .map(|(name, _)| name.to_string())
                    .collect(),
                install_hint: install_hint("python"),
            });
        }
    }

    // Java — pom.xml, build.gradle, build.gradle.kts (depth 2 for monorepos)
    //
    // CAVEAT: settings.gradle / settings.gradle.kts can match non-Java Gradle projects
    // (Android-only, Kotlin-only). This is an acceptable false positive — jdtls will
    // simply find no Java sources and idle. A future refinement can check for .java
    // files in src/ if this becomes a problem.
    let (java_root, java_marker) = if get_override!("java").is_some() {
        (get_override!("java"), None)
    } else if let Some(r) = find_marker(workspace_root, "pom.xml", 2).await {
        (Some(r), Some("pom.xml"))
    } else if let Some(r) = find_marker(workspace_root, "build.gradle", 2).await {
        (Some(r), Some("build.gradle"))
    } else if let Some(r) = find_marker(workspace_root, "build.gradle.kts", 2).await {
        (Some(r), Some("build.gradle.kts"))
    } else {
        (
            find_marker(workspace_root, "settings.gradle", 2).await,
            Some("settings.gradle"),
        )
    };
    if let Some(root) = java_root {
        let has_override = get_command_override!("java").is_some();
        let cmd = get_command_override!("java").or_else(|| resolve_command("jdtls", "java"));
        if let Some(command) = cmd {
            // ST-2: validate marker before spawning
            if let Some(marker) = java_marker {
                let marker_path = root.join(marker);
                if let Err(reason) = validate_marker_file(&marker_path, "java") {
                    tracing::warn!(language = "java", %reason, "ST-2: invalid manifest");
                    missing.push(MissingLanguage {
                        language_id: "java".to_owned(),
                        marker_file: marker.to_string(),
                        tried_binaries: vec!["jdtls".to_string()],
                        install_hint: format!("Fix {marker}: {reason}"),
                    });
                } else {
                    detected.push(LanguageLsp {
                        language_id: "java".to_owned(),
                        command,
                        args: get_args!("java", vec![]),
                        root,
                        init_timeout_secs: Some(180), // jdtls is slow to start on large projects
                        auto_plugins: vec![],
                        init_options: detect_java_init_options(workspace_root),
                    });
                }
            } else {
                // config root_override — no marker to validate
                detected.push(LanguageLsp {
                    language_id: "java".to_owned(),
                    command,
                    args: get_args!("java", vec![]),
                    root,
                    init_timeout_secs: Some(180),
                    auto_plugins: vec![],
                    init_options: detect_java_init_options(workspace_root),
                });
            }
        } else if !has_override {
            missing.push(MissingLanguage {
                language_id: "java".to_owned(),
                marker_file: java_marker.unwrap_or("pom.xml or build.gradle").to_string(),
                tried_binaries: vec!["jdtls".to_string()],
                install_hint: install_hint("java"),
            });
        }
    }

    Ok(DetectionResult { detected, missing })
}

/// Map a file extension to its language identifier.
///
/// Used to look up the correct LSP process when a tool call names a specific
/// file. Returns `None` if the language is unsupported.
#[must_use]
pub fn language_id_for_extension(ext: &str) -> Option<&'static str> {
    match ext {
        "rs" => Some("rust"),
        "go" => Some("go"),
        // Both TypeScript and JavaScript are served by typescript-language-server
        "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" | "vue" => Some("typescript"),
        "py" | "pyi" => Some("python"),
        "java" => Some("java"),
        _ => None,
    }
}

#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
    use super::*;
    use std::sync::Mutex;
    use tempfile::tempdir;

    // Mutex to ensure tests that modify PATH run serially
    static PATH_MUTEX: Mutex<()> = Mutex::new(());

    /// Helper to run tests with fake Python LSP binaries in PATH
    #[allow(clippy::await_holding_lock, clippy::expect_fun_call)]
    async fn test_with_fake_python_binaries<F, Fut>(binaries: &[&str], test: F)
    where
        F: FnOnce() -> Fut,
        Fut: std::future::Future<Output = ()>,
    {
        // Lock mutex to ensure serial execution
        let _guard = match PATH_MUTEX.lock() {
            Ok(guard) => guard,
            Err(poisoned) => poisoned.into_inner(), // Recover from poisoned mutex
        };

        // Create temp dir for fake binaries
        let temp_bin_dir = tempdir().expect("create temp bin dir");
        let sh_path = which::which("sh").expect("sh not found on PATH");

        // Create symlinks to `sh` for each requested binary
        for binary in binaries {
            let symlink_path = temp_bin_dir.path().join(binary);
            #[cfg(unix)]
            std::os::unix::fs::symlink(&sh_path, &symlink_path)
                .expect(&format!("create symlink for {binary}"));
        }

        // Save original PATH
        let original_path = std::env::var("PATH").ok();

        // Set PATH to ONLY our temp bin dir to avoid finding system LSPs
        let new_path = temp_bin_dir.path().to_string_lossy().to_string();
        std::env::set_var("PATH", &new_path);

        // Run the test
        test().await;

        // Restore original PATH
        if let Some(orig) = original_path {
            std::env::set_var("PATH", orig);
        } else {
            std::env::remove_var("PATH");
        }
    }

    // Helper to create a fake @vue/typescript-plugin directory in node_modules.
    fn create_vue_plugin(workspace: &Path) {
        let dir = workspace
            .join("node_modules")
            .join("@vue")
            .join("typescript-plugin");
        std::fs::create_dir_all(&dir).expect("create plugin dir");
        std::fs::write(
            dir.join("package.json"),
            r#"{"name":"@vue/typescript-plugin"}"#,
        )
        .expect("write plugin package.json");
    }

    // Helper to create a fake pnpm-style @vue/typescript-plugin.
    fn create_vue_plugin_pnpm(workspace: &Path) {
        let pkg_dir = workspace
            .join("node_modules")
            .join(".pnpm")
            .join("@vue+typescript-plugin@2.0.0")
            .join("node_modules")
            .join("@vue")
            .join("typescript-plugin");
        std::fs::create_dir_all(&pkg_dir).expect("create pnpm plugin dir");
        std::fs::write(
            pkg_dir.join("package.json"),
            r#"{"name":"@vue/typescript-plugin"}"#,
        )
        .expect("write pnpm plugin package.json");
    }

    // Helper to create a .vue file in src/.
    fn create_vue_file(workspace: &Path) {
        let src = workspace.join("src");
        std::fs::create_dir_all(&src).expect("create src dir");
        std::fs::write(src.join("App.vue"), "<script setup lang=\"ts\"></script>")
            .expect("write vue file");
    }

    // Helper to create a .vue file in a nested components/ directory.
    fn create_vue_file_nested(workspace: &Path) {
        let components = workspace.join("src").join("components");
        std::fs::create_dir_all(&components).expect("create components dir");
        std::fs::write(components.join("Button.vue"), "<template></template>")
            .expect("write vue file");
    }

    // Helper to create a .vue file deep in a monorepo (depth 3+).
    // Simulates: apps/frontend/src/views/Home.vue
    fn create_vue_file_monorepo(workspace: &Path) {
        let views = workspace
            .join("apps")
            .join("frontend")
            .join("src")
            .join("views");
        std::fs::create_dir_all(&views).expect("create monorepo views dir");
        std::fs::write(views.join("Home.vue"), "<template>Home</template>")
            .expect("write vue file");
    }

    fn make_ts_config() -> pathfinder_common::config::PathfinderConfig {
        pathfinder_common::config::PathfinderConfig::default()
    }

    // ── existing tests (preserved) ──────────────────────────────────────────

    #[tokio::test]
    async fn test_detects_cargo_toml() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("Cargo.toml"), "[package]").expect("write");
        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");
        if let Some(rust) = result.detected.iter().find(|l| l.language_id == "rust") {
            assert!(rust.args.is_empty());
            assert!(rust.init_timeout_secs.is_none());
            assert!(!rust.command.is_empty());
        }
    }

    #[tokio::test]
    async fn test_detects_go_mod() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("go.mod"), "module foo").expect("write");
        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");
        if let Some(go) = result.detected.iter().find(|l| l.language_id == "go") {
            assert!(!go.command.is_empty());
        }
    }

    #[tokio::test]
    async fn test_detects_typescript_via_tsconfig() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("tsconfig.json"), "{}").expect("write");
        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");
        if let Some(ts) = result
            .detected
            .iter()
            .find(|l| l.language_id == "typescript")
        {
            assert_eq!(ts.args, ["--stdio"]);
            assert!(ts.init_timeout_secs.is_none());
        }
    }

    #[tokio::test]
    async fn test_detects_typescript_via_package_json() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("package.json"), "{}").expect("write");
        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");
        let found = result
            .detected
            .iter()
            .any(|l| l.language_id == "typescript");
        let _ = found;
    }

    #[tokio::test]
    async fn test_detects_python_via_pyproject() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("pyproject.toml"), "[tool.poetry]").expect("write");
        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");
        if let Some(py) = result.detected.iter().find(|l| l.language_id == "python") {
            assert!(!py.command.is_empty());
        }
    }

    #[tokio::test]
    async fn test_detects_multiple_languages() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("Cargo.toml"), "[package]").expect("write");
        std::fs::write(dir.path().join("package.json"), "{}").expect("write");
        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");
        let ids: Vec<&str> = result
            .detected
            .iter()
            .map(|l| l.language_id.as_str())
            .collect();
        for id in &ids {
            assert!(["rust", "go", "typescript", "python"].contains(id));
        }
    }

    #[tokio::test]
    async fn test_empty_directory() {
        let dir = tempdir().expect("temp dir");
        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");
        assert!(result.detected.is_empty() && result.missing.is_empty());
    }

    #[tokio::test]
    async fn test_detects_go_mod_in_subdirectory() {
        let dir = tempdir().expect("temp dir");
        let sub_dir = dir.path().join("apps").join("backend");
        std::fs::create_dir_all(&sub_dir).expect("create dir");
        std::fs::write(sub_dir.join("go.mod"), "module foo").expect("write");
        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");
        if let Some(go_lang) = result.detected.into_iter().find(|l| l.language_id == "go") {
            assert_eq!(go_lang.root, sub_dir);
        }
    }

    #[tokio::test]
    async fn test_root_override_config() {
        let dir = tempdir().expect("temp dir");
        let mut config = pathfinder_common::config::PathfinderConfig::default();
        config.lsp.insert(
            "go".to_string(),
            pathfinder_common::config::LspConfig {
                command: "gopls".to_string(),
                args: vec![],
                idle_timeout_minutes: 15,
                settings: serde_json::Value::Null,
                root_override: Some("custom/backend".to_string()),
                typescript_plugins: vec![],
            },
        );

        let result = detect_languages(dir.path(), &config).await.expect("detect");
        if let Some(go_lang) = result.detected.into_iter().find(|l| l.language_id == "go") {
            assert_eq!(go_lang.root, dir.path().join("custom/backend"));
            assert_eq!(go_lang.command, "gopls");
        }
    }

    #[test]
    fn test_language_id_for_extension() {
        assert_eq!(language_id_for_extension("rs"), Some("rust"));
        assert_eq!(language_id_for_extension("go"), Some("go"));
        assert_eq!(language_id_for_extension("ts"), Some("typescript"));
        assert_eq!(language_id_for_extension("tsx"), Some("typescript"));
        assert_eq!(language_id_for_extension("js"), Some("typescript"));
        assert_eq!(language_id_for_extension("py"), Some("python"));
        assert_eq!(language_id_for_extension("md"), None);
        assert_eq!(language_id_for_extension("yaml"), None);
    }

    #[test]
    fn test_resolve_command_not_found_returns_none() {
        let result = resolve_command(
            "pathfinder_lsp_binary_that_does_not_exist_xyzzy_42",
            "test-lang",
        );
        assert!(
            result.is_none(),
            "resolve_command must return None when the binary is not on PATH"
        );
    }

    #[test]
    fn test_resolve_command_found_returns_some() {
        // Acquire PATH_MUTEX to avoid racing with tests that temporarily replace PATH.
        let _guard = match PATH_MUTEX.lock() {
            Ok(g) => g,
            Err(p) => p.into_inner(),
        };
        let result = resolve_command("sh", "shell");
        assert!(
            result.is_some(),
            "resolve_command must return Some for a binary on PATH"
        );
        let path = result.expect("sh must resolve to an absolute path");
        assert!(!path.is_empty());
    }

    #[tokio::test]
    async fn test_find_marker_finds_at_depth_2() {
        let dir = tempdir().expect("temp dir");
        let deep = dir.path().join("apps").join("backend");
        std::fs::create_dir_all(&deep).expect("create dirs");
        std::fs::write(deep.join("go.mod"), "module deep").expect("write");
        let found = find_marker(dir.path(), "go.mod", 2).await;
        assert_eq!(
            found.as_deref(),
            Some(deep.as_path()),
            "depth-2 marker must be discovered"
        );
    }

    #[tokio::test]
    async fn test_find_marker_depth_0_does_not_recurse() {
        let dir = tempdir().expect("temp dir");
        let sub = dir.path().join("sub");
        std::fs::create_dir_all(&sub).expect("create dir");
        std::fs::write(sub.join("Cargo.toml"), "[package]").expect("write");
        let found = find_marker(dir.path(), "Cargo.toml", 0).await;
        assert!(
            found.is_none(),
            "max_depth=0 must not recurse into subdirectories"
        );
    }

    #[tokio::test]
    async fn test_find_marker_missing_returns_none() {
        let dir = tempdir().expect("temp dir");
        let found = find_marker(dir.path(), "no_such_marker_file.toml", 2).await;
        assert!(found.is_none(), "absent marker must return None");
    }

    #[tokio::test]
    async fn test_command_override_empty_string_falls_back_to_which() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("go.mod"), "module test").expect("write");
        let mut config = pathfinder_common::config::PathfinderConfig::default();
        config.lsp.insert(
            "go".to_string(),
            pathfinder_common::config::LspConfig {
                command: String::default(),
                args: vec![],
                idle_timeout_minutes: 15,
                settings: serde_json::Value::Null,
                root_override: None,
                typescript_plugins: vec![],
            },
        );
        let result = detect_languages(dir.path(), &config).await.expect("detect");
        for l in &result.detected {
            assert!(!l.command.is_empty(), "resolved command must be non-empty");
        }
    }

    // ── Vue plugin auto-detection tests ─────────────────────────────────────

    #[tokio::test]
    async fn test_auto_detects_vue_plugin_when_present() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("package.json"), "{}").expect("write");
        create_vue_file(dir.path());
        create_vue_plugin(dir.path());

        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");

        if let Some(ts) = result
            .detected
            .iter()
            .find(|l| l.language_id == "typescript")
        {
            assert_eq!(ts.auto_plugins, ["@vue/typescript-plugin"]);
        }
    }

    #[tokio::test]
    async fn test_no_vue_plugin_without_vue_files() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("package.json"), "{}").expect("write");
        // Plugin installed but NO .vue files
        create_vue_plugin(dir.path());

        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");

        if let Some(ts) = result
            .detected
            .iter()
            .find(|l| l.language_id == "typescript")
        {
            assert!(
                ts.auto_plugins.is_empty(),
                "auto_plugins should be empty when no .vue files exist"
            );
        }
    }

    #[tokio::test]
    async fn test_no_vue_plugin_when_no_ts_marker() {
        let dir = tempdir().expect("temp dir");
        // Plugin + .vue files exist but no tsconfig.json / package.json
        create_vue_file(dir.path());
        create_vue_plugin(dir.path());

        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");

        assert!(
            !result
                .detected
                .iter()
                .any(|l| l.language_id == "typescript"),
            "TypeScript should not be detected without a marker file"
        );
    }

    #[tokio::test]
    async fn test_config_plugins_override_auto_detection() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("package.json"), "{}").expect("write");
        create_vue_file(dir.path());
        create_vue_plugin(dir.path());

        let mut config = pathfinder_common::config::PathfinderConfig::default();
        config.lsp.insert(
            "typescript".to_owned(),
            pathfinder_common::config::LspConfig {
                command: String::new(),
                args: vec![],
                idle_timeout_minutes: 15,
                settings: serde_json::Value::Null,
                root_override: None,
                typescript_plugins: vec!["@custom/plugin".to_owned()],
            },
        );

        let result = detect_languages(dir.path(), &config).await.expect("detect");

        if let Some(ts) = result
            .detected
            .iter()
            .find(|l| l.language_id == "typescript")
        {
            assert_eq!(
                ts.auto_plugins,
                ["@custom/plugin"],
                "should use configured plugins, not auto-detected"
            );
        }
    }

    #[tokio::test]
    async fn test_detect_vue_files_in_subdirectory() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("package.json"), "{}").expect("write");
        create_vue_file_nested(dir.path());
        create_vue_plugin(dir.path());

        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");

        if let Some(ts) = result
            .detected
            .iter()
            .find(|l| l.language_id == "typescript")
        {
            assert_eq!(
                ts.auto_plugins.len(),
                1,
                "should detect Vue files in subdirectory"
            );
        }
    }

    #[tokio::test]
    async fn test_no_vue_files_no_plugin() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("package.json"), "{}").expect("write");
        // No .vue files, no plugin installed

        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");

        if let Some(ts) = result
            .detected
            .iter()
            .find(|l| l.language_id == "typescript")
        {
            assert!(
                ts.auto_plugins.is_empty(),
                "should not detect plugin without .vue files"
            );
        }
    }

    #[tokio::test]
    async fn test_detect_vue_files_in_monorepo_deep() {
        // Vue files at depth 4: apps/frontend/src/views/Home.vue
        // This tests the recursive depth-3 scan.
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("package.json"), "{}").expect("write");
        create_vue_file_monorepo(dir.path());
        create_vue_plugin(dir.path());

        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");

        if let Some(ts) = result
            .detected
            .iter()
            .find(|l| l.language_id == "typescript")
        {
            assert_eq!(
                ts.auto_plugins.len(),
                1,
                "should detect Vue files in deep monorepo structure (apps/frontend/src/views/)"
            );
            assert_eq!(ts.auto_plugins[0], "@vue/typescript-plugin");
        }
    }

    #[tokio::test]
    async fn test_detect_vue_plugin_pnpm() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("package.json"), "{}").expect("write");
        create_vue_file(dir.path());
        create_vue_plugin_pnpm(dir.path());

        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");

        if let Some(ts) = result
            .detected
            .iter()
            .find(|l| l.language_id == "typescript")
        {
            assert_eq!(
                ts.auto_plugins.len(),
                1,
                "should detect plugin in pnpm structure"
            );
            assert_eq!(ts.auto_plugins[0], "@vue/typescript-plugin");
        }
    }

    // ── Python LSP fallback chain tests ────────────────────────────────────
    // These tests use `sh` (always available on Unix-like systems) as a stand-in
    // for Python LSP binaries. We create symlinks in a temporary directory and
    // prepend that directory to PATH.

    #[tokio::test]
    async fn test_python_not_detected_without_binary() {
        // Run with an empty fake bin dir (no Python LSP binaries)
        test_with_fake_python_binaries(&[], || async {
            // Use a temp dir with pyproject.toml but no LSP binaries in PATH
            let dir = tempdir().expect("temp dir");
            std::fs::write(dir.path().join("pyproject.toml"), "[tool.poetry]").expect("write");

            let result = detect_languages(dir.path(), &make_ts_config())
                .await
                .expect("detect");

            assert!(
                !result.detected.iter().any(|l| l.language_id == "python"),
                "Python should not be detected without any LSP binary on PATH"
            );
        })
        .await;
    }

    #[tokio::test]
    async fn test_prefers_pyright_over_pylsp() {
        test_with_fake_python_binaries(&["pyright-langserver", "pylsp"], || async {
            let dir = tempdir().expect("temp dir");
            std::fs::write(dir.path().join("pyproject.toml"), "[tool.poetry]").expect("write");

            let result = detect_languages(dir.path(), &make_ts_config())
                .await
                .expect("detect");

            if let Some(py) = result.detected.iter().find(|l| l.language_id == "python") {
                // Should prefer pyright-langserver, which uses --stdio
                assert_eq!(py.args, ["--stdio"]);
                assert!(py.command.contains("pyright-langserver"));
            } else {
                panic!("Python should be detected with pyright-langserver");
            }
        })
        .await;
    }

    #[tokio::test]
    async fn test_detects_python_fallback_to_pyright() {
        test_with_fake_python_binaries(&["pyright"], || async {
            let dir = tempdir().expect("temp dir");
            std::fs::write(dir.path().join("pyproject.toml"), "[tool.poetry]").expect("write");

            let result = detect_languages(dir.path(), &make_ts_config())
                .await
                .expect("detect");

            if let Some(py) = result.detected.iter().find(|l| l.language_id == "python") {
                // pyright uses --stdio args
                assert_eq!(py.args.len(), 1);
                assert!(py.args[0].contains("--stdio"));
                assert!(py.command.contains("pyright"));
            } else {
                panic!("Python should be detected with pyright");
            }
        })
        .await;
    }

    #[tokio::test]
    async fn test_detects_python_fallback_to_pylsp() {
        test_with_fake_python_binaries(&["pylsp"], || async {
            let dir = tempdir().expect("temp dir");
            std::fs::write(dir.path().join("pyproject.toml"), "[tool.poetry]").expect("write");

            let result = detect_languages(dir.path(), &make_ts_config())
                .await
                .expect("detect");

            if let Some(py) = result.detected.iter().find(|l| l.language_id == "python") {
                // pylsp uses empty args
                assert!(py.args.is_empty());
                assert!(py.command.contains("pylsp"));
            } else {
                panic!("Python should be detected with pylsp");
            }
        })
        .await;
    }

    #[tokio::test]
    async fn test_detects_python_fallback_to_ruff() {
        test_with_fake_python_binaries(&["ruff"], || async {
            let dir = tempdir().expect("temp dir");
            std::fs::write(dir.path().join("pyproject.toml"), "[tool.poetry]").expect("write");

            let result = detect_languages(dir.path(), &make_ts_config())
                .await
                .expect("detect");

            if let Some(py) = result.detected.iter().find(|l| l.language_id == "python") {
                // ruff uses "server --stdio" args
                assert_eq!(py.args.len(), 2);
                assert!(py.args[0].contains("server"));
                assert!(py.args[1].contains("--stdio"));
                assert!(py.command.contains("ruff"));
            } else {
                panic!("Python should be detected with ruff");
            }
        })
        .await;
    }

    #[tokio::test]
    async fn test_detects_python_fallback_to_jedi() {
        test_with_fake_python_binaries(&["jedi-language-server"], || async {
            let dir = tempdir().expect("temp dir");
            std::fs::write(dir.path().join("pyproject.toml"), "[tool.poetry]").expect("write");

            let result = detect_languages(dir.path(), &make_ts_config())
                .await
                .expect("detect");

            if let Some(py) = result.detected.iter().find(|l| l.language_id == "python") {
                // jedi-language-server uses empty args
                assert!(py.args.is_empty());
                assert!(py.command.contains("jedi-language-server"));
            } else {
                panic!("Python should be detected with jedi-language-server");
            }
        })
        .await;
    }

    #[tokio::test]
    async fn test_python_args_correct_per_binary() {
        // Test custom command with default args (backward compat: --stdio)
        test_with_fake_python_binaries(&[], || async {
            let dir = tempdir().expect("temp dir");
            std::fs::write(dir.path().join("pyproject.toml"), "[tool.poetry]").expect("write");

            let mut config = pathfinder_common::config::PathfinderConfig::default();
            config.lsp.insert(
                "python".to_string(),
                pathfinder_common::config::LspConfig {
                    command: "sh".to_string(), // Use sh as a dummy command
                    args: vec![],              // No args specified in config
                    idle_timeout_minutes: 15,
                    settings: serde_json::Value::Null,
                    root_override: None,
                    typescript_plugins: vec![],
                },
            );

            let result = detect_languages(dir.path(), &config).await.expect("detect");
            if let Some(py) = result.detected.iter().find(|l| l.language_id == "python") {
                // Custom command with no args specified in config should default to --stdio (backward compat)
                assert_eq!(py.args, ["--stdio"]);
            } else {
                panic!("Python should be detected with custom command");
            }
        })
        .await;
    }

    // ── ST-2: validate_marker_file tests ────────────────────────────────────

    #[test]
    fn test_validate_marker_file_valid_cargo_toml() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("Cargo.toml");
        std::fs::write(&path, "[package]\nname = \"foo\"").expect("write");
        assert!(validate_marker_file(&path, "rust").is_ok());
    }

    #[test]
    fn test_validate_marker_file_valid_cargo_workspace() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("Cargo.toml");
        std::fs::write(&path, "[workspace]\nmembers = []").expect("write");
        assert!(validate_marker_file(&path, "rust").is_ok());
    }

    #[test]
    fn test_validate_marker_file_invalid_cargo_toml_no_sections() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("Cargo.toml");
        std::fs::write(&path, "[dependencies]\nfoo = \"1.0\"").expect("write");
        let result = validate_marker_file(&path, "rust");
        assert!(
            result.is_err(),
            "Cargo.toml with only [dependencies] should fail"
        );
        assert!(result
            .expect_err("expected Err")
            .contains("neither [package] nor [workspace]"));
    }

    #[test]
    fn test_validate_marker_file_invalid_cargo_toml_syntax() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("Cargo.toml");
        std::fs::write(&path, "this is not toml at all !!!").expect("write");
        let result = validate_marker_file(&path, "rust");
        assert!(result.is_err(), "Malformed TOML should fail validation");
        assert!(result.expect_err("expected Err").contains("not valid TOML"));
    }

    #[test]
    fn test_validate_marker_file_valid_go_mod() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("go.mod");
        std::fs::write(&path, "module github.com/foo/bar\n\ngo 1.21").expect("write");
        assert!(validate_marker_file(&path, "go").is_ok());
    }

    #[test]
    fn test_validate_marker_file_invalid_go_mod() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("go.mod");
        std::fs::write(&path, "// corrupted file").expect("write");
        let result = validate_marker_file(&path, "go");
        assert!(result.is_err(), "go.mod without 'module' should fail");
        assert!(result
            .expect_err("expected Err")
            .contains("'module' declaration"));
    }

    #[test]
    fn test_validate_marker_file_valid_tsconfig() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("tsconfig.json");
        std::fs::write(&path, r#"{"compilerOptions":{"strict":true}}"#).expect("write");
        assert!(validate_marker_file(&path, "typescript").is_ok());
    }

    #[test]
    fn test_validate_marker_file_invalid_tsconfig() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("tsconfig.json");
        std::fs::write(&path, "{ this is not json }}").expect("write");
        let result = validate_marker_file(&path, "typescript");
        assert!(result.is_err(), "Malformed JSON tsconfig.json should fail");
        assert!(result.expect_err("expected Err").contains("not valid JSON"));
    }

    #[test]
    fn test_validate_marker_file_valid_pyproject_toml() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("pyproject.toml");
        std::fs::write(&path, "[tool.poetry]\nname = \"app\"").expect("write");
        assert!(validate_marker_file(&path, "python").is_ok());
    }

    #[test]
    fn test_validate_marker_file_invalid_pyproject_toml() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("pyproject.toml");
        std::fs::write(&path, "NOT VALID TOML !!!").expect("write");
        let result = validate_marker_file(&path, "python");
        assert!(result.is_err(), "Malformed pyproject.toml should fail");
        assert!(result.expect_err("expected Err").contains("not valid TOML"));
    }

    #[test]
    fn test_validate_marker_file_package_json_always_ok() {
        // package.json is too loose to validate structurally — always passes
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("package.json");
        std::fs::write(&path, "not even json").expect("write");
        assert!(validate_marker_file(&path, "typescript").is_ok());
    }

    // ── ST-5: detect_venv tests ──────────────────────────────────────────────

    #[test]
    fn test_detect_venv_finds_dot_venv() {
        let dir = tempdir().expect("temp dir");
        let bin = dir.path().join(".venv").join("bin");
        std::fs::create_dir_all(&bin).expect("create .venv/bin");
        let python = bin.join("python");
        std::fs::write(&python, "#!/bin/sh").expect("write fake python");
        let result = detect_venv(dir.path());
        assert_eq!(result, Some(python), "should detect .venv/bin/python");
    }

    #[test]
    fn test_detect_venv_finds_venv_fallback() {
        let dir = tempdir().expect("temp dir");
        let bin = dir.path().join("venv").join("bin");
        std::fs::create_dir_all(&bin).expect("create venv/bin");
        let python = bin.join("python");
        std::fs::write(&python, "#!/bin/sh").expect("write fake python");
        let result = detect_venv(dir.path());
        assert_eq!(result, Some(python), "should detect venv/bin/python");
    }

    #[test]
    fn test_detect_venv_prefers_dot_venv_over_venv() {
        let dir = tempdir().expect("temp dir");
        for subdir in &[".venv/bin", "venv/bin"] {
            let bin = dir.path().join(subdir);
            std::fs::create_dir_all(&bin).expect("create bin");
            std::fs::write(bin.join("python"), "#!/bin/sh").expect("write fake python");
        }
        let result = detect_venv(dir.path());
        assert_eq!(
            result,
            Some(dir.path().join(".venv").join("bin").join("python")),
            ".venv must be preferred over venv"
        );
    }

    #[test]
    fn test_detect_venv_returns_none_when_no_venv() {
        let dir = tempdir().expect("temp dir");
        let result = detect_venv(dir.path());
        assert!(result.is_none(), "should return None when no venv exists");
    }

    #[tokio::test]
    async fn test_detect_invalid_cargo_toml_adds_to_missing() {
        test_with_fake_python_binaries(&["rust-analyzer"], || async {
            let dir = tempdir().expect("temp dir");
            // ST-2: Cargo.toml with no [package] or [workspace] should be rejected
            std::fs::write(
                dir.path().join("Cargo.toml"),
                "[dependencies]\nfoo = \"1.0\"",
            )
            .expect("write");

            let result = detect_languages(dir.path(), &make_ts_config())
                .await
                .expect("detect");

            assert!(
                result.detected.iter().all(|l| l.language_id != "rust"),
                "invalid Cargo.toml should not produce a detected Rust LSP"
            );
            let maybe_missing = result.missing.iter().find(|m| m.language_id == "rust");
            assert!(
                maybe_missing.is_some(),
                "invalid Cargo.toml should add rust to missing"
            );
            let hint = &maybe_missing.expect("checked above").install_hint;
            assert!(
                hint.contains("Fix Cargo.toml"),
                "install_hint should tell user to fix the manifest, got: {hint}"
            );
        })
        .await;
    }

    // ── Java detection tests (AC-2.3 – AC-2.5, AC-2.10) ─────────────────────

    #[tokio::test]
    async fn test_detects_java_via_pom_xml() {
        test_with_fake_python_binaries(&["jdtls"], || async {
            let dir = tempdir().expect("temp dir");
            std::fs::write(
                dir.path().join("pom.xml"),
                "<project><modelVersion>4.0.0</modelVersion></project>",
            )
            .expect("write pom.xml");

            let config = pathfinder_common::config::PathfinderConfig::default();
            let result = detect_languages(dir.path(), &config).await.expect("detect");

            let java = result.detected.iter().find(|l| l.language_id == "java");
            assert!(java.is_some(), "Java should be detected via pom.xml");
            let java = java.expect("checked above");
            assert_eq!(
                java.init_timeout_secs,
                Some(180),
                "Java timeout must be 180s"
            );
            assert!(
                !java.init_options.is_null(),
                "Java init_options should not be null"
            );
            assert!(
                java.init_options["java"]["import"]["maven"]["enabled"]
                    .as_bool()
                    .unwrap_or(false),
                "Maven import should be enabled"
            );
        })
        .await;
    }

    #[tokio::test]
    async fn test_detects_java_via_build_gradle() {
        test_with_fake_python_binaries(&["jdtls"], || async {
            let dir = tempdir().expect("temp dir");
            std::fs::write(dir.path().join("build.gradle"), "plugins { id 'java' }\n")
                .expect("write build.gradle");

            let config = pathfinder_common::config::PathfinderConfig::default();
            let result = detect_languages(dir.path(), &config).await.expect("detect");

            let java = result.detected.iter().find(|l| l.language_id == "java");
            assert!(java.is_some(), "Java should be detected via build.gradle");
        })
        .await;
    }

    #[tokio::test]
    async fn test_detects_java_via_build_gradle_kts() {
        test_with_fake_python_binaries(&["jdtls"], || async {
            let dir = tempdir().expect("temp dir");
            std::fs::write(dir.path().join("build.gradle.kts"), "plugins { java }\n")
                .expect("write build.gradle.kts");

            let config = pathfinder_common::config::PathfinderConfig::default();
            let result = detect_languages(dir.path(), &config).await.expect("detect");

            let java = result.detected.iter().find(|l| l.language_id == "java");
            assert!(
                java.is_some(),
                "Java should be detected via build.gradle.kts"
            );
        })
        .await;
    }

    #[tokio::test]
    async fn test_java_not_detected_without_binary() {
        test_with_fake_python_binaries(&[], || async {
            let dir = tempdir().expect("temp dir");
            std::fs::write(
                dir.path().join("pom.xml"),
                "<project><modelVersion>4.0.0</modelVersion></project>",
            )
            .expect("write pom.xml");

            let config = pathfinder_common::config::PathfinderConfig::default();
            let result = detect_languages(dir.path(), &config).await.expect("detect");

            assert!(
                result.detected.iter().all(|l| l.language_id != "java"),
                "Java should not be in detected without jdtls binary"
            );
            let missing = result.missing.iter().find(|m| m.language_id == "java");
            assert!(
                missing.is_some(),
                "Java should be in missing when jdtls not found"
            );
            assert!(
                missing
                    .expect("checked above")
                    .tried_binaries
                    .contains(&"jdtls".to_string()),
                "jdtls should be listed in tried_binaries"
            );
        })
        .await;
    }

    // ── validate_marker_file Java tests ─────────────────────────────────────

    #[test]
    fn test_validate_marker_file_valid_pom_xml() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("pom.xml");
        std::fs::write(
            &path,
            "<project><modelVersion>4.0.0</modelVersion></project>",
        )
        .expect("write");
        assert!(validate_marker_file(&path, "java").is_ok());
    }

    #[test]
    fn test_validate_marker_file_invalid_pom_xml() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("pom.xml");
        std::fs::write(&path, "<dependency>foo</dependency>").expect("write");
        let result = validate_marker_file(&path, "java");
        assert!(result.is_err(), "pom.xml without <project should fail");
        assert!(
            result.expect_err("expected Err").contains("<project"),
            "error should mention <project element"
        );
    }

    #[test]
    fn test_validate_marker_file_empty_build_gradle() {
        let dir = tempdir().expect("temp dir");
        let path = dir.path().join("build.gradle");
        std::fs::write(&path, "").expect("write");
        let result = validate_marker_file(&path, "java");
        assert!(result.is_err(), "empty build.gradle should fail");
        assert!(
            result.expect_err("expected Err").contains("empty"),
            "error should mention empty"
        );
    }

    // ── language_id_for_extension Java ──────────────────────────────────────

    #[test]
    fn test_language_id_for_extension_java() {
        assert_eq!(language_id_for_extension("java"), Some("java"));
    }

    // ── Python init_options migration tests ─────────────────────────────────

    #[test]
    fn test_detect_python_init_options_no_venv() {
        let dir = tempdir().expect("temp dir");
        let opts = detect_python_init_options(dir.path());
        assert!(opts.is_null(), "should be Null when no venv");
    }

    #[test]
    fn test_detect_python_init_options_with_venv() {
        let dir = tempdir().expect("temp dir");
        let venv_python = dir.path().join(".venv").join("bin").join("python");
        std::fs::create_dir_all(venv_python.parent().expect("parent")).expect("mkdir");
        std::fs::write(&venv_python, "#!/bin/sh\nexec python3 \"$@\"").expect("write");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&venv_python, std::fs::Permissions::from_mode(0o755))
                .expect("chmod");
        }

        let opts = detect_python_init_options(dir.path());
        assert!(!opts.is_null(), "should not be Null when venv found");
        assert!(
            opts["python"]["pythonPath"].as_str().is_some(),
            "pythonPath should be a string"
        );
    }

    // ── detect_java_init_options structure tests ─────────────────────────────

    #[test]
    fn test_detect_java_init_options_structure() {
        let dir = tempdir().expect("temp dir");
        let opts = detect_java_init_options(dir.path());
        assert!(!opts.is_null(), "java init_options should not be null");
        assert!(
            opts["java"]["import"]["gradle"]["enabled"]
                .as_bool()
                .unwrap_or(false),
            "Gradle import should be enabled"
        );
        assert!(
            opts["java"]["import"]["maven"]["enabled"]
                .as_bool()
                .unwrap_or(false),
            "Maven import should be enabled"
        );
    }

    #[test]
    fn test_language_id_for_extension_covers_all_known() {
        assert_eq!(language_id_for_extension("rs"), Some("rust"));
        assert_eq!(language_id_for_extension("go"), Some("go"));
        assert_eq!(language_id_for_extension("ts"), Some("typescript"));
        assert_eq!(language_id_for_extension("tsx"), Some("typescript"));
        assert_eq!(language_id_for_extension("js"), Some("typescript"));
        assert_eq!(language_id_for_extension("jsx"), Some("typescript"));
        assert_eq!(language_id_for_extension("mjs"), Some("typescript"));
        assert_eq!(language_id_for_extension("cjs"), Some("typescript"));
        assert_eq!(language_id_for_extension("vue"), Some("typescript"));
        assert_eq!(language_id_for_extension("py"), Some("python"));
        assert_eq!(language_id_for_extension("pyi"), Some("python"));
        assert_eq!(language_id_for_extension("java"), Some("java"));
    }

    #[test]
    fn test_language_id_for_extension_unknown() {
        assert_eq!(language_id_for_extension("txt"), None);
        assert_eq!(language_id_for_extension("html"), None);
        assert_eq!(language_id_for_extension("css"), None);
        assert_eq!(language_id_for_extension("json"), None);
        assert_eq!(language_id_for_extension(""), None);
    }

    #[test]
    fn test_install_hint_all_languages() {
        assert!(install_hint("rust").contains("rust-analyzer"));
        assert!(install_hint("go").contains("gopls"));
        assert!(install_hint("typescript").contains("typescript-language-server"));
        assert!(install_hint("python").contains("pyright"));
        assert!(install_hint("java").contains("jdtls"));
        assert!(install_hint("unknown_lang").contains("unknown_lang"));
    }

    #[test]
    fn test_detect_venv_env_var() {
        let dir = tempdir().expect("temp dir");
        let venv_dir = dir.path().join("custom_venv").join("bin");
        std::fs::create_dir_all(&venv_dir).expect("create venv bin");
        let venv_python = venv_dir.join("python");
        std::fs::write(&venv_python, "#!/bin/sh").expect("write python");

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&venv_python, std::fs::Permissions::from_mode(0o755))
                .expect("chmod");
        }

        let _guard = match PATH_MUTEX.lock() {
            Ok(g) => g,
            Err(p) => p.into_inner(),
        };
        let orig = std::env::var("VIRTUAL_ENV").ok();
        std::env::set_var("VIRTUAL_ENV", dir.path().join("custom_venv"));

        let result = detect_venv(dir.path());

        if let Some(orig_val) = orig {
            std::env::set_var("VIRTUAL_ENV", orig_val);
        } else {
            std::env::remove_var("VIRTUAL_ENV");
        }

        assert!(
            result.is_some(),
            "should detect venv from VIRTUAL_ENV env var"
        );
    }

    #[tokio::test]
    async fn test_detect_typescript_with_args_override() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("package.json"), "{}").expect("write");

        let mut config = pathfinder_common::config::PathfinderConfig::default();
        config.lsp.insert(
            "typescript".to_string(),
            pathfinder_common::config::LspConfig {
                command: String::new(),
                args: vec!["--stdio".to_owned(), "--log-level".to_owned()],
                idle_timeout_minutes: 15,
                settings: serde_json::Value::Null,
                root_override: None,
                typescript_plugins: vec![],
            },
        );

        let result = detect_languages(dir.path(), &config).await.expect("detect");

        if let Some(ts) = result
            .detected
            .iter()
            .find(|l| l.language_id == "typescript")
        {
            assert_eq!(
                ts.args,
                vec!["--stdio".to_owned(), "--log-level".to_owned()],
                "args should match config override"
            );
        }
    }

    #[tokio::test]
    async fn test_detect_java_via_settings_gradle_kts() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(
            dir.path().join("settings.gradle"),
            "rootProject.name = \"test\"",
        )
        .expect("write");

        let result = detect_languages(dir.path(), &make_ts_config()).await;

        assert!(
            result.is_ok(),
            "detect_languages should not error: {result:?}"
        );

        let r = result.expect("detect_languages result should be Ok");
        let has_java = r.detected.iter().any(|l| l.language_id == "java")
            || r.missing.iter().any(|l| l.language_id == "java");
        assert!(
            has_java,
            "settings.gradle should trigger Java detection (or missing report)"
        );
    }

    #[tokio::test]
    async fn test_detect_python_via_requirements_txt() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("requirements.txt"), "flask==2.0").expect("write");

        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");

        let has_python = result.detected.iter().any(|l| l.language_id == "python")
            || result.missing.iter().any(|l| l.language_id == "python");
        assert!(
            has_python,
            "should detect or report Python missing with requirements.txt"
        );
    }

    #[tokio::test]
    async fn test_detect_python_via_setup_py() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(
            dir.path().join("setup.py"),
            "from setuptools import setup; setup()",
        )
        .expect("write");

        let result = detect_languages(dir.path(), &make_ts_config())
            .await
            .expect("detect");

        let has_python = result.detected.iter().any(|l| l.language_id == "python")
            || result.missing.iter().any(|l| l.language_id == "python");
        assert!(
            has_python,
            "should detect or report Python missing with setup.py"
        );
    }

    #[tokio::test]
    async fn test_detect_vue_files_in_shallow_nested_dirs() {
        let dir = tempdir().expect("temp dir");
        let nested = dir.path().join("src").join("components");
        std::fs::create_dir_all(&nested).expect("create dirs");
        std::fs::write(nested.join("Widget.vue"), "<template/>").expect("write");

        assert!(
            workspace_has_vue_files(dir.path()).await,
            "should find .vue files in shallow nested directories"
        );
    }

    #[tokio::test]
    async fn test_validate_marker_file_unsupported_marker() {
        let dir = tempdir().expect("temp dir");
        let marker = dir.path().join("package.json");
        std::fs::write(&marker, "{}").expect("write");

        let result = validate_marker_file(&marker, "rust");
        assert!(
            result.is_ok(),
            "unsupported marker file name should return Ok by default"
        );
    }

    #[tokio::test]
    async fn test_find_marker_at_root() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("Cargo.toml"), "[package]").expect("write");

        let found = find_marker(dir.path(), "Cargo.toml", 0).await;
        assert_eq!(found.as_deref(), Some(dir.path()));
    }

    #[tokio::test]
    async fn test_find_marker_at_depth_1() {
        let dir = tempdir().expect("temp dir");
        let sub = dir.path().join("backend");
        std::fs::create_dir_all(&sub).expect("create dir");
        std::fs::write(sub.join("go.mod"), "module backend").expect("write");

        let found = find_marker(dir.path(), "go.mod", 2).await;
        assert_eq!(found.as_deref(), Some(sub.as_path()));
    }

    #[tokio::test]
    async fn test_detect_command_override_skips_which() {
        let dir = tempdir().expect("temp dir");
        std::fs::write(dir.path().join("go.mod"), "module test").expect("write");

        let mut config = pathfinder_common::config::PathfinderConfig::default();
        config.lsp.insert(
            "go".to_string(),
            pathfinder_common::config::LspConfig {
                command: "/usr/local/bin/custom-gopls".to_string(),
                args: vec![],
                idle_timeout_minutes: 15,
                settings: serde_json::Value::Null,
                root_override: None,
                typescript_plugins: vec![],
            },
        );

        let result = detect_languages(dir.path(), &config).await.expect("detect");

        if let Some(go) = result.detected.iter().find(|l| l.language_id == "go") {
            assert_eq!(
                go.command, "/usr/local/bin/custom-gopls",
                "should use config command override"
            );
        }
    }

    #[tokio::test]
    async fn test_detect_ignores_build_artifact_paths() {
        let dir = tempdir().expect("temp dir");
        let target_dir = dir.path().join("target").join("debug");
        std::fs::create_dir_all(&target_dir).expect("create target dir");

        let result = detect_languages(dir.path(), &make_ts_config()).await;
        assert!(result.is_ok(), "should handle missing markers gracefully");
    }

    #[test]
    fn test_detect_python_init_options_no_venv_returns_null() {
        let dir = tempdir().expect("temp dir");
        let opts = detect_python_init_options(dir.path());
        assert!(
            opts.is_null(),
            "should return Null when no venv found: {opts:?}"
        );
    }

    #[test]
    fn test_detect_venv_conda_prefix() {
        let dir = tempdir().expect("temp dir");
        let conda_bin = dir.path().join("bin");
        std::fs::create_dir_all(&conda_bin).expect("create bin");

        let python_path = conda_bin.join("python");
        std::fs::write(&python_path, "#!/bin/sh").expect("write python");

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&python_path, std::fs::Permissions::from_mode(0o755))
                .expect("chmod");
        }

        let _guard = match PATH_MUTEX.lock() {
            Ok(g) => g,
            Err(p) => p.into_inner(),
        };
        let orig = std::env::var("CONDA_PREFIX").ok();
        std::env::set_var("CONDA_PREFIX", dir.path().to_string_lossy().as_ref());

        let result = detect_venv(dir.path());

        if let Some(orig_val) = orig {
            std::env::set_var("CONDA_PREFIX", orig_val);
        } else {
            std::env::remove_var("CONDA_PREFIX");
        }

        assert!(result.is_some(), "should detect Python from CONDA_PREFIX");
    }
}
