// Auto-split from the former monolithic doctor/mod.rs.

use super::{BOLD, DIM, GREEN, Outcome, RED, RST, WHITE};
use std::path::PathBuf;

/// Human-readable byte size for doctor output (MB / KB / B).
pub(super) fn human_bytes(bytes: u64) -> String {
    if bytes >= 1_048_576 {
        format!("{:.1} MB", bytes as f64 / 1_048_576.0)
    } else if bytes >= 1024 {
        format!("{:.1} KB", bytes as f64 / 1024.0)
    } else {
        format!("{bytes} B")
    }
}

/// Abbreviate the user's `$HOME` to `~` wherever it appears in `text` (#437).
/// Doctor output otherwise mixes `/home/<user>/…` and `~/…`, which is noisy and
/// forces users to redact their username before pasting a report. Matches both
/// the native and the forward-slash spelling of the home prefix; non-home
/// absolute paths (e.g. `/usr/local/bin`) are left untouched.
pub(super) fn tildify_home(text: &str) -> String {
    let Some(home) = dirs::home_dir() else {
        return text.to_string();
    };
    let home_str = home.to_string_lossy();
    let home_trimmed = home_str.trim_end_matches(['/', '\\']);
    if home_trimmed.is_empty() {
        return text.to_string();
    }
    let mut out = text.replace(home_trimmed, "~");
    let home_fwd = crate::core::protocol::display_path(home_trimmed);
    if home_fwd != home_trimmed {
        out = out.replace(&home_fwd, "~");
    }
    out
}

/// Render a single path for doctor output: `~` for `$HOME`, forward slashes, and
/// the home prefix matched only on a component boundary (so `/home/foo` never
/// turns `/home/foobar` into `~bar`). (#437)
pub(super) fn display_user_path(path: &std::path::Path) -> String {
    let normalized = crate::core::protocol::display_path(&path.to_string_lossy());
    let Some(home) = dirs::home_dir() else {
        return normalized;
    };
    let home_norm = crate::core::protocol::display_path(&home.to_string_lossy());
    let home_trimmed = home_norm.trim_end_matches('/');
    if home_trimmed.is_empty() {
        return normalized;
    }
    if normalized == home_trimmed {
        return "~".to_string();
    }
    match normalized.strip_prefix(home_trimmed) {
        Some(rest) if rest.starts_with('/') => format!("~{rest}"),
        _ => normalized,
    }
}

pub(super) fn print_check(outcome: &Outcome) {
    let mark = if outcome.ok {
        format!("{GREEN}✓{RST}")
    } else {
        format!("{RED}✗{RST}")
    };
    println!("  {mark}  {}", tildify_home(&outcome.line));
}

pub(super) fn path_in_path_env() -> bool {
    if let Ok(path) = std::env::var("PATH") {
        for dir in std::env::split_paths(&path) {
            if dir.join("lean-ctx").is_file() {
                return true;
            }
            if cfg!(windows)
                && (dir.join("lean-ctx.exe").is_file() || dir.join("lean-ctx.cmd").is_file())
            {
                return true;
            }
        }
    }
    false
}

pub(super) fn resolve_lean_ctx_binary() -> Option<PathBuf> {
    if let Ok(path) = std::env::var("PATH") {
        for dir in std::env::split_paths(&path) {
            if cfg!(windows) {
                let exe = dir.join("lean-ctx.exe");
                if exe.is_file() {
                    return Some(exe);
                }
                let cmd = dir.join("lean-ctx.cmd");
                if cmd.is_file() {
                    return Some(cmd);
                }
            } else {
                let bin = dir.join("lean-ctx");
                if bin.is_file() {
                    return Some(bin);
                }
            }
        }
    }
    None
}

pub(super) fn lean_ctx_version_from_path() -> Outcome {
    let resolved = resolve_lean_ctx_binary();
    let bin = resolved
        .clone()
        .unwrap_or_else(|| std::env::current_exe().unwrap_or_else(|_| "lean-ctx".into()));

    let v = env!("CARGO_PKG_VERSION");
    let note = match std::env::current_exe() {
        Ok(exe) if exe == bin => format!("{DIM}(this binary){RST}"),
        Ok(_) | Err(_) => format!("{DIM}(resolved: {}){RST}", bin.display()),
    };
    Outcome {
        ok: true,
        line: format!("{BOLD}lean-ctx version{RST}  {WHITE}lean-ctx {v}{RST}  {note}"),
    }
}

pub(super) fn rc_contains_lean_ctx(path: &PathBuf) -> bool {
    match std::fs::read_to_string(path) {
        Ok(s) => s.contains("lean-ctx"),
        Err(_) => false,
    }
}

pub(super) fn has_pipe_guard_in_content(content: &str) -> bool {
    content.contains("! -t 1")
        || content.contains("isatty stdout")
        || content.contains("IsOutputRedirected")
}

pub(super) fn rc_references_shell_hook(content: &str) -> bool {
    // Match any sourced hook file, not just the default `lean-ctx/` parent:
    // with LEAN_CTX_CONFIG_DIR the rc block references e.g. `custom/shell-hook.bash`.
    content.contains("shell-hook.")
}

pub(super) fn rc_has_pipe_guard(path: &PathBuf) -> bool {
    match std::fs::read_to_string(path) {
        Ok(s) => {
            if has_pipe_guard_in_content(&s) {
                return true;
            }
            if rc_references_shell_hook(&s) {
                let dirs_to_check = hook_dirs();
                for dir in &dirs_to_check {
                    for ext in &["zsh", "bash", "fish", "ps1"] {
                        let hook = dir.join(format!("shell-hook.{ext}"));
                        if let Ok(h) = std::fs::read_to_string(&hook)
                            && has_pipe_guard_in_content(&h)
                        {
                            return true;
                        }
                    }
                }
            }
            false
        }
        Err(_) => false,
    }
}

pub(super) fn hook_dirs() -> Vec<std::path::PathBuf> {
    let mut dirs = Vec::new();
    // Shell hooks are written to the *config* dir (`lean-ctx init`), which is
    // relocatable via LEAN_CTX_CONFIG_DIR — without it the pipe-guard check
    // reads a hook location the installer never used and reports a false
    // "pipe guard missing" on every custom-config-dir install.
    if let Ok(d) = crate::core::paths::config_dir() {
        dirs.push(d);
    }
    if let Ok(d) = crate::core::data_dir::lean_ctx_data_dir()
        && !dirs.contains(&d)
    {
        dirs.push(d);
    }
    if let Some(home) = dirs::home_dir() {
        let legacy = home.join(".lean-ctx");
        if !dirs.contains(&legacy) {
            dirs.push(legacy);
        }
        let xdg = home.join(".config").join("lean-ctx");
        if !dirs.contains(&xdg) {
            dirs.push(xdg);
        }
    }
    dirs
}

pub(super) fn is_active_shell_impl(
    rc_name: &str,
    shell: &str,
    is_windows: bool,
    is_powershell: bool,
) -> bool {
    match rc_name {
        "~/.zshrc" => shell.contains("zsh"),
        "~/.bashrc" => {
            // On Windows, .bashrc is only relevant when explicitly running
            // inside Git Bash (not PowerShell, cmd, or other Windows shells).
            // Git Bash sets $SHELL to bash.exe system-wide, which makes $SHELL
            // unreliable on Windows. We also check that the user is NOT in
            // PowerShell (PSModulePath) and NOT in plain cmd (PROMPT).
            if is_windows {
                if is_powershell {
                    return false;
                }
                // Even without PSModulePath, $SHELL containing "bash" on Windows
                // is unreliable (Git Bash sets it globally). Only flag if running
                // from an actual bash interactive session (BASH_VERSION is set).
                return std::env::var("BASH_VERSION").is_ok();
            }
            shell.contains("bash") || shell.is_empty()
        }
        "~/.config/fish/config.fish" => shell.contains("fish"),
        _ => true,
    }
}

/// Detect whether we are running inside a PowerShell session on Windows.
/// Git Bash may set `$SHELL` to bash.exe system-wide, so `$SHELL` alone
/// is not sufficient — we also need to rule out PowerShell as the actual
/// running host process.
pub(super) fn is_powershell_session() -> bool {
    std::env::var("PSModulePath").is_ok()
}

pub(super) fn is_active_shell(rc_name: &str) -> bool {
    let shell = std::env::var("SHELL").unwrap_or_default();
    is_active_shell_impl(rc_name, &shell, cfg!(windows), is_powershell_session())
}

pub(super) struct McpLocation {
    pub(super) name: &'static str,
    pub(super) display: String,
    pub(super) path: PathBuf,
}

pub(super) fn mcp_config_locations(home: &std::path::Path) -> Vec<McpLocation> {
    let mut locations = vec![
        McpLocation {
            name: "Cursor",
            display: "~/.cursor/mcp.json".into(),
            path: home.join(".cursor").join("mcp.json"),
        },
        McpLocation {
            name: "Claude Code",
            display: format!(
                "{}",
                crate::core::editor_registry::claude_mcp_json_path(home).display()
            ),
            path: crate::core::editor_registry::claude_mcp_json_path(home),
        },
        McpLocation {
            name: "CodeBuddy",
            display: format!(
                "{}",
                crate::core::editor_registry::codebuddy_mcp_json_path(home).display()
            ),
            path: crate::core::editor_registry::codebuddy_mcp_json_path(home),
        },
        McpLocation {
            name: "Windsurf",
            display: "~/.codeium/windsurf/mcp_config.json".into(),
            path: home
                .join(".codeium")
                .join("windsurf")
                .join("mcp_config.json"),
        },
        McpLocation {
            name: "Codex",
            display: {
                let codex_dir =
                    crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
                format!("{}/config.toml", codex_dir.display())
            },
            path: crate::core::home::resolve_codex_dir()
                .unwrap_or_else(|| home.join(".codex"))
                .join("config.toml"),
        },
        McpLocation {
            name: "Gemini CLI",
            display: "~/.gemini/settings.json".into(),
            path: home.join(".gemini").join("settings.json"),
        },
        McpLocation {
            name: "Antigravity",
            display: "~/.gemini/antigravity/mcp_config.json".into(),
            path: home
                .join(".gemini")
                .join("antigravity")
                .join("mcp_config.json"),
        },
        McpLocation {
            name: "Antigravity CLI",
            display: "~/.gemini/antigravity-cli/mcp_config.json".into(),
            path: home
                .join(".gemini")
                .join("antigravity-cli")
                .join("mcp_config.json"),
        },
    ];

    #[cfg(unix)]
    {
        let zed_cfg = home.join(".config").join("zed").join("settings.json");
        locations.push(McpLocation {
            name: "Zed",
            display: "~/.config/zed/settings.json".into(),
            path: zed_cfg,
        });
    }

    locations.push(McpLocation {
        name: "Qwen Code",
        display: "~/.qwen/settings.json".into(),
        path: home.join(".qwen").join("settings.json"),
    });
    locations.push(McpLocation {
        name: "Trae",
        display: "~/.trae/mcp.json".into(),
        path: home.join(".trae").join("mcp.json"),
    });
    locations.push(McpLocation {
        name: "Amazon Q",
        display: "~/.aws/amazonq/default.json".into(),
        path: home.join(".aws").join("amazonq").join("default.json"),
    });
    locations.push(McpLocation {
        name: "JetBrains",
        display: "~/.jb-mcp.json".into(),
        path: home.join(".jb-mcp.json"),
    });
    locations.push(McpLocation {
        name: "AWS Kiro",
        display: "~/.kiro/settings/mcp.json".into(),
        path: home.join(".kiro").join("settings").join("mcp.json"),
    });
    locations.push(McpLocation {
        name: "Verdent",
        display: "~/.verdent/mcp.json".into(),
        path: home.join(".verdent").join("mcp.json"),
    });
    locations.push(McpLocation {
        name: "Crush",
        display: "~/.config/crush/crush.json".into(),
        path: home.join(".config").join("crush").join("crush.json"),
    });
    locations.push(McpLocation {
        name: "Pi",
        display: "~/.pi/agent/mcp.json".into(),
        path: home.join(".pi").join("agent").join("mcp.json"),
    });
    locations.push(McpLocation {
        name: "Amp",
        display: "~/.config/amp/settings.json".into(),
        path: home.join(".config").join("amp").join("settings.json"),
    });

    {
        #[cfg(unix)]
        let opencode_cfg = home.join(".config").join("opencode").join("opencode.json");
        #[cfg(unix)]
        let opencode_display = "~/.config/opencode/opencode.json";

        #[cfg(windows)]
        let opencode_cfg = if let Ok(appdata) = std::env::var("APPDATA") {
            std::path::PathBuf::from(appdata)
                .join("opencode")
                .join("opencode.json")
        } else {
            home.join(".config").join("opencode").join("opencode.json")
        };
        #[cfg(windows)]
        let opencode_display = "%APPDATA%/opencode/opencode.json";

        locations.push(McpLocation {
            name: "OpenCode",
            display: opencode_display.into(),
            path: opencode_cfg,
        });
    }

    #[cfg(target_os = "macos")]
    {
        let vscode_mcp = home.join("Library/Application Support/Code/User/mcp.json");
        locations.push(McpLocation {
            name: "VS Code",
            display: "~/Library/Application Support/Code/User/mcp.json".into(),
            path: vscode_mcp,
        });
        // Insiders keeps a fully separate profile dir — a server registered in
        // stable is invisible there (GH #694).
        locations.push(McpLocation {
            name: "VS Code Insiders",
            display: "~/Library/Application Support/Code - Insiders/User/mcp.json".into(),
            path: home.join("Library/Application Support/Code - Insiders/User/mcp.json"),
        });
    }
    #[cfg(target_os = "linux")]
    {
        let user_dirs = [
            home.join(".config/Code/User"),
            home.join(".config/Code - Insiders/User"),
            home.join(".vscode-server/data/User"),
        ];
        let user_dir = user_dirs
            .iter()
            .find(|p| p.exists())
            .cloned()
            .unwrap_or_else(|| user_dirs[0].clone());
        let vscode_mcp = user_dir.join("mcp.json");
        let display = vscode_mcp.strip_prefix(home).unwrap_or(&vscode_mcp);
        let display_str = format!("~/{}", display.display());
        locations.push(McpLocation {
            name: "VS Code",
            display: display_str,
            path: vscode_mcp,
        });
        // When stable AND Insiders coexist the chain above resolves to stable
        // only — surface Insiders separately so its config is not a blind
        // spot (GH #694).
        let insiders_user = home.join(".config/Code - Insiders/User");
        if user_dir != insiders_user && insiders_user.exists() {
            locations.push(McpLocation {
                name: "VS Code Insiders",
                display: "~/.config/Code - Insiders/User/mcp.json".into(),
                path: insiders_user.join("mcp.json"),
            });
        }
    }
    #[cfg(target_os = "windows")]
    {
        if let Ok(appdata) = std::env::var("APPDATA") {
            let vscode_mcp = std::path::PathBuf::from(&appdata).join("Code/User/mcp.json");
            locations.push(McpLocation {
                name: "VS Code",
                display: "%APPDATA%/Code/User/mcp.json".into(),
                path: vscode_mcp,
            });
            locations.push(McpLocation {
                name: "VS Code Insiders",
                display: "%APPDATA%/Code - Insiders/User/mcp.json".into(),
                path: std::path::PathBuf::from(&appdata).join("Code - Insiders/User/mcp.json"),
            });
        }
    }

    locations.push(McpLocation {
        name: "Copilot CLI",
        display: "~/.copilot/mcp-config.json".into(),
        path: home.join(".copilot/mcp-config.json"),
    });

    locations.push(McpLocation {
        name: "Hermes Agent",
        display: "~/.hermes/config.yaml".into(),
        path: home.join(".hermes").join("config.yaml"),
    });

    {
        let cline_path = crate::core::editor_registry::cline_mcp_path();
        if cline_path.to_str().is_some_and(|s| s != "/nonexistent") {
            locations.push(McpLocation {
                name: "Cline",
                display: cline_path.display().to_string(),
                path: cline_path,
            });
        }
    }
    {
        let roo_path = crate::core::editor_registry::roo_mcp_path();
        if roo_path.to_str().is_some_and(|s| s != "/nonexistent") {
            locations.push(McpLocation {
                name: "Roo Code",
                display: roo_path.display().to_string(),
                path: roo_path,
            });
        }
    }

    locations
}

pub(super) fn has_lean_ctx_mcp_entry(content: &str) -> bool {
    // Parse as JSONC: editor config files (VS Code settings.json / mcp.json,
    // Cursor, Windsurf, …) commonly contain comments and trailing commas which
    // strict JSON rejects. See issue #311.
    if let Ok(json) = crate::core::jsonc::parse_jsonc(content) {
        // Known container keys across editors that hold a map of MCP servers:
        //   mcpServers       — most agents (Cursor, Claude, Windsurf, …)
        //   servers          — VS Code mcp.json
        //   context_servers  — Zed settings.json
        for key in ["mcpServers", "servers", "context_servers"] {
            if let Some(servers) = json.get(key).and_then(|v| v.as_object())
                && servers.contains_key("lean-ctx")
            {
                return true;
            }
        }
        // mcp.servers.lean-ctx (some multi-level configs)
        if let Some(servers) = json
            .get("mcp")
            .and_then(|v| v.get("servers"))
            .and_then(|v| v.as_object())
            && servers.contains_key("lean-ctx")
        {
            return true;
        }
        // mcp.lean-ctx — OpenCode's schema (https://opencode.ai/config.json)
        // nests servers DIRECTLY under "mcp", no "servers" level (GH #686).
        if let Some(servers) = json.get("mcp").and_then(|v| v.as_object())
            && servers.contains_key("lean-ctx")
        {
            return true;
        }
        // Parsed cleanly but no lean-ctx entry under any known key.
        return false;
    }
    // Unparseable even as JSONC: fall back to a substring heuristic.
    content.contains("lean-ctx")
}

pub(super) fn lean_ctx_mcp_location_names(
    home: &std::path::Path,
) -> std::collections::BTreeSet<&'static str> {
    let mut names = std::collections::BTreeSet::new();
    for loc in mcp_config_locations(home) {
        if let Ok(content) = std::fs::read_to_string(&loc.path)
            && has_lean_ctx_mcp_entry(&content)
        {
            names.insert(loc.name);
        }
    }
    names
}

pub(super) fn proxy_auth_probe(port: u16) -> bool {
    use std::io::{Read, Write};
    use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};

    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
    let token = crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN");

    let Ok(mut stream) = TcpStream::connect_timeout(&addr, crate::proxy_setup::proxy_timeout())
    else {
        return false;
    };
    let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(3)));

    let req = format!(
        "GET /health HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {token}\r\nConnection: close\r\n\r\n"
    );
    if stream.write_all(req.as_bytes()).is_err() {
        return false;
    }

    let mut buf = [0u8; 128];
    let Ok(n) = stream.read(&mut buf) else {
        return false;
    };
    let response = String::from_utf8_lossy(&buf[..n]);
    response.contains("200") || response.contains("ok")
}

/// Fetches the proxy's live upstreams from the authenticated `/status` endpoint.
/// Returns `(anthropic, openai, chatgpt, gemini)`, or `None` if unreachable or
/// the response lacks the `upstreams` block. Drives the #449 drift check.
pub(super) fn proxy_live_upstreams(port: u16) -> Option<(String, String, String, String)> {
    let token = crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN");
    let url = format!("http://127.0.0.1:{port}/status");
    let resp = ureq::get(&url)
        .header("Authorization", &format!("Bearer {token}"))
        .call()
        .ok()?;
    let body = resp.into_body().read_to_string().ok()?;
    let v: serde_json::Value = serde_json::from_str(&body).ok()?;
    let up = v.get("upstreams")?;
    Some((
        up.get("anthropic")?.as_str()?.to_string(),
        up.get("openai")?.as_str()?.to_string(),
        up.get("chatgpt")?.as_str()?.to_string(),
        up.get("gemini")?.as_str()?.to_string(),
    ))
}

/// How Claude Code currently receives the full lean-ctx instructions.
///
/// Single source of truth for the main doctor check *and* `doctor integrations`
/// (GH #396: both previously demanded the retired `~/.claude/rules/lean-ctx.md`,
/// which `setup` deletes since the v3 layout — GL #555).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ClaudeInstructionsState {
    /// rules_scope=project: global instructions are intentionally absent.
    ProjectScope,
    /// rules_injection=off: user opted out of instructions entirely (GH #361).
    InjectionOff,
    /// rules_injection=dedicated: SessionStart hook injects, skill on disk.
    DedicatedWithSkill,
    /// rules_injection=dedicated but the skill is missing.
    DedicatedMissingSkill,
    /// CLAUDE.md block + on-demand skill (post-3.8 default layout).
    BlockAndSkill,
    /// CLAUDE.md block present, skill missing (still functional).
    BlockOnly,
    /// Legacy rules file from a pre-3.8 install (works until next setup).
    LegacyRules,
    /// Nothing installed — Claude only sees the 2048-char-capped MCP instructions.
    Missing,
}

impl ClaudeInstructionsState {
    pub(super) fn ok(self) -> bool {
        !matches!(self, Self::DedicatedMissingSkill | Self::Missing)
    }
}

pub(super) fn claude_instructions_state(
    home: &std::path::Path,
    scope: crate::core::config::RulesScope,
    injection: crate::core::config::RulesInjection,
) -> ClaudeInstructionsState {
    use ClaudeInstructionsState as S;

    if scope == crate::core::config::RulesScope::Project {
        return S::ProjectScope;
    }
    if injection == crate::core::config::RulesInjection::Off {
        return S::InjectionOff;
    }

    let has_skill = home.join(".claude/skills/lean-ctx/SKILL.md").exists();

    if injection == crate::core::config::RulesInjection::Dedicated {
        return if has_skill {
            S::DedicatedWithSkill
        } else {
            S::DedicatedMissingSkill
        };
    }

    let claude_md = crate::core::editor_registry::claude_state_dir(home).join("CLAUDE.md");
    let has_block = std::fs::read_to_string(&claude_md)
        .is_ok_and(|c| c.contains(crate::hooks::agents::CLAUDE_MD_BLOCK_START));
    if has_block {
        return if has_skill {
            S::BlockAndSkill
        } else {
            S::BlockOnly
        };
    }

    let has_rules = crate::core::editor_registry::claude_rules_dir(home)
        .join("lean-ctx.md")
        .exists();
    if has_rules {
        return S::LegacyRules;
    }

    S::Missing
}

/// CodeBuddy instructions state — mirrors `claude_instructions_state` since
/// CodeBuddy uses the same CODEBUDDY.md block + skill pattern as Claude Code.
pub(super) fn codebuddy_instructions_state(
    home: &std::path::Path,
    scope: crate::core::config::RulesScope,
    injection: crate::core::config::RulesInjection,
) -> ClaudeInstructionsState {
    use ClaudeInstructionsState as S;

    if scope == crate::core::config::RulesScope::Project {
        return S::ProjectScope;
    }
    if injection == crate::core::config::RulesInjection::Off {
        return S::InjectionOff;
    }

    let has_skill = home.join(".codebuddy/skills/lean-ctx/SKILL.md").exists();

    if injection == crate::core::config::RulesInjection::Dedicated {
        return if has_skill {
            S::DedicatedWithSkill
        } else {
            S::DedicatedMissingSkill
        };
    }

    let codebuddy_md = crate::core::editor_registry::codebuddy_state_dir(home).join("CODEBUDDY.md");
    let has_block = std::fs::read_to_string(&codebuddy_md)
        .is_ok_and(|c| c.contains(crate::hooks::agents::CODEBUDDY_MD_BLOCK_START));
    if has_block {
        return if has_skill {
            S::BlockAndSkill
        } else {
            S::BlockOnly
        };
    }

    let has_rules = crate::core::editor_registry::codebuddy_rules_dir(home)
        .join("lean-ctx.md")
        .exists();
    if has_rules {
        return S::LegacyRules;
    }

    S::Missing
}

pub(super) fn claude_binary_exists() -> bool {
    #[cfg(unix)]
    {
        std::process::Command::new("which")
            .arg("claude")
            .output()
            .is_ok_and(|o| o.status.success())
    }
    #[cfg(windows)]
    {
        std::process::Command::new("where")
            .arg("claude")
            .output()
            .is_ok_and(|o| o.status.success())
    }
}

pub(super) fn codebuddy_binary_exists() -> bool {
    #[cfg(unix)]
    {
        std::process::Command::new("which")
            .arg("codebuddy")
            .output()
            .is_ok_and(|o| o.status.success())
    }
    #[cfg(windows)]
    {
        std::process::Command::new("where")
            .arg("codebuddy")
            .output()
            .is_ok_and(|o| o.status.success())
    }
}

#[cfg(test)]
mod tests {
    use super::{display_user_path, has_lean_ctx_mcp_entry, tildify_home};
    use std::path::Path;

    // GH #686: OpenCode's schema (https://opencode.ai/config.json) nests
    // servers DIRECTLY under "mcp" — `mcp.lean-ctx`, no "servers" level. The
    // doctor used to only walk `mcp.servers.lean-ctx` and reported a working
    // install as missing.
    #[test]
    fn opencode_direct_mcp_child_is_recognized() {
        let opencode = r#"{
            "$schema": "https://opencode.ai/config.json",
            "mcp": {
                "lean-ctx": { "command": ["/usr/bin/lean-ctx"], "enabled": true, "type": "local" }
            }
        }"#;
        assert!(has_lean_ctx_mcp_entry(opencode));
    }

    #[test]
    fn mcp_servers_nested_form_still_recognized() {
        let nested = r#"{ "mcp": { "servers": { "lean-ctx": { "command": "lean-ctx" } } } }"#;
        assert!(has_lean_ctx_mcp_entry(nested));
        let flat = r#"{ "mcpServers": { "lean-ctx": { "command": "lean-ctx" } } }"#;
        assert!(has_lean_ctx_mcp_entry(flat));
    }

    #[test]
    fn unrelated_mcp_children_are_not_a_match() {
        // A different server directly under "mcp" must not count as ours.
        let other = r#"{ "mcp": { "some-other-server": { "command": "x" } } }"#;
        assert!(!has_lean_ctx_mcp_entry(other));
    }

    #[test]
    fn display_user_path_abbreviates_home() {
        if let Some(home) = dirs::home_dir() {
            let shown = display_user_path(&home.join(".cursor").join("mcp.json"));
            assert_eq!(shown, "~/.cursor/mcp.json");
            assert_eq!(display_user_path(&home), "~");
        }
    }

    #[test]
    fn display_user_path_leaves_non_home_paths() {
        assert_eq!(
            display_user_path(Path::new("/opt/leanctx/bin")),
            "/opt/leanctx/bin"
        );
    }

    #[test]
    fn display_user_path_normalizes_separators() {
        assert_eq!(
            display_user_path(Path::new("rel\\sub\\file")),
            "rel/sub/file"
        );
    }

    #[test]
    fn display_user_path_respects_component_boundary() {
        // `/home/foo` must never turn `/home/foo-sibling/...` into `~-sibling/...`.
        if let Some(home) = dirs::home_dir()
            && let (Some(parent), Some(name)) = (home.parent(), home.file_name())
        {
            let sibling = parent
                .join(format!("{}-sibling", name.to_string_lossy()))
                .join("x");
            assert!(!display_user_path(&sibling).starts_with('~'));
        }
    }

    #[test]
    fn tildify_home_replaces_home_in_formatted_text() {
        if let Some(home) = dirs::home_dir() {
            let line = format!("ok ({}/.config/x)", home.display());
            let shown = tildify_home(&line);
            assert!(shown.contains("~/.config/x"), "got: {shown}");
            assert!(
                !shown.contains(&home.display().to_string()),
                "home leaked: {shown}"
            );
        }
    }

    #[test]
    fn claude_instructions_state_detects_pointer_block() {
        // GH #549: the check must recognise the pointer block the installer
        // actually writes (delimited by `<!-- lean-ctx -->`), not the retired
        // full-rules marker — otherwise it falsely reports the block missing.
        let _lock = crate::core::data_dir::test_env_lock();
        use crate::core::config::{RulesInjection, RulesScope};
        let tmp = tempfile::tempdir().unwrap();
        let home = tmp.path();
        let dir = crate::core::editor_registry::claude_state_dir(home);
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(
            dir.join("CLAUDE.md"),
            format!(
                "# notes\n\n{}\n## lean-ctx\n{}\n",
                crate::hooks::agents::CLAUDE_MD_BLOCK_START,
                crate::core::rules_canonical::AGENTS_BLOCK_END
            ),
        )
        .unwrap();

        let state =
            super::claude_instructions_state(home, RulesScope::Global, RulesInjection::Shared);
        assert_ne!(
            state,
            super::ClaudeInstructionsState::Missing,
            "doctor must detect the CLAUDE.md pointer block (#549)"
        );
        assert!(state.ok(), "a detected block must be a healthy state");
    }

    #[test]
    fn codebuddy_instructions_state_detects_pointer_block() {
        let _lock = crate::core::data_dir::test_env_lock();
        use crate::core::config::{RulesInjection, RulesScope};
        let tmp = tempfile::tempdir().unwrap();
        let home = tmp.path();
        let dir = crate::core::editor_registry::codebuddy_state_dir(home);
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(
            dir.join("CODEBUDDY.md"),
            format!(
                "# notes\n\n{}\n## lean-ctx\n{}\n",
                crate::hooks::agents::CODEBUDDY_MD_BLOCK_START,
                crate::core::rules_canonical::AGENTS_BLOCK_END
            ),
        )
        .unwrap();

        let state =
            super::codebuddy_instructions_state(home, RulesScope::Global, RulesInjection::Shared);
        assert_ne!(
            state,
            super::ClaudeInstructionsState::Missing,
            "doctor must detect the CODEBUDDY.md pointer block (#549)"
        );
        assert!(state.ok());
    }
}
