//! `spool mcp <subcommand>` command handlers.
//!
//! Thin glue between the clap argument structs and the `installers`
//! module. Stays here (not inside `installers/`) to keep the installer
//! crate boundary independent from CLI argument types.

use std::path::Path;

use anyhow::{Context, Result};

use crate::cli::args::{
    ClientValue, McpDoctorArgs, McpInstallArgs, McpReportFormat, McpUninstallArgs, McpUpdateArgs,
};
use crate::installers::{
    ClientId, DiagnosticReport, DiagnosticStatus, InstallContext, InstallReport, InstallStatus,
    UninstallReport, UninstallStatus, UpdateReport, UpdateStatus, installer_for,
};

pub fn execute_install(args: McpInstallArgs) -> Result<()> {
    let installer = installer_for(client_id(args.client));
    let ctx = InstallContext {
        binary_path: args
            .binary_path
            .map(absolutize)
            .transpose()
            .context("normalizing --binary-path")?,
        config_path: absolutize(args.config).context("normalizing --config")?,
        dry_run: args.dry_run,
        force: args.force,
    };

    let report = installer.install(&ctx)?;
    match args.format {
        McpReportFormat::Text => print!("{}", render_install_text(&report)),
        McpReportFormat::Json => {
            println!("{}", serde_json::to_string_pretty(&report)?);
        }
    }
    if matches!(report.status, InstallStatus::Conflict) {
        anyhow::bail!(
            "install conflict for client {}: existing entry differs (re-run with --force)",
            report.client
        );
    }
    Ok(())
}

pub fn execute_update(args: McpUpdateArgs) -> Result<()> {
    let installer = installer_for(client_id(args.client));
    let ctx = InstallContext {
        binary_path: args
            .binary_path
            .map(absolutize)
            .transpose()
            .context("normalizing --binary-path")?,
        config_path: absolutize(args.config).context("normalizing --config")?,
        dry_run: args.dry_run,
        force: false,
    };

    let report = installer.update(&ctx)?;
    match args.format {
        McpReportFormat::Text => print!("{}", render_update_text(&report)),
        McpReportFormat::Json => {
            println!("{}", serde_json::to_string_pretty(&report)?);
        }
    }
    Ok(())
}

pub fn execute_uninstall(args: McpUninstallArgs) -> Result<()> {
    let installer = installer_for(client_id(args.client));
    let ctx = InstallContext {
        binary_path: None,
        config_path: std::env::current_dir().unwrap_or_default(),
        dry_run: args.dry_run,
        force: false,
    };

    let report = installer.uninstall(&ctx)?;
    match args.format {
        McpReportFormat::Text => print!("{}", render_uninstall_text(&report)),
        McpReportFormat::Json => {
            println!("{}", serde_json::to_string_pretty(&report)?);
        }
    }
    Ok(())
}

pub fn execute_doctor(args: McpDoctorArgs) -> Result<()> {
    let installer = installer_for(client_id(args.client));
    let ctx = InstallContext {
        binary_path: args
            .binary_path
            .map(absolutize)
            .transpose()
            .context("normalizing --binary-path")?,
        config_path: absolutize(args.config).context("normalizing --config")?,
        dry_run: false,
        force: false,
    };

    let report = installer.diagnose(&ctx)?;
    match args.format {
        McpReportFormat::Text => print!("{}", render_doctor_text(&report)),
        McpReportFormat::Json => {
            println!("{}", serde_json::to_string_pretty(&report)?);
        }
    }
    if has_failure(&report) {
        anyhow::bail!("doctor reported FAIL checks for client {}", report.client);
    }
    Ok(())
}

fn client_id(value: ClientValue) -> ClientId {
    match value {
        ClientValue::Claude => ClientId::Claude,
        ClientValue::Codex => ClientId::Codex,
        ClientValue::Cursor => ClientId::Cursor,
        ClientValue::Opencode => ClientId::OpenCode,
    }
}

fn absolutize(p: std::path::PathBuf) -> Result<std::path::PathBuf> {
    if p.is_absolute() {
        return Ok(p);
    }
    let cwd = std::env::current_dir().context("current_dir() failed")?;
    Ok(cwd.join(p))
}

fn render_install_text(report: &InstallReport) -> String {
    let mut out = String::new();
    out.push_str(&format!("[install] client={}\n", report.client));
    out.push_str(&format!(
        "  status: {}\n",
        install_status_label(&report.status)
    ));
    out.push_str(&format!("  binary: {}\n", report.binary_path.display()));
    out.push_str(&format!("  config: {}\n", report.config_path.display()));
    if !report.planned_writes.is_empty() {
        out.push_str("  writes:\n");
        for p in &report.planned_writes {
            out.push_str(&format!("    - {}\n", p.display()));
        }
    }
    if !report.backups.is_empty() {
        out.push_str("  backups:\n");
        for p in &report.backups {
            out.push_str(&format!("    - {}\n", p.display()));
        }
    }
    if !report.notes.is_empty() {
        out.push_str("  notes:\n");
        for n in &report.notes {
            out.push_str(&format!("    - {}\n", n));
        }
    }
    out
}

fn render_update_text(report: &UpdateReport) -> String {
    let mut out = String::new();
    out.push_str(&format!("[update] client={}\n", report.client));
    out.push_str(&format!(
        "  status: {}\n",
        update_status_label(&report.status)
    ));
    if !report.updated_paths.is_empty() {
        out.push_str("  updated:\n");
        for p in &report.updated_paths {
            out.push_str(&format!("    - {}\n", p.display()));
        }
    }
    if !report.notes.is_empty() {
        out.push_str("  notes:\n");
        for n in &report.notes {
            out.push_str(&format!("    - {}\n", n));
        }
    }
    out
}

fn render_uninstall_text(report: &UninstallReport) -> String {
    let mut out = String::new();
    out.push_str(&format!("[uninstall] client={}\n", report.client));
    out.push_str(&format!(
        "  status: {}\n",
        uninstall_status_label(&report.status)
    ));
    if !report.removed_paths.is_empty() {
        out.push_str("  removed:\n");
        for p in &report.removed_paths {
            out.push_str(&format!("    - {}\n", p.display()));
        }
    }
    if !report.backups.is_empty() {
        out.push_str("  backups:\n");
        for p in &report.backups {
            out.push_str(&format!("    - {}\n", p.display()));
        }
    }
    if !report.notes.is_empty() {
        out.push_str("  notes:\n");
        for n in &report.notes {
            out.push_str(&format!("    - {}\n", n));
        }
    }
    out
}

fn render_doctor_text(report: &DiagnosticReport) -> String {
    let mut out = String::new();
    out.push_str(&format!("[doctor] client={}\n", report.client));
    for c in &report.checks {
        out.push_str(&format!(
            "  [{}] {} — {}\n",
            diag_label(&c.status),
            c.name,
            c.detail
        ));
    }
    out
}

fn install_status_label(status: &InstallStatus) -> &'static str {
    match status {
        InstallStatus::Installed => "installed",
        InstallStatus::Unchanged => "unchanged",
        InstallStatus::Conflict => "conflict",
        InstallStatus::DryRun => "dry-run",
    }
}

fn uninstall_status_label(status: &UninstallStatus) -> &'static str {
    match status {
        UninstallStatus::Removed => "removed",
        UninstallStatus::NotInstalled => "not-installed",
        UninstallStatus::DryRun => "dry-run",
    }
}

fn update_status_label(status: &UpdateStatus) -> &'static str {
    match status {
        UpdateStatus::Updated => "updated",
        UpdateStatus::Unchanged => "unchanged",
        UpdateStatus::NotInstalled => "not-installed",
        UpdateStatus::DryRun => "dry-run",
    }
}

fn diag_label(status: &DiagnosticStatus) -> &'static str {
    match status {
        DiagnosticStatus::Ok => " ok ",
        DiagnosticStatus::Warn => "warn",
        DiagnosticStatus::Fail => "FAIL",
        DiagnosticStatus::NotApplicable => "n/a ",
    }
}

fn has_failure(report: &DiagnosticReport) -> bool {
    report
        .checks
        .iter()
        .any(|c| matches!(c.status, DiagnosticStatus::Fail))
}

// Touch path import to silence unused warning on platforms where Path
// isn't used after refactors.
#[allow(dead_code)]
fn _path_anchor(_p: &Path) {}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::installers::{
        DiagnosticCheck, DiagnosticReport, DiagnosticStatus, InstallReport, InstallStatus,
        UninstallReport, UninstallStatus,
    };

    fn fake_install_report() -> InstallReport {
        InstallReport {
            client: "claude".into(),
            binary_path: "/abs/spool-mcp".into(),
            config_path: "/abs/spool.toml".into(),
            status: InstallStatus::Installed,
            planned_writes: vec!["/abs/.claude.json".into()],
            backups: vec!["/abs/.claude.json.bak-spool-1".into()],
            notes: vec!["binary missing".into()],
        }
    }

    #[test]
    fn render_install_text_includes_all_sections() {
        let text = render_install_text(&fake_install_report());
        assert!(text.contains("[install] client=claude"));
        assert!(text.contains("status: installed"));
        assert!(text.contains("binary: /abs/spool-mcp"));
        assert!(text.contains("writes:"));
        assert!(text.contains("backups:"));
        assert!(text.contains("notes:"));
    }

    #[test]
    fn render_uninstall_text_handles_empty_sections() {
        let report = UninstallReport {
            client: "claude".into(),
            status: UninstallStatus::NotInstalled,
            removed_paths: vec![],
            backups: vec![],
            notes: vec![],
        };
        let text = render_uninstall_text(&report);
        assert!(text.contains("[uninstall]"));
        assert!(text.contains("status: not-installed"));
        assert!(!text.contains("removed:"));
        assert!(!text.contains("backups:"));
    }

    #[test]
    fn render_doctor_text_lists_checks() {
        let report = DiagnosticReport {
            client: "claude".into(),
            checks: vec![
                DiagnosticCheck {
                    name: "claude_config_exists".into(),
                    status: DiagnosticStatus::Ok,
                    detail: "/abs/.claude.json".into(),
                },
                DiagnosticCheck {
                    name: "spool_mcp_binary".into(),
                    status: DiagnosticStatus::Fail,
                    detail: "/abs/spool-mcp".into(),
                },
            ],
        };
        let text = render_doctor_text(&report);
        assert!(text.contains("[ ok ]"));
        assert!(text.contains("[FAIL]"));
        assert!(text.contains("claude_config_exists"));
        assert!(text.contains("spool_mcp_binary"));
    }

    #[test]
    fn has_failure_detects_any_fail() {
        let mut report = DiagnosticReport {
            client: "claude".into(),
            checks: vec![DiagnosticCheck {
                name: "x".into(),
                status: DiagnosticStatus::Ok,
                detail: "".into(),
            }],
        };
        assert!(!has_failure(&report));
        report.checks.push(DiagnosticCheck {
            name: "y".into(),
            status: DiagnosticStatus::Fail,
            detail: "".into(),
        });
        assert!(has_failure(&report));
    }

    #[test]
    fn absolutize_keeps_absolute_unchanged() {
        let path = if cfg!(windows) {
            std::path::PathBuf::from("C:\\abs\\path")
        } else {
            std::path::PathBuf::from("/abs/path")
        };
        let abs = absolutize(path.clone()).unwrap();
        assert_eq!(abs, path);
    }

    #[test]
    fn absolutize_resolves_relative_against_cwd() {
        let cwd = std::env::current_dir().unwrap();
        let abs = absolutize(std::path::PathBuf::from("foo")).unwrap();
        assert_eq!(abs, cwd.join("foo"));
    }

    #[test]
    fn render_update_text_includes_all_sections() {
        let report = UpdateReport {
            client: "claude".into(),
            status: UpdateStatus::Updated,
            updated_paths: vec!["/abs/.claude/hooks/spool-Stop.sh".into()],
            notes: vec!["1 file(s) updated to latest templates.".into()],
        };
        let text = render_update_text(&report);
        assert!(text.contains("[update] client=claude"));
        assert!(text.contains("status: updated"));
        assert!(text.contains("updated:"));
        assert!(text.contains("spool-Stop.sh"));
        assert!(text.contains("notes:"));
    }

    #[test]
    fn render_update_text_handles_empty_sections() {
        let report = UpdateReport {
            client: "claude".into(),
            status: UpdateStatus::Unchanged,
            updated_paths: vec![],
            notes: vec![],
        };
        let text = render_update_text(&report);
        assert!(text.contains("[update]"));
        assert!(text.contains("status: unchanged"));
        assert!(!text.contains("updated:"));
        assert!(!text.contains("notes:"));
    }
}
