//! Config show command and rendering.
//!
//! Functions for displaying user config, project config, shell status,
//! diagnostics, and runtime info.

use std::collections::HashSet;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};

use anyhow::Context;
use color_print::cformat;
use worktrunk::config::{
    ProjectConfig, UserConfig, default_system_config_path, require_config_path, system_config_path,
};
use worktrunk::git::{CiPlatform, ErrorExt, Repository};
use worktrunk::path::format_path_for_display;
use worktrunk::shell::{FileDetectionResult, Shell, scan_for_detection_details};
use worktrunk::shell_exec::Cmd;
use worktrunk::styling::{
    FormattedMessage, error_message, format_bash_with_gutter, format_heading, format_toml,
    format_with_gutter, hint_message, info_message, success_message, warning_message,
};

use crate::cli::{SwitchFormat, version_str};
use crate::commands::configure_shell::{ConfigAction, ConfigureResult, scan_shell_configs};
use crate::commands::list::ci_status::CiToolsStatus;
use crate::help_pager::show_help_in_pager;
use crate::llm::test_commit_generation;
use crate::output;

/// Handle the config show command
pub fn handle_config_show(full: bool, format: SwitchFormat) -> anyhow::Result<()> {
    if format == SwitchFormat::Json {
        return handle_config_show_json();
    }
    // Build the complete output as a string
    let mut show_output = String::new();

    // Render system config section (only when a system config file exists)
    let has_system_config = render_system_config(&mut show_output)?;
    if has_system_config {
        show_output.push('\n');
    }

    // Render user config
    render_user_config(&mut show_output, has_system_config)?;
    show_output.push('\n');

    // Render project config if in a git repository
    render_project_config(&mut show_output)?;
    show_output.push('\n');

    // Render shell integration status
    render_shell_status(&mut show_output)?;

    // Render Claude Code status (only when claude CLI is available)
    if is_claude_available() {
        show_output.push('\n');
        render_claude_code_status(&mut show_output)?;
    }

    // Render Codex status (only when codex CLI is available)
    if is_codex_available() {
        show_output.push('\n');
        render_codex_status(&mut show_output)?;
    }

    // Render OpenCode status (only when opencode CLI is available)
    if is_opencode_available() {
        show_output.push('\n');
        render_opencode_status(&mut show_output)?;
    }

    // Render Gemini status (only when gemini CLI is available)
    if is_gemini_available() {
        show_output.push('\n');
        render_gemini_status(&mut show_output)?;
    }

    // Run full diagnostic checks if requested (includes slow network calls)
    if full {
        show_output.push('\n');
        render_diagnostics(&mut show_output)?;
    }

    // Render runtime info at the bottom (version, binary name, shell integration status)
    show_output.push('\n');
    render_runtime_info(&mut show_output)?;

    // Display through pager (config show is always long-form output)
    show_help_in_pager(&show_output, true);

    Ok(())
}

/// JSON output for config show: paths, existence, and parsed config contents.
fn handle_config_show_json() -> anyhow::Result<()> {
    let user_path = require_config_path()?;
    let user_exists = user_path.exists();
    let user_config = if user_exists {
        Some(serde_json::to_value(&UserConfig::load()?)?)
    } else {
        None
    };

    let (project_path, project_config, project_identifier) = if let Ok(repo) = Repository::current()
    {
        let config = repo.load_project_config()?;
        let on_disk = repo.project_config_path()?;
        // When config resolved but not from an existing on-disk file, it came
        // from the object-store fallback (bare repo, default branch checked out
        // in no worktree — #3461). Surface that revision spec as the source so
        // `path`/`exists`/`config` agree, instead of pointing `path` at a
        // missing file while `config` is populated.
        let path = match &on_disk {
            Some(p) if p.exists() => on_disk.clone(),
            _ if config.is_some() => repo
                .default_branch_project_config_content()
                .map(|(_, spec)| spec),
            _ => on_disk.clone(),
        };
        let identifier = repo.project_identifier().ok();
        (
            path,
            config.map(|c| serde_json::to_value(&c)).transpose()?,
            identifier,
        )
    } else {
        (None, None, None)
    };

    let system_path = system_config_path().or_else(default_system_config_path);
    let system_exists = system_path.as_ref().is_some_and(|p| p.exists());

    let output = serde_json::json!({
        "user": {
            "path": user_path,
            "exists": user_exists,
            "config": user_config,
        },
        "project": {
            "path": project_path,
            // Config source resolved — an on-disk file or the object-store
            // fallback — iff `config` is populated. Keying `exists` off the
            // loaded config (not `path.exists()`) keeps it consistent with
            // `config` in the object-store case, where `path` is a revision
            // spec with no file on disk.
            "exists": project_config.is_some(),
            "identifier": project_identifier,
            "config": project_config,
        },
        "system": {
            "path": system_path,
            "exists": system_exists,
        },
    });
    worktrunk::styling::println!("{}", serde_json::to_string_pretty(&output)?);
    Ok(())
}

// ==================== Helper Functions ====================

/// Check if Claude Code CLI is available
pub(super) fn is_claude_available() -> bool {
    // Allow tests to override detection
    if let Ok(val) = std::env::var("WORKTRUNK_TEST_CLAUDE_INSTALLED") {
        return val == "1";
    }
    which::which("claude").is_ok()
}

/// Check if Codex CLI is available
pub(super) fn is_codex_available() -> bool {
    // Allow tests to override detection
    if let Ok(val) = std::env::var("WORKTRUNK_TEST_CODEX_INSTALLED") {
        return val == "1";
    }
    which::which("codex").is_ok()
}

/// Get the home directory for Claude Code config detection
pub(super) fn home_dir() -> Option<PathBuf> {
    // Try HOME/USERPROFILE env vars first (for tests and explicit overrides),
    // then fall back to the OS lookup
    std::env::var("HOME")
        .or_else(|_| std::env::var("USERPROFILE"))
        .ok()
        .map(PathBuf::from)
        .or_else(worktrunk::path::home_dir)
}

/// Get the Claude Code config directory.
///
/// Honors `CLAUDE_CONFIG_DIR`, which Claude Code uses to relocate its entire
/// config tree (`settings.json`, `plugins/`, ...) away from the default
/// `~/.claude`. A leading `~/` in the value is expanded against the home
/// directory; the shell normally expands it before the variable is set, so a
/// literal `~` only reaches us when the variable is set in a non-shell context.
pub(super) fn claude_config_dir() -> Option<PathBuf> {
    if let Ok(dir) = std::env::var("CLAUDE_CONFIG_DIR")
        && !dir.is_empty()
    {
        if let Some(rest) = dir.strip_prefix("~/") {
            return home_dir().map(|home| home.join(rest));
        }
        return Some(PathBuf::from(dir));
    }
    home_dir().map(|home| home.join(".claude"))
}

/// Check if the worktrunk plugin is installed in Claude Code
pub(super) fn is_plugin_installed() -> bool {
    let Some(config_dir) = claude_config_dir() else {
        return false;
    };

    let plugins_file = config_dir.join("plugins/installed_plugins.json");
    let Ok(content) = std::fs::read_to_string(&plugins_file) else {
        return false;
    };

    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
        return false;
    };

    json.get("plugins")
        .and_then(|p| p.get("worktrunk@worktrunk"))
        .is_some()
}

/// Check if the statusline is configured in Claude Code settings
pub(super) fn is_statusline_configured() -> bool {
    let Some(config_dir) = claude_config_dir() else {
        return false;
    };

    let settings_file = config_dir.join("settings.json");
    let Ok(content) = std::fs::read_to_string(&settings_file) else {
        return false;
    };

    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
        return false;
    };

    json.get("statusLine")
        .and_then(|s| s.get("command"))
        .and_then(|c| c.as_str())
        .is_some_and(|cmd| cmd.contains("wt "))
}

/// Get the git version string (e.g., "2.47.1")
fn git_version() -> Option<String> {
    let output = Cmd::new("git").arg("--version").run().ok()?;
    if !output.status.success() {
        return None;
    }

    // Parse "git version 2.47.1" -> "2.47.1"
    let stdout = String::from_utf8_lossy(&output.stdout);
    stdout
        .trim()
        .strip_prefix("git version ")
        .map(|s| s.to_string())
}

/// Args for the interactive zsh compinit probe.
///
/// `+m` (disable job control) is load-bearing: without it the interactive
/// `zsh -ic` grabs wt's controlling terminal and, if the timeout kills it
/// first, leaves the pager to suspend with SIGTTOU. See `check_zsh_compinit_missing`
/// and issue #3322.
const ZSH_COMPINIT_PROBE_ARGS: [&str; 4] =
    ["--no-globalrcs", "+m", "-ic", "(( $+functions[compdef] ))"];

/// Check if zsh has compinit enabled by spawning an interactive shell
///
/// Returns true if compinit is NOT enabled (i.e., user needs to add it).
/// Returns false if compinit is enabled or we can't determine (fail-safe: don't warn).
///
// TODO(zsh-compinit-probe-unify): this duplicates `shell::detect_zsh_compinit`
// — both spawn an interactive `zsh -ic` to probe for the `compdef` function, and
// #3322 had to add the `+m` job-control fix to both. They diverge on purpose, so
// folding them into one parameterized probe isn't a mechanical rename: this one
// passes `--no-globalrcs` (checks the USER's config so we only nudge when *they*
// haven't enabled compinit) while `detect_zsh_compinit` intentionally sources
// global rcs (so shell setup won't re-add what /etc/zshrc already enables); they
// also differ in result type (bool vs Option<bool>) and runner (`Cmd` vs raw
// `Command`). Unify behind one `probe_zsh_compdef(no_globalrcs: bool)` helper.
fn check_zsh_compinit_missing() -> bool {
    // Allow tests to bypass this check since zsh subprocess behavior varies across CI envs
    if std::env::var("WORKTRUNK_TEST_COMPINIT_CONFIGURED").is_ok() {
        return false; // Assume compinit is configured
    }

    // Force compinit to be missing (for tests that expect the warning)
    if std::env::var("WORKTRUNK_TEST_COMPINIT_MISSING").is_ok() {
        return true; // Force warning to appear
    }

    // Probe zsh to check if compdef function exists (indicates compinit has run)
    // Use --no-globalrcs to skip system files (like /etc/zshrc on macOS which enables compinit)
    // This ensures we're checking the USER's configuration, not system defaults
    // Suppress stderr to avoid noise like "can't change option: zle"
    // The (( ... )) arithmetic returns exit 0 if true (compdef exists), 1 if false
    // Suppress zsh's "insecure directories" warning from compinit.
    // See detailed rationale in shell::detect_zsh_compinit().
    //
    // Bound the probe with a timeout that kills the child on expiry — the same
    // hardening detect_zsh_compinit() relies on. An interactive `zsh -ic` whose
    // startup prompts on /dev/tty (compinit's insecure-directories prompt
    // bypasses both the stdin=null default and the stderr suppression above)
    // would otherwise hang `wt config show` indefinitely. On timeout run()
    // returns Err, so the `else` below declines to warn rather than misreport.
    // `+m` disables job control so this interactive probe doesn't grab wt's
    // controlling terminal. An interactive zsh with job control on `tcsetpgrp`s
    // to claim the terminal foreground; if the 2s timeout kills it before it
    // restores that, wt is left in a background process group and the pager
    // spawned moments later (config show always pages) raises SIGTTOU,
    // suspending the command (`suspended (tty output)`). See issue #3322.
    let Ok(output) = Cmd::new("zsh")
        .args(ZSH_COMPINIT_PROBE_ARGS)
        .env("ZSH_DISABLE_COMPFIX", "true")
        .timeout(std::time::Duration::from_secs(2))
        .run()
    else {
        return false; // Can't determine, don't warn
    };

    // compdef NOT found = need to warn
    !output.status.success()
}

// ==================== Render Functions ====================

/// Render CLAUDE CODE section (plugin and statusline status).
/// Caller must check `is_claude_available()` first.
fn render_claude_code_status(out: &mut String) -> anyhow::Result<()> {
    writeln!(out, "{}", format_heading("CLAUDE CODE", None))?;

    // Plugin status
    let plugin_installed = is_plugin_installed();
    if plugin_installed {
        writeln!(out, "{}", success_message("Plugin installed"))?;
    } else {
        writeln!(
            out,
            "{}",
            hint_message(cformat!(
                "Plugin not installed. To install, run <underline>wt config plugins claude install</>"
            ))
        )?;
    }

    // Statusline status
    let statusline_configured = is_statusline_configured();
    if statusline_configured {
        writeln!(out, "{}", success_message("Statusline configured"))?;
    } else {
        writeln!(
            out,
            "{}",
            hint_message(cformat!(
                "Statusline not configured. To configure, run <underline>wt config plugins claude install-statusline</>"
            ))
        )?;
    }

    Ok(())
}

/// Render CODEX section (marketplace install hint).
/// Caller must check `is_codex_available()` first.
fn render_codex_status(out: &mut String) -> anyhow::Result<()> {
    writeln!(out, "{}", format_heading("CODEX", None))?;
    writeln!(out, "{}", success_message("Codex CLI available"))?;
    writeln!(
        out,
        "{}",
        hint_message(cformat!(
            "If Worktrunk is not installed in /plugins yet, run <underline>wt config plugins codex install</>"
        ))
    )?;

    Ok(())
}

/// Check if OpenCode CLI is available
fn is_opencode_available() -> bool {
    // Allow tests to override detection
    if let Ok(val) = std::env::var("WORKTRUNK_TEST_OPENCODE_INSTALLED") {
        return val == "1";
    }
    which::which("opencode").is_ok()
}

/// Render OPENCODE section (plugin status).
/// Caller must check `is_opencode_available()` first.
fn render_opencode_status(out: &mut String) -> anyhow::Result<()> {
    writeln!(out, "{}", format_heading("OPENCODE", None))?;

    // Plugin status
    let plugin_installed = super::opencode::is_plugin_installed();
    let plugin_exists = super::opencode::plugin_file_exists();
    if plugin_installed {
        writeln!(out, "{}", success_message("Plugin installed"))?;
    } else if plugin_exists {
        writeln!(
            out,
            "{}",
            hint_message(cformat!(
                "Plugin outdated. To update, run <underline>wt config plugins opencode install</>"
            ))
        )?;
    } else {
        writeln!(
            out,
            "{}",
            hint_message(cformat!(
                "Plugin not installed. To install, run <underline>wt config plugins opencode install</>"
            ))
        )?;
    }

    Ok(())
}

/// Check if Gemini CLI is available
fn is_gemini_available() -> bool {
    // Allow tests to override detection
    if let Ok(val) = std::env::var("WORKTRUNK_TEST_GEMINI_INSTALLED") {
        return val == "1";
    }
    which::which("gemini").is_ok()
}

/// Check if the worktrunk extension is installed in Gemini CLI.
///
/// `gemini extensions install` clones the extension into
/// `~/.gemini/extensions/<name>/`, so a worktrunk install leaves a
/// `gemini-extension.json` whose `name` is `worktrunk` at that path.
fn is_gemini_extension_installed() -> bool {
    let Some(home) = home_dir() else {
        return false;
    };

    let manifest = home.join(".gemini/extensions/worktrunk/gemini-extension.json");
    let Ok(content) = std::fs::read_to_string(&manifest) else {
        return false;
    };

    let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
        return false;
    };

    json.get("name").and_then(|n| n.as_str()) == Some("worktrunk")
}

/// Render GEMINI CLI section (extension status).
/// Caller must check `is_gemini_available()` first.
fn render_gemini_status(out: &mut String) -> anyhow::Result<()> {
    writeln!(out, "{}", format_heading("GEMINI CLI", None))?;

    if is_gemini_extension_installed() {
        writeln!(out, "{}", success_message("Extension installed"))?;
    } else {
        writeln!(
            out,
            "{}",
            hint_message(cformat!(
                "Extension not installed. To install, run <underline>gemini extensions install https://github.com/max-sixty/worktrunk</>"
            ))
        )?;
    }

    Ok(())
}

/// Render OTHER section (version, hyperlinks)
fn render_runtime_info(out: &mut String) -> anyhow::Result<()> {
    let cmd = crate::binary_name();
    let version = version_str();

    writeln!(out, "{}", format_heading("OTHER", None))?;

    // Version info
    writeln!(
        out,
        "{}",
        info_message(cformat!("{cmd}: <bold>{version}</>"))
    )?;
    if let Some(git_version) = git_version() {
        writeln!(
            out,
            "{}",
            info_message(cformat!("git: <bold>{git_version}</>"))
        )?;
    }

    // Show hyperlink support status
    let hyperlinks_supported =
        worktrunk::styling::supports_hyperlinks(worktrunk::styling::Stream::Stderr);
    let status = if hyperlinks_supported {
        "active"
    } else {
        "inactive"
    };
    writeln!(
        out,
        "{}",
        info_message(cformat!("Hyperlinks: <bold>{status}</>"))
    )?;

    Ok(())
}

/// Run full diagnostic checks (CI tools, commit generation) and render to buffer
fn render_diagnostics(out: &mut String) -> anyhow::Result<()> {
    writeln!(out, "{}", format_heading("DIAGNOSTICS", None))?;

    // Check the CI tool for this repo's platform (project config, else remote URL).
    let repo = Repository::current()?;
    match repo.ci_platform(None) {
        Some(CiPlatform::GitHub) => {
            let ci_tools = CiToolsStatus::detect(None);
            render_ci_tool_status(
                out,
                "gh",
                "GitHub",
                ci_tools.gh_installed,
                ci_tools.gh_authenticated,
            )?;
        }
        Some(CiPlatform::GitLab) => {
            let ci_tools = CiToolsStatus::detect(None);
            render_ci_tool_status(
                out,
                "glab",
                "GitLab",
                ci_tools.glab_installed,
                ci_tools.glab_authenticated,
            )?;
        }
        Some(CiPlatform::Gitea) => {
            let ci_tools = CiToolsStatus::detect(None);
            render_ci_tool_status(
                out,
                "tea",
                "Gitea",
                ci_tools.tea_installed,
                ci_tools.tea_authenticated,
            )?;
        }
        Some(CiPlatform::AzureDevOps) => {
            let ci_tools = CiToolsStatus::detect(None);
            render_ci_tool_status(
                out,
                "az",
                "Azure DevOps",
                ci_tools.az_installed,
                ci_tools.az_authenticated,
            )?;
        }
        None => {
            writeln!(
                out,
                "{}",
                hint_message("CI status requires GitHub, GitLab, Gitea, or Azure DevOps remote")
            )?;
        }
    }

    // Check for newer version on GitHub
    render_version_check(out)?;

    // Test commit generation - use effective config for current project
    let config = UserConfig::load()?;
    let project_id = repo.project_identifier().ok();
    let commit_config = config.commit_generation(project_id.as_deref());

    if !commit_config.is_configured() {
        writeln!(out, "{}", hint_message("Commit generation not configured"))?;
    } else {
        // `is_configured()` guarantees `command` is `Some` and non-empty here;
        // `unwrap_or_default()` avoids a panic-prone `unwrap()` in this
        // `Result`-returning function (the default is unreachable).
        let command_display = commit_config.command.clone().unwrap_or_default();

        match test_commit_generation(&commit_config) {
            Ok(message) => {
                writeln!(
                    out,
                    "{}",
                    success_message(cformat!(
                        "Commit generation working (<bold>{command_display}</>)"
                    ))
                )?;
                writeln!(out, "{}", format_with_gutter(&message, None))?;
            }
            Err(e) => {
                writeln!(
                    out,
                    "{}",
                    error_message(cformat!(
                        "Commit generation failed (<bold>{command_display}</>)"
                    ))
                )?;
                // Use the typed diagnostic block (with hint, gutter, etc.)
                // when present; otherwise fall back to the short Display label.
                let body = e.render_diagnostic().unwrap_or_else(|| e.to_string());
                writeln!(out, "{}", format_with_gutter(&body, None))?;
            }
        }
    }

    Ok(())
}

/// Render the SYSTEM CONFIG section. Returns true if a system config file was found.
fn render_system_config(out: &mut String) -> anyhow::Result<bool> {
    let Some(system_path) = system_config_path() else {
        return Ok(false);
    };

    writeln!(
        out,
        "{}",
        format_heading(
            "SYSTEM CONFIG",
            Some(&format!("@ {}", format_path_for_display(&system_path)))
        )
    )?;

    // Read and display the file contents
    let contents =
        std::fs::read_to_string(&system_path).context("Failed to read system config file")?;

    if contents.trim().is_empty() {
        writeln!(out, "{}", hint_message("Empty file (no system defaults)"))?;
        return Ok(true);
    }

    // Validate config (syntax + schema) and warn if invalid
    if let Err(e) = toml::from_str::<UserConfig>(&contents) {
        writeln!(out, "{}", error_message("Invalid config"))?;
        writeln!(out, "{}", format_with_gutter(&e.to_string(), None))?;
    } else {
        out.push_str(&warn_unknown_keys::<UserConfig>(&contents));
    }

    // Display TOML with syntax highlighting
    writeln!(out, "{}", format_toml(&contents))?;

    Ok(true)
}

fn render_user_config(out: &mut String, has_system_config: bool) -> anyhow::Result<()> {
    let config_path = require_config_path()?;

    writeln!(
        out,
        "{}",
        format_heading(
            "USER CONFIG",
            Some(&format!("@ {}", format_path_for_display(&config_path)))
        )
    )?;

    // Check if file exists
    if !config_path.exists() {
        writeln!(
            out,
            "{}",
            hint_message(cformat!(
                "Not found; to create one, run <underline>wt config create</>"
            ))
        )?;
        return Ok(());
    }

    // Read and display the file contents
    let contents = std::fs::read_to_string(&config_path).context("Failed to read config file")?;

    // Check for deprecations with emit_inline_warnings=false (silent mode)
    // User config is global, not tied to any repository
    // Deprecated patterns supersede the TOML dump below (their diff covers
    // the file); a pending-default pin is additive, so the dump stays. An
    // empty file still gets the pending-pin details — `wt config update`
    // would rewrite it — just no dump.
    let mut details_shown = false;
    let skip_dump = match worktrunk::config::check_and_migrate(
        &config_path,
        &contents,
        true,
        worktrunk::config::ConfigFileKind::User,
        None,
        false, // silent mode - we'll format the output ourselves
    ) {
        Ok(result) => {
            if let Some(info) = result.info {
                out.push_str(&worktrunk::config::format_deprecation_details(
                    &info, &contents,
                ));
                details_shown = true;
                info.has_deprecated_patterns()
            } else {
                false
            }
        }
        Err(err) => {
            writeln!(out, "{}", error_message(err.to_string()))?;
            false
        }
    };

    if contents.trim().is_empty() {
        writeln!(out, "{}", hint_message("Empty file (using defaults)"))?;
        return Ok(());
    }

    // Validate config (syntax + schema) and warn if invalid
    if let Err(e) = toml::from_str::<UserConfig>(&contents) {
        // Use gutter for error details to avoid markup interpretation of user content
        writeln!(out, "{}", error_message("Invalid config"))?;
        writeln!(out, "{}", format_with_gutter(&e.to_string(), None))?;
    } else {
        out.push_str(&warn_unknown_keys::<UserConfig>(&contents));
    }

    // Display TOML with syntax highlighting (gutter at column 0).
    // Skip when deprecations were shown — the proposed diff already covers it.
    if !skip_dump {
        if details_shown {
            // Pending-pin details above end in their diff; separate phases.
            out.push('\n');
        }
        writeln!(out, "{}", format_toml(&contents))?;
    }

    if !has_system_config {
        render_system_config_hint(out)?;
    }

    Ok(())
}

fn render_system_config_hint(out: &mut String) -> anyhow::Result<()> {
    if let Some(path) = default_system_config_path() {
        writeln!(
            out,
            "{}",
            hint_message(cformat!(
                "Optional system config not found @ <dim>{}</>",
                format_path_for_display(&path)
            ))
        )?;
    }
    Ok(())
}

/// Format warnings for unknown config keys in `raw_contents`.
///
/// Generic over `C`, the config type. Classification is shared with the
/// load-time warning path via
/// [`collect_unknown_warnings`](worktrunk::config::collect_unknown_warnings);
/// only the message wording differs.
pub(super) fn warn_unknown_keys<C: worktrunk::config::WorktrunkConfig>(
    raw_contents: &str,
) -> String {
    let mut out = String::new();
    for warning in worktrunk::config::collect_unknown_warnings::<C>(raw_contents) {
        let _ = writeln!(out, "{}", warning_message(format_show_warning(&warning)));
    }
    out
}

fn format_show_warning(warning: &worktrunk::config::UnknownWarning) -> String {
    use worktrunk::config::UnknownWarning;
    match warning {
        UnknownWarning::TopLevelUnknown { key } => {
            cformat!("Unknown key <bold>{key}</> will be ignored")
        }
        UnknownWarning::TopLevelWrongConfig {
            key,
            other_description,
        } => worktrunk::config::with_scope_note(
            cformat!("Key <bold>{key}</> belongs in {other_description} (will be ignored)"),
            other_description,
            key,
        ),
        UnknownWarning::TopLevelDeprecatedWrongConfig {
            key,
            other_description,
            canonical_display,
        } => cformat!("Key <bold>{key}</> belongs in {other_description} as {canonical_display}"),
        UnknownWarning::NestedWrongConfig {
            path,
            other_description,
        } => worktrunk::config::with_scope_note(
            cformat!("Key <bold>{path}</> belongs in {other_description} (will be ignored)"),
            other_description,
            path,
        ),
        UnknownWarning::NestedUnknown { path } => {
            cformat!("Unknown key <bold>{path}</> will be ignored")
        }
    }
}

fn render_project_config(out: &mut String) -> anyhow::Result<()> {
    // Try to get current repository root
    let repo = match Repository::current() {
        Ok(repo) => repo,
        Err(_) => {
            writeln!(
                out,
                "{}",
                cformat!(
                    "<dim>{}</>",
                    format_heading("PROJECT CONFIG", Some("Not in a git repository"))
                )
            )?;
            return Ok(());
        }
    };
    // Helper: heading + project identifier, shared across the resolved and
    // absent branches so their output stays uniform.
    fn write_heading_and_identifier(
        out: &mut String,
        repo: &Repository,
        source: &str,
    ) -> anyhow::Result<()> {
        writeln!(out, "{}", format_heading("PROJECT CONFIG", Some(source)))?;
        // Project identifier — used as the key for [projects."..."] sections in
        // user config. Surface it here so users can find the right key without
        // hand-deriving it from the remote URL.
        if let Ok(project_id) = repo.project_identifier() {
            let line = info_message(cformat!("Identifier: <bold>{project_id}</>"));
            writeln!(out, "{line}")?;
        }
        Ok(())
    }

    // Resolve the effective config source, mirroring `ProjectConfig::load`: an
    // on-disk `.config/wt.toml` when one exists, otherwise the committed
    // default-branch config read from the object store (bare repo, default
    // branch checked out in no worktree — #3461). Reading the raw text here
    // rather than calling `load_project_config` keeps the deprecation and
    // validation rendering below, which operates on the TOML source. Without
    // this fallback, `config show` reports "Not found" while the hooks from the
    // object-store config actually run — the opposite of reality.
    let on_disk = repo.project_config_path()?;
    let (config_path, contents) = match &on_disk {
        Some(path) if path.exists() => {
            let contents = std::fs::read_to_string(path).context("Failed to read config file")?;
            let source = format!("@ {}", format_path_for_display(path));
            write_heading_and_identifier(out, &repo, &source)?;
            (path.clone(), contents)
        }
        _ => match repo.default_branch_project_config_content() {
            Some((object_store_contents, spec)) => {
                // `spec` is a git revision spec (`<default>:.config/wt.toml`),
                // not a filesystem path — display it verbatim, tagged as the
                // object-store source so it isn't mistaken for an on-disk file.
                let source = format!("@ {} (from object store)", spec.to_string_lossy());
                write_heading_and_identifier(out, &repo, &source)?;
                (spec, object_store_contents)
            }
            None => {
                // Neither an on-disk file nor a committed fallback resolved.
                let Some(path) = on_disk else {
                    let heading = format_heading("PROJECT CONFIG", Some("No project config"));
                    writeln!(out, "{}", cformat!("<dim>{}</>", heading))?;
                    return Ok(());
                };
                let source = format!("@ {}", format_path_for_display(&path));
                write_heading_and_identifier(out, &repo, &source)?;
                writeln!(out, "{}", hint_message("Not found"))?;
                return Ok(());
            }
        },
    };

    if contents.trim().is_empty() {
        writeln!(out, "{}", hint_message("Empty file"))?;
        return Ok(());
    }

    // Check for deprecations with emit_inline_warnings=false (silent mode)
    // Only write migration file in main worktree, not linked worktrees.
    // Deprecated patterns supersede the TOML dump below (their diff covers
    // the file); a pending-default pin would be additive, so the dump stays —
    // no pending-default rule targets project config today, but the shape
    // mirrors render_user_config so the two stay interchangeable.
    let is_main_worktree = !repo.current_worktree().is_linked().unwrap_or(true);
    let mut details_shown = false;
    let skip_dump = match worktrunk::config::check_and_migrate(
        &config_path,
        &contents,
        is_main_worktree,
        worktrunk::config::ConfigFileKind::Project,
        Some(&repo),
        false, // silent mode - we'll format the output ourselves
    ) {
        Ok(result) => {
            if let Some(info) = result.info {
                out.push_str(&worktrunk::config::format_deprecation_details(
                    &info, &contents,
                ));
                details_shown = true;
                info.has_deprecated_patterns()
            } else {
                false
            }
        }
        Err(err) => {
            writeln!(out, "{}", error_message(err.to_string()))?;
            false
        }
    };

    // Validate config (syntax + schema) and warn if invalid
    if let Err(e) = toml::from_str::<ProjectConfig>(&contents) {
        // Use gutter for error details to avoid markup interpretation of user content
        writeln!(out, "{}", error_message("Invalid config"))?;
        writeln!(out, "{}", format_with_gutter(&e.to_string(), None))?;
    } else {
        out.push_str(&warn_unknown_keys::<ProjectConfig>(&contents));
    }

    // Display TOML with syntax highlighting (gutter at column 0).
    // Skip when deprecations were shown — the proposed diff already covers it.
    if !skip_dump {
        if details_shown {
            // Pending-pin details above end in their diff; separate phases.
            out.push('\n');
        }
        writeln!(out, "{}", format_toml(&contents))?;
    }

    Ok(())
}

/// Emit the "fish integration found in deprecated location" notice plus the
/// hint pointing at the canonical path. Used wherever fish integration lives
/// at the legacy `~/.config/fish/conf.d/` location (deprecated since #566).
fn render_fish_legacy_migration(
    out: &mut String,
    legacy_fish_conf_d: Option<&Path>,
    cmd: &str,
) -> anyhow::Result<()> {
    let legacy_path = legacy_fish_conf_d
        .map(format_path_for_display)
        .unwrap_or_default();
    let canonical_path = Shell::Fish
        .config_paths(cmd)
        .ok()
        .and_then(|p| p.into_iter().next())
        .map(|p| format_path_for_display(&p))
        .unwrap_or_else(|| "~/.config/fish/functions/".to_string());
    writeln!(
        out,
        "{}",
        info_message(cformat!(
            "Fish integration found in deprecated location @ <bold>{legacy_path}</>"
        ))
    )?;
    writeln!(
        out,
        "{}",
        hint_message(cformat!(
            "To migrate to <underline>{canonical_path}</>, run <underline>{cmd} config shell install fish</>"
        ))
    )?;
    Ok(())
}

/// Zsh-only: warn when compinit isn't enabled, since the integration
/// installs completions but they won't load without compinit.
fn render_zsh_compinit_warning(out: &mut String) -> anyhow::Result<()> {
    if !check_zsh_compinit_missing() {
        return Ok(());
    }
    writeln!(
        out,
        "{}",
        warning_message("Completions won't work; add to ~/.zshrc before the wt line:")
    )?;
    writeln!(
        out,
        "{}",
        format_with_gutter("autoload -Uz compinit && compinit", None)
    )?;
    Ok(())
}

/// Fish-only: report whether the separate completions file is in place.
/// Doesn't flip `any_not_configured` — missing fish completions have a
/// shell-specific remediation hint rather than the generic "To configure"
/// summary.
fn render_fish_completion_status(out: &mut String, cmd: &str) -> anyhow::Result<()> {
    let Ok(completion_path) = Shell::Fish.completion_path(cmd) else {
        return Ok(());
    };
    let completion_display = format_path_for_display(&completion_path);
    let shell = Shell::Fish;
    if completion_path.exists() {
        writeln!(
            out,
            "{}",
            info_message(cformat!(
                "<bold>{shell}</>: Already configured completions @ {completion_display}"
            ))
        )?;
    } else {
        let warning = warning_message(cformat!(
            "<bold>{shell}</>: Completions not configured @ <bold>{completion_display}</>"
        ));
        let hint = hint_message(cformat!(
            "To configure completions, run <underline>{cmd} config shell install {shell}</>"
        ));
        writeln!(out, "{warning}\n{hint}")?;
    }
    Ok(())
}

/// When the integration is configured but the wrapper isn't loaded in the
/// running shell, suggest a verify command. Only fires for the user's
/// current shell — `type wt` (or `Get-Command wt`) probes the running
/// shell, so a hint targeted at a different shell would mislead.
fn render_verify_wrapper_hint(
    out: &mut String,
    shell: Shell,
    cmd: &str,
    shell_active: bool,
) -> anyhow::Result<()> {
    if shell_active || Some(shell) != worktrunk::shell::current_shell() {
        return Ok(());
    }
    let verify_cmd = match shell {
        Shell::PowerShell => format!("Get-Command {cmd}"),
        _ => format!("type {cmd}"),
    };
    let hint = hint_message(cformat!(
        "To verify wrapper loaded: <underline>{verify_cmd}</>"
    ));
    writeln!(out, "{hint}")?;
    Ok(())
}

/// Render the `AlreadyExists` row plus any per-shell follow-ups (matched
/// lines, .exe warning, zsh compinit, fish completions, verify hint).
fn render_already_configured(
    out: &mut String,
    result: &ConfigureResult,
    detection_results: &[FileDetectionResult],
    cmd: &str,
    shell_active: bool,
) -> anyhow::Result<()> {
    let shell = result.shell;
    let path = format_path_for_display(&result.path);
    let what = crate::output::shell_integration::shell_extension_label(shell);

    let detection = detection_results
        .iter()
        .find(|d| d.path == result.path && !d.matched_lines.is_empty());

    // Build file:line location (clickable in terminals - use first line only)
    let location = match detection.and_then(|d| d.matched_lines.first()) {
        Some(first_line) => format!("{}:{}", path, first_line.line_number),
        None => path.to_string(),
    };

    writeln!(
        out,
        "{}",
        info_message(cformat!(
            "<bold>{shell}</>: Already configured {what} @ {location}"
        ))
    )?;

    if let Some(det) = detection {
        for detected in &det.matched_lines {
            writeln!(out, "{}", format_bash_with_gutter(detected.content.trim()))?;
        }
        // Warn when the matched lines use .exe — installs the function as
        // wt.exe, but aliases still need to point at wt.
        if det.matched_lines.iter().any(|m| m.content.contains(".exe")) {
            writeln!(
                out,
                "{}",
                hint_message(cformat!(
                    "Creates shell function <bold>{cmd}</>. Aliases should use <underline>{cmd}</>, not <underline>{cmd}.exe</>"
                ))
            )?;
        }
    }

    match shell {
        Shell::Zsh => render_zsh_compinit_warning(out)?,
        Shell::Fish => render_fish_completion_status(out, cmd)?,
        _ => {}
    }

    render_verify_wrapper_hint(out, shell, cmd, shell_active)?;
    Ok(())
}

/// Render the `WouldAdd`/`WouldCreate` arm. Returns whether the row
/// should count toward `any_not_configured` (the trailing "To configure"
/// summary is suppressed for outdated wrappers and dotfile-managed setups).
fn render_would_add_or_create(
    out: &mut String,
    result: &ConfigureResult,
    legacy_fish_conf_d: Option<&Path>,
    legacy_fish_has_integration: bool,
    cmd: &str,
    shell_active: bool,
) -> anyhow::Result<bool> {
    let shell = result.shell;
    let path = format_path_for_display(&result.path);
    let what = crate::output::shell_integration::shell_extension_label(shell);

    // Fish: prefer migration hint when the legacy conf.d location has
    // working integration — silencing the "Not configured" row.
    if matches!(shell, Shell::Fish) && legacy_fish_has_integration {
        render_fish_legacy_migration(out, legacy_fish_conf_d, cmd)?;
        return Ok(false);
    }

    // Wrapper-based shells with WouldAdd: file exists but content drifted
    // (e.g. outdated wrapper). The per-shell "To update" hint covers it,
    // so the generic "To configure" summary stays silent.
    if shell.is_wrapper_based() && matches!(result.action, ConfigAction::WouldAdd) {
        let warning = warning_message(cformat!(
            "<bold>{shell}</>: Outdated shell extension @ <bold>{path}</>"
        ));
        let hint = hint_message(cformat!(
            "To update, run <underline>{cmd} config shell install {shell}</>"
        ));
        writeln!(out, "{warning}\n{hint}")?;
        return Ok(false);
    }

    // Integration is loaded at runtime even though no rc file matched —
    // common with dotfile managers (stow/chezmoi) that source from
    // unknown locations.
    if shell_active && Some(shell) == worktrunk::shell::current_shell() {
        writeln!(
            out,
            "{}",
            info_message(cformat!(
                "<bold>{shell}</>: Configured {what} (not found in {path})"
            ))
        )?;
        return Ok(false);
    }

    writeln!(
        out,
        "{}",
        info_message(cformat!("<bold>{shell}</>: Not configured {what}"))
    )?;
    Ok(true)
}

fn render_shell_status(out: &mut String) -> anyhow::Result<()> {
    writeln!(out, "{}", format_heading("SHELL INTEGRATION", None))?;

    // Use the same detection logic as `wt config shell install`. Hoisted above the
    // active/inactive warning so the warning text can distinguish "installed but
    // not loaded" (▲ not active) from "never installed" (▲ not configured).
    let cmd = crate::binary_name();
    let scan_result = match scan_shell_configs(None, true, &cmd) {
        Ok(r) => r,
        Err(e) => {
            writeln!(
                out,
                "{}",
                hint_message(format!("Could not determine shell status: {e}"))
            )?;
            return Ok(());
        }
    };

    // Get detection details to show matched lines inline
    let detection_results = scan_for_detection_details(&cmd).unwrap_or_default();

    // Check for legacy fish conf.d path (deprecated location from before #566)
    // We need this early to handle the case where fish shows "Not configured" at the
    // new location but has valid integration at the legacy location.
    let legacy_fish_conf_d = Shell::legacy_fish_conf_d_path(&cmd).ok();
    let legacy_fish_has_integration = legacy_fish_conf_d.as_ref().is_some_and(|legacy_path| {
        detection_results
            .iter()
            .any(|d| d.path == *legacy_path && !d.matched_lines.is_empty())
    });

    // "Configured somewhere" means any rc file already has the init line, any
    // wrapper-based shell has its wrapper file (WouldAdd on a wrapper means
    // the file exists but content differs — i.e. outdated, still installed),
    // or fish has integration at the legacy conf.d path.
    let any_configured_somewhere = scan_result.configured.iter().any(|r| {
        matches!(r.action, ConfigAction::AlreadyExists)
            || (r.shell.is_wrapper_based() && matches!(r.action, ConfigAction::WouldAdd))
    }) || legacy_fish_has_integration;

    // Shell integration runtime status (moved from RUNTIME section)
    let shell_active = output::is_shell_integration_active();
    // When the user has no working integration anywhere, the install hint goes
    // directly under the warning so cause and remedy stay together. The trailing
    // hint at the bottom of the section is suppressed in this case.
    let hint_under_warning = !shell_active && !any_configured_somewhere;
    if shell_active {
        writeln!(out, "{}", info_message("Shell integration active"))?;
    } else {
        let warning_text = if any_configured_somewhere {
            "Shell integration not active"
        } else {
            "Shell integration not configured"
        };
        writeln!(out, "{}", warning_message(warning_text))?;
        if hint_under_warning {
            let hint = hint_message(cformat!(
                "To configure, run <underline>{cmd} config shell install</>"
            ));
            writeln!(out, "{hint}")?;
        }
        // Show invocation details to help diagnose
        let invocation = crate::invocation_path();
        let is_git_subcommand = crate::is_git_subcommand();
        let mut debug_lines = vec![cformat!("Invoked as: <bold>{invocation}</>")];

        // Show actual binary path if different from invocation (helps detect wrong wt in PATH)
        if let Ok(exe_path) = std::env::current_exe() {
            let exe_display = format_path_for_display(&exe_path);
            // Only show if meaningfully different (not just ./ prefix differences)
            let invocation_canonical = std::fs::canonicalize(&invocation).ok();
            let exe_canonical = std::fs::canonicalize(&exe_path).ok();
            if invocation_canonical != exe_canonical {
                debug_lines.push(cformat!("Running from: <bold>{exe_display}</>"));
            }
        }

        // Show the shell actually running wt — the process tree names the
        // interactive shell even when $SHELL points at a different login shell
        let ancestor = worktrunk::shell::ancestor_shell();
        if let Some(ancestor) = ancestor {
            debug_lines.push(cformat!(
                "Detected shell: <bold>{}</> (process tree)",
                ancestor.name
            ));
        }
        // Show $SHELL to help diagnose rc file sourcing issues
        let shell_env = std::env::var("SHELL").ok().filter(|s| !s.is_empty());
        if let Some(shell_env) = &shell_env {
            debug_lines.push(cformat!("$SHELL: <bold>{shell_env}</>"));
        } else if ancestor.is_none()
            && let Some(detected) = worktrunk::shell::current_shell()
        {
            debug_lines.push(cformat!(
                "Detected shell: <bold>{detected}</> (via PSModulePath)"
            ));
        }

        if is_git_subcommand {
            debug_lines.push("Git subcommand: yes (GIT_EXEC_PATH set)".to_string());
        }
        writeln!(out, "{}", format_with_gutter(&debug_lines.join("\n"), None))?;
    }

    // Blank line separates the active/not-active status from the per-shell list.
    // Only emit when there's a list to render — otherwise the trailing hint
    // ("To configure, run …") would float behind a stray blank, breaking the
    // hint-attaches-to-subject formatting rule.
    if !scan_result.configured.is_empty() || !scan_result.skipped.is_empty() {
        writeln!(out)?;
    }

    let mut any_not_configured = false;
    let mut has_any_unmatched = false;

    // Show configured and not-configured shells (matching `config shell install` format exactly)
    // Fish ships completions as a separate file: "shell extension" for functions/ and "completions" for completions/
    // Every other supported shell wires completions inline with the extension, so they show "shell extension & completions"
    for result in &scan_result.configured {
        match result.action {
            ConfigAction::AlreadyExists => {
                render_already_configured(out, result, &detection_results, &cmd, shell_active)?;
            }
            ConfigAction::WouldAdd | ConfigAction::WouldCreate
                if render_would_add_or_create(
                    out,
                    result,
                    legacy_fish_conf_d.as_deref(),
                    legacy_fish_has_integration,
                    &cmd,
                    shell_active,
                )? =>
            {
                any_not_configured = true;
            }
            _ => {} // Added/Created won't appear in dry_run mode
        }
    }

    // Show skipped (not installed) shells
    // For fish with legacy integration, show migration hint instead of "skipped"
    for (shell, path) in &scan_result.skipped {
        if matches!(shell, Shell::Fish) && legacy_fish_has_integration {
            // Show migration hint for legacy fish location
            render_fish_legacy_migration(out, legacy_fish_conf_d.as_deref(), &cmd)?;
            continue;
        }
        let path = format_path_for_display(path);
        writeln!(
            out,
            "{}",
            info_message(cformat!(
                "<bold>{shell}</>: <dim>Skipped; {path} not found</>"
            ))
        )?;
    }

    // Summary hint pointing at `wt config shell install`. The fresh-user and
    // nothing-configured-anywhere cases are already covered by the hint emitted
    // directly under the warning, so this trailing emit is suppressed when
    // `hint_under_warning` fired. It still fires for the partial-config case
    // (some shells configured, some not) and for fish-legacy (working
    // integration via the legacy path, other shells unconfigured).
    let nothing_configured_yet = !shell_active && scan_result.configured.is_empty();
    if !hint_under_warning && (any_not_configured || nothing_configured_yet) {
        writeln!(
            out,
            "{}",
            hint_message(cformat!(
                "To configure, run <underline>{cmd} config shell install</>"
            ))
        )?;
    }

    // Show potential false negatives (lines containing cmd but not detected)
    // Skip files that have valid integration detected (matched_lines) - those are fine,
    // and the other lines containing cmd are just part of the integration script.
    // Also skip files already confirmed as integration by scan_shell_configs (e.g., Nushell/Fish
    // wrapper files that ARE the integration, not config files that source it).
    let confirmed_paths: HashSet<&Path> = scan_result
        .configured
        .iter()
        .filter(|r| {
            // For wrapper-based shells, the file at the path IS the integration — any
            // action means it was recognized. For eval-based shells, only AlreadyExists
            // means the config line was found.
            r.shell.is_wrapper_based() || matches!(r.action, ConfigAction::AlreadyExists)
        })
        .map(|r| r.path.as_path())
        .collect();
    for detection in &detection_results {
        if !detection.unmatched_candidates.is_empty()
            && detection.matched_lines.is_empty()
            && !confirmed_paths.contains(detection.path.as_path())
        {
            has_any_unmatched = true;
            let path = format_path_for_display(&detection.path);

            // Build file:line location (clickable in terminals - use first line only)
            let location = if let Some(first) = detection.unmatched_candidates.first() {
                format!("{}:{}", path, first.line_number)
            } else {
                path.to_string()
            };
            writeln!(
                out,
                "{}",
                warning_message(cformat!(
                    "Found <bold>{cmd}</> in <bold>{location}</> but not detected as integration:"
                ))
            )?;
            for detected in &detection.unmatched_candidates {
                writeln!(out, "{}", format_bash_with_gutter(detected.content.trim()))?;
            }

            // If any unmatched lines contain .exe, explain the function name issue
            let uses_exe = detection
                .unmatched_candidates
                .iter()
                .any(|m| m.content.contains(".exe"));
            if uses_exe {
                writeln!(
                    out,
                    "{}",
                    hint_message(cformat!(
                        "Note: <bold>{cmd}.exe</> creates shell function <bold>{cmd}</>. \
                         Aliases should use <underline>{cmd}</>, not <underline>{cmd}.exe</>"
                    ))
                )?;
            }
        }
    }

    // Show aliases that bypass shell integration (Issue #348)
    for detection in &detection_results {
        for alias in &detection.bypass_aliases {
            let path = format_path_for_display(&detection.path);
            let location = format!("{}:{}", path, alias.line_number);
            writeln!(
                out,
                "{}",
                warning_message(cformat!(
                    "Alias <bold>{}</> bypasses shell integration — won't auto-cd",
                    alias.alias_name
                ))
            )?;
            writeln!(out, "{}", format_bash_with_gutter(alias.content.trim()))?;
            writeln!(
                out,
                "{}",
                hint_message(cformat!(
                    "Change to <underline>alias {}=\"{cmd}\"</> @ {location}",
                    alias.alias_name
                ))
            )?;
        }
    }

    // Whenever there are unmatched candidates, offer the false-negative report link.
    // A real detector miss in one config file is worth reporting even if another shell
    // is correctly detected — the `confirmed_paths` filter already excludes wrapper files,
    // and the narrow phrasing ("meant to load") steers non-integration lines (plain aliases)
    // away from reporting themselves as bugs.
    if has_any_unmatched {
        let unmatched_summary: Vec<_> = detection_results
            .iter()
            .filter(|r| {
                !r.unmatched_candidates.is_empty()
                    && r.matched_lines.is_empty()
                    && !confirmed_paths.contains(r.path.as_path())
            })
            .flat_map(|r| {
                r.unmatched_candidates
                    .iter()
                    .map(|d| d.content.trim().to_string())
            })
            .collect();
        let body = format!(
            "Shell integration not detected despite config containing `{cmd}`.\n\n\
             **Unmatched lines:**\n```\n{}\n```\n\n\
             **Expected behavior:** These lines should be detected as shell integration.",
            unmatched_summary.join("\n")
        );
        let issue_url = format!(
            "https://github.com/max-sixty/worktrunk/issues/new?title={}&body={}",
            urlencoding::encode("Shell integration detection false negative"),
            urlencoding::encode(&body)
        );

        // Quote a short version of the unmatched content in the hint
        let quoted = if unmatched_summary.len() == 1 {
            format!("`{}`", unmatched_summary[0])
        } else {
            format!(
                "`{}` (and {} more)",
                unmatched_summary[0],
                unmatched_summary.len() - 1
            )
        };
        writeln!(
            out,
            "{}",
            hint_message(format!(
                "If {quoted} is meant to load Worktrunk shell integration, report a false negative: {issue_url}"
            ))
        )?;
    }

    Ok(())
}

pub(super) fn render_ci_tool_status(
    out: &mut String,
    tool: &str,
    platform: &str,
    installed: bool,
    authenticated: bool,
) -> anyhow::Result<()> {
    if installed {
        if authenticated {
            writeln!(
                out,
                "{}",
                success_message(cformat!("<bold>{tool}</> installed & authenticated"))
            )?;
        } else {
            // The auth-setup command differs by CLI: `gh`/`glab` use
            // `<tool> auth login`, `az` uses `az login`, `tea` uses `tea login add`.
            let auth_command = match tool {
                "az" => format!("{tool} login"),
                "tea" => format!("{tool} login add"),
                _ => format!("{tool} auth login"),
            };
            writeln!(
                out,
                "{}",
                warning_message(cformat!(
                    "<bold>{tool}</> installed but not authenticated; run <bold>{auth_command}</>"
                ))
            )?;
        }
    } else {
        writeln!(
            out,
            "{}",
            hint_message(cformat!(
                "<bold>{tool}</> not found ({platform} CI status unavailable)"
            ))
        )?;
    }
    Ok(())
}

/// Format the version-check line given the latest release version.
///
/// Pure over `latest` so both arms are unit-testable without injecting a
/// version through the environment; `render_version_check` supplies the value
/// from `fetch_latest_version`.
fn format_version_status(latest: &str) -> FormattedMessage {
    let current = crate::cli::version_str();
    if is_newer_version(latest, env!("CARGO_PKG_VERSION")) {
        info_message(cformat!(
            "Update available: <bold>{latest}</> (current: {current})"
        ))
    } else {
        info_message(cformat!("Up to date (<bold>{current}</>)"))
    }
}

/// Render version update check (fetches from GitHub)
fn render_version_check(out: &mut String) -> anyhow::Result<()> {
    match fetch_latest_version() {
        Ok(latest) => writeln!(out, "{}", format_version_status(&latest))?,
        Err(e) => {
            tracing::debug!(error = %e, "Version check failed: {e}");
            writeln!(out, "{}", hint_message("Version check unavailable"))?;
        }
    }
    Ok(())
}

/// Fetch the latest release version from GitHub
fn fetch_latest_version() -> anyhow::Result<String> {
    // Held for the whole lookup; the sub-startup-delay paths (test injection,
    // fast failures, response parsing) return before it ever renders.
    let _watchdog = worktrunk::progress::Watchdog::start("the version check", None);

    // Allow tests to inject a version without network access.
    // Set to "error" to simulate a fetch failure.
    if let Ok(version) = std::env::var("WORKTRUNK_TEST_LATEST_VERSION") {
        if version == "error" {
            anyhow::bail!("simulated fetch failure");
        }
        return Ok(version);
    }

    let user_agent = format!(
        "worktrunk/{} (https://worktrunk.dev)",
        env!("CARGO_PKG_VERSION")
    );
    // The watchdog (above) supplies "still waiting" feedback, so the fetch needn't
    // be cut off at an aggressive 5s. But the watchdog is TTY-gated — a
    // non-interactive run (CI, scripts, redirected stderr) gets no feedback — so a
    // hard ceiling still has to exist or such a run could hang silently:
    // --connect-timeout fails fast when offline, and a generous --max-time bounds a
    // connected-but-stalled host without cutting off a slow-but-progressing fetch.
    let output = {
        Cmd::new("curl")
            .args([
                "--silent",
                "--fail",
                "--connect-timeout",
                "10",
                "--max-time",
                "60",
                "--header",
                &format!("User-Agent: {user_agent}"),
                "https://api.github.com/repos/max-sixty/worktrunk/releases/latest",
            ])
            .run()?
    };

    if !output.status.success() {
        anyhow::bail!("GitHub API request failed");
    }

    let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
    let tag = json["tag_name"]
        .as_str()
        .ok_or_else(|| anyhow::anyhow!("missing tag_name in response"))?;

    // Strip leading 'v' prefix (e.g., "v0.23.2" -> "0.23.2")
    Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
}

/// Compare two semver version strings (e.g., "0.24.0" > "0.23.2")
fn is_newer_version(latest: &str, current: &str) -> bool {
    let parse = |s: &str| -> Option<(u32, u32, u32)> {
        let mut parts = s.splitn(3, '.');
        Some((
            parts.next()?.parse().ok()?,
            parts.next()?.parse().ok()?,
            parts.next()?.parse().ok()?,
        ))
    };
    match (parse(latest), parse(current)) {
        (Some(l), Some(c)) => l > c,
        _ => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_get_git_version_returns_version() {
        // In a normal environment with git installed, should return a version
        let version = git_version();
        assert!(version.is_some());
        let version = version.unwrap();
        // Version should look like a semver (e.g., "2.47.1")
        assert!(version.chars().next().unwrap().is_ascii_digit());
        assert!(version.contains('.'));
    }

    #[test]
    fn test_is_newer_version() {
        // Newer versions
        assert!(is_newer_version("0.24.0", "0.23.2"));
        assert!(is_newer_version("1.0.0", "0.99.99"));
        assert!(is_newer_version("0.23.3", "0.23.2"));
        assert!(is_newer_version("0.23.2", "0.23.1"));

        // Same version
        assert!(!is_newer_version("0.23.2", "0.23.2"));

        // Older versions
        assert!(!is_newer_version("0.23.1", "0.23.2"));
        assert!(!is_newer_version("0.22.0", "0.23.2"));

        // Invalid input
        assert!(!is_newer_version("invalid", "0.23.2"));
        assert!(!is_newer_version("0.23.2", "invalid"));
    }

    /// Regression guard for #3322: the interactive zsh compinit probe must
    /// disable job control (`+m`). Without it, the probe grabs wt's controlling
    /// terminal; a timeout-kill then leaves wt in a background process group and
    /// the `wt config show` pager suspends with SIGTTOU (`suspended (tty output)`).
    #[test]
    fn test_compinit_probe_disables_job_control() {
        assert!(
            ZSH_COMPINIT_PROBE_ARGS.contains(&"+m"),
            "compinit probe must pass +m to disable job control (#3322); got {ZSH_COMPINIT_PROBE_ARGS:?}"
        );
        // `+m` must precede `-ic` so it's parsed as an option, not a command arg.
        let m = ZSH_COMPINIT_PROBE_ARGS.iter().position(|a| *a == "+m");
        let ic = ZSH_COMPINIT_PROBE_ARGS.iter().position(|a| *a == "-ic");
        assert!(
            m < ic,
            "+m must come before -ic: {ZSH_COMPINIT_PROBE_ARGS:?}"
        );
    }

    #[test]
    fn test_format_version_status() {
        // A version far above the current crate version is "newer".
        let update = format_version_status("999.0.0").to_string();
        assert!(
            update.contains("Update available"),
            "expected update message, got: {update}"
        );

        // The current crate version is not newer than itself.
        let up_to_date = format_version_status(env!("CARGO_PKG_VERSION")).to_string();
        assert!(
            up_to_date.contains("Up to date"),
            "expected up-to-date message, got: {up_to_date}"
        );
    }
}
