//! Claude Code installer adapter.
//!
//! ## What we touch
//! - `~/.claude.json`        — `mcpServers.spool` entry (R1)
//! - `~/.claude/settings.json` — `hooks.{event}` arrays, append-only
//!   per-event entries pointing to our hook scripts (R2)
//! - `~/.claude/hooks/spool-*.sh`         — five hook scripts (R2)
//! - `~/.claude/commands/spool-*.md`      — four slash commands (R2)
//! - `~/.claude/skills/spool-runtime/SKILL.md` — runtime skill (R2)
//!
//! All sibling tools (proxyman, pencil, `bd prime`, Trellis, …) stay
//! untouched. Every hook script / command / skill file we write uses a
//! `spool-` prefix so uninstall can sweep precisely.
//!
//! ## Binary resolution
//! - When `ctx.binary_path` is set, we use it verbatim (escape hatch
//!   for power users running an alternate build).
//! - Otherwise we default to `~/.cargo/bin/spool-mcp`. We deliberately
//!   do NOT shell out to `cargo install --path .` from the installer:
//!   that would couple `spool mcp install` to a long-running build
//!   step that's hard to recover from inside a hook context. The
//!   `diagnose` step warns when the binary is missing so `spool mcp
//!   doctor` (R5) can guide the user. Until then, the user is
//!   expected to run `cargo install --path .` once before
//!   `spool mcp install`.
//!
//! ## Idempotency
//! Re-running `install` after a successful install is a no-op when
//! every desired piece already matches what's on disk. Hook script
//! bodies are compared byte-for-byte; the report distinguishes
//! `Installed` (some change written) from `Unchanged` (everything
//! already in place).

use anyhow::{Context, Result};
use std::path::{Path, PathBuf};

use super::shared::{
    self, McpMergeOutcome, McpRemoveOutcome, SettingsHookOutcome, build_mcp_entry, merge_mcp_entry,
    purge_settings_hook_entries, remove_mcp_entry, upsert_settings_hook_command,
};
use super::templates::{
    self, CommandSpec, HookSpec, SkillSpec, claude_command_specs, claude_hook_specs,
    claude_skill_specs,
};
use super::{
    ClientId, DiagnosticCheck, DiagnosticReport, DiagnosticStatus, InstallContext, InstallReport,
    InstallStatus, Installer, UninstallReport, UninstallStatus, UpdateReport, UpdateStatus,
};

pub struct ClaudeInstaller {
    home_override: Option<PathBuf>,
}

impl ClaudeInstaller {
    pub fn new() -> Self {
        Self {
            home_override: None,
        }
    }

    /// Test-only constructor: route file lookups under `root` instead
    /// of the real `$HOME`. Used by unit tests + cli_smoke to keep the
    /// user's actual `~/.claude*` untouched.
    #[doc(hidden)]
    pub fn with_home_root(root: PathBuf) -> Self {
        Self {
            home_override: Some(root),
        }
    }

    fn home(&self) -> Result<PathBuf> {
        match &self.home_override {
            Some(p) => Ok(p.clone()),
            None => shared::home_dir(),
        }
    }

    fn claude_config_path(&self) -> Result<PathBuf> {
        Ok(self.home()?.join(".claude.json"))
    }

    fn settings_path(&self) -> Result<PathBuf> {
        Ok(self.home()?.join(".claude").join("settings.json"))
    }

    fn hooks_dir(&self) -> Result<PathBuf> {
        Ok(self.home()?.join(".claude").join("hooks"))
    }

    fn commands_dir(&self) -> Result<PathBuf> {
        Ok(self.home()?.join(".claude").join("commands"))
    }

    fn skills_dir(&self) -> Result<PathBuf> {
        Ok(self.home()?.join(".claude").join("skills"))
    }

    fn default_binary_path(&self) -> Result<PathBuf> {
        Ok(self.home()?.join(".cargo").join("bin").join("spool-mcp"))
    }

    fn resolve_binary_path(&self, ctx: &InstallContext) -> Result<PathBuf> {
        match &ctx.binary_path {
            Some(p) => Ok(p.clone()),
            None => self.default_binary_path(),
        }
    }

    fn validate_inputs(&self, ctx: &InstallContext, binary_path: &Path) -> Result<()> {
        if !ctx.config_path.is_absolute() {
            anyhow::bail!(
                "config path must be absolute, got: {}",
                ctx.config_path.display()
            );
        }
        if !binary_path.is_absolute() {
            anyhow::bail!(
                "binary path must be absolute, got: {}",
                binary_path.display()
            );
        }
        Ok(())
    }
}

impl Default for ClaudeInstaller {
    fn default() -> Self {
        Self::new()
    }
}

impl Installer for ClaudeInstaller {
    fn id(&self) -> ClientId {
        ClientId::Claude
    }

    fn detect(&self) -> Result<bool> {
        let claude_dir = self.home()?.join(".claude");
        Ok(claude_dir.exists() || self.claude_config_path()?.exists())
    }

    fn install(&self, ctx: &InstallContext) -> Result<InstallReport> {
        let binary_path = self.resolve_binary_path(ctx)?;
        self.validate_inputs(ctx, &binary_path)?;
        shared::ensure_config_exists(&ctx.config_path)?;

        let mut planned_writes: Vec<PathBuf> = Vec::new();
        let mut backups: Vec<PathBuf> = Vec::new();
        let mut notes: Vec<String> = Vec::new();

        // ── 1. mcpServers entry in ~/.claude.json ──────────────────
        let claude_config_path = self.claude_config_path()?;
        let mut claude_doc = shared::read_json_or_empty(&claude_config_path)?;
        let desired = build_mcp_entry(&binary_path, &ctx.config_path);
        let mcp_outcome = merge_mcp_entry(&mut claude_doc, "spool", desired, ctx.force);

        if !binary_path.exists() {
            notes.push(format!(
                "spool-mcp binary not found at {}. Run `cargo install --path .` or pass --binary-path.",
                binary_path.display()
            ));
        }

        let mcp_status = match mcp_outcome {
            McpMergeOutcome::Inserted => MergeStatus::Changed,
            McpMergeOutcome::Unchanged => MergeStatus::Unchanged,
            McpMergeOutcome::Conflict {
                force_applied: true,
            } => {
                notes.push(format!(
                    "Existing spool mcpServers entry was overwritten (force=true). Backup at {}.",
                    claude_config_path.display()
                ));
                MergeStatus::Changed
            }
            McpMergeOutcome::Conflict {
                force_applied: false,
            } => {
                notes.push(
                    "Existing spool mcpServers entry differs. Re-run with --force to overwrite, or `spool mcp uninstall` first."
                        .to_string(),
                );
                MergeStatus::Conflict
            }
        };

        // Short-circuit on hard mcpServers conflict — we don't keep
        // going to write hooks/commands/skills until the user resolves
        // the registration mismatch.
        if matches!(mcp_status, MergeStatus::Conflict) {
            return Ok(InstallReport {
                client: ClientId::Claude.as_str().to_string(),
                binary_path,
                config_path: ctx.config_path.clone(),
                status: InstallStatus::Conflict,
                planned_writes,
                backups,
                notes,
            });
        }

        // ── 2. settings.json hooks entries ─────────────────────────
        let settings_path = self.settings_path()?;
        let mut settings_doc = shared::read_json_or_empty(&settings_path)?;
        let hooks_dir = self.hooks_dir()?;
        let hook_specs = claude_hook_specs();
        let mut settings_changed = false;
        for spec in &hook_specs {
            let target_path = hooks_dir.join(spec.file_name);
            let target_str = target_path.to_string_lossy().into_owned();
            match upsert_settings_hook_command(&mut settings_doc, spec.hook_event, &target_str) {
                SettingsHookOutcome::Appended => settings_changed = true,
                SettingsHookOutcome::Unchanged => {}
            }
        }

        // ── 3. plan hook script writes ─────────────────────────────
        let spool_bin_for_hook = templates::bin_path_for_hook(&binary_path);
        let hook_files: Vec<HookFilePlan> = hook_specs
            .iter()
            .map(|spec| HookFilePlan {
                spec,
                target_path: hooks_dir.join(spec.file_name),
                rendered: templates::render_hook(spec.body, &spool_bin_for_hook, &ctx.config_path),
            })
            .collect();
        let hook_files_changed = hook_files
            .iter()
            .any(|p| !file_has_exact_contents(&p.target_path, &p.rendered));

        // ── 4. plan command + skill writes ─────────────────────────
        let commands_dir = self.commands_dir()?;
        let command_specs = claude_command_specs();
        let command_files: Vec<CommandFilePlan> = command_specs
            .iter()
            .map(|spec| CommandFilePlan {
                spec,
                target_path: commands_dir.join(spec.file_name),
            })
            .collect();
        let commands_changed = command_files
            .iter()
            .any(|p| !file_has_exact_contents(&p.target_path, p.spec.body));

        let skills_dir = self.skills_dir()?;
        let skill_specs = claude_skill_specs();
        let skill_files: Vec<SkillFilePlan> = skill_specs
            .iter()
            .map(|spec| SkillFilePlan {
                spec,
                target_path: skills_dir.join(spec.dir_name).join("SKILL.md"),
            })
            .collect();
        let skills_changed = skill_files
            .iter()
            .any(|p| !file_has_exact_contents(&p.target_path, p.spec.body));

        // ── 5. dry-run preview ─────────────────────────────────────
        let any_change = matches!(mcp_status, MergeStatus::Changed)
            || settings_changed
            || hook_files_changed
            || commands_changed
            || skills_changed;

        if ctx.dry_run {
            if matches!(mcp_status, MergeStatus::Changed) {
                planned_writes.push(claude_config_path.clone());
            }
            if settings_changed {
                planned_writes.push(settings_path.clone());
            }
            for plan in &hook_files {
                if !file_has_exact_contents(&plan.target_path, &plan.rendered) {
                    planned_writes.push(plan.target_path.clone());
                }
            }
            for plan in &command_files {
                if !file_has_exact_contents(&plan.target_path, plan.spec.body) {
                    planned_writes.push(plan.target_path.clone());
                }
            }
            for plan in &skill_files {
                if !file_has_exact_contents(&plan.target_path, plan.spec.body) {
                    planned_writes.push(plan.target_path.clone());
                }
            }
            return Ok(InstallReport {
                client: ClientId::Claude.as_str().to_string(),
                binary_path,
                config_path: ctx.config_path.clone(),
                status: if any_change {
                    InstallStatus::DryRun
                } else {
                    InstallStatus::Unchanged
                },
                planned_writes,
                backups,
                notes,
            });
        }

        // ── 6. apply mcpServers ────────────────────────────────────
        if matches!(mcp_status, MergeStatus::Changed) {
            if let Some(b) = shared::backup_file(&claude_config_path)
                .with_context(|| format!("backing up {}", claude_config_path.display()))?
            {
                backups.push(b);
            }
            shared::write_json_atomic(&claude_config_path, &claude_doc)
                .with_context(|| format!("writing {}", claude_config_path.display()))?;
            planned_writes.push(claude_config_path.clone());
        }

        // ── 7. apply settings.json hooks ───────────────────────────
        if settings_changed {
            if let Some(b) = shared::backup_file(&settings_path)
                .with_context(|| format!("backing up {}", settings_path.display()))?
            {
                backups.push(b);
            }
            shared::write_json_atomic(&settings_path, &settings_doc)
                .with_context(|| format!("writing {}", settings_path.display()))?;
            planned_writes.push(settings_path.clone());
        }

        // ── 8. apply hook script files ─────────────────────────────
        if !hooks_dir.exists() {
            std::fs::create_dir_all(&hooks_dir)
                .with_context(|| format!("creating {}", hooks_dir.display()))?;
        }
        for plan in &hook_files {
            if file_has_exact_contents(&plan.target_path, &plan.rendered) {
                continue;
            }
            std::fs::write(&plan.target_path, &plan.rendered)
                .with_context(|| format!("writing {}", plan.target_path.display()))?;
            set_executable(&plan.target_path)?;
            planned_writes.push(plan.target_path.clone());
        }

        // ── 9. apply commands ──────────────────────────────────────
        if !commands_dir.exists() {
            std::fs::create_dir_all(&commands_dir)
                .with_context(|| format!("creating {}", commands_dir.display()))?;
        }
        for plan in &command_files {
            if file_has_exact_contents(&plan.target_path, plan.spec.body) {
                continue;
            }
            std::fs::write(&plan.target_path, plan.spec.body)
                .with_context(|| format!("writing {}", plan.target_path.display()))?;
            planned_writes.push(plan.target_path.clone());
        }

        // ── 10. apply skills ───────────────────────────────────────
        for plan in &skill_files {
            if file_has_exact_contents(&plan.target_path, plan.spec.body) {
                continue;
            }
            if let Some(parent) = plan.target_path.parent()
                && !parent.exists()
            {
                std::fs::create_dir_all(parent)
                    .with_context(|| format!("creating {}", parent.display()))?;
            }
            std::fs::write(&plan.target_path, plan.spec.body)
                .with_context(|| format!("writing {}", plan.target_path.display()))?;
            planned_writes.push(plan.target_path.clone());
        }

        let final_status = if any_change {
            InstallStatus::Installed
        } else {
            InstallStatus::Unchanged
        };

        Ok(InstallReport {
            client: ClientId::Claude.as_str().to_string(),
            binary_path,
            config_path: ctx.config_path.clone(),
            status: final_status,
            planned_writes,
            backups,
            notes,
        })
    }

    fn update(&self, ctx: &InstallContext) -> Result<UpdateReport> {
        let mut updated_paths: Vec<PathBuf> = Vec::new();
        let mut notes: Vec<String> = Vec::new();

        // ── 1. Check if spool is installed (mcpServers entry exists) ──
        let claude_config_path = self.claude_config_path()?;
        if !claude_config_path.exists() {
            return Ok(UpdateReport {
                client: ClientId::Claude.as_str().to_string(),
                status: UpdateStatus::NotInstalled,
                updated_paths,
                notes: vec!["spool is not installed (no ~/.claude.json found). Run `spool mcp install` first.".to_string()],
            });
        }
        let claude_doc = shared::read_json_or_empty(&claude_config_path)?;
        let mcp_entry = claude_doc.get("mcpServers").and_then(|v| v.get("spool"));
        if mcp_entry.is_none() {
            return Ok(UpdateReport {
                client: ClientId::Claude.as_str().to_string(),
                status: UpdateStatus::NotInstalled,
                updated_paths,
                notes: vec![
                    "spool is not registered in mcpServers. Run `spool mcp install` first."
                        .to_string(),
                ],
            });
        }

        // ── 2. Resolve binary path from existing entry or ctx override ──
        let binary_path = match &ctx.binary_path {
            Some(p) => p.clone(),
            None => {
                // Try to read from the existing mcpServers entry
                let existing_bin = mcp_entry
                    .and_then(|e| e.get("command"))
                    .and_then(|c| c.as_str())
                    .map(PathBuf::from);
                match existing_bin {
                    Some(p) => p,
                    None => self.default_binary_path()?,
                }
            }
        };
        let spool_bin_for_hook = templates::bin_path_for_hook(&binary_path);

        // ── 3. Re-render hooks and compare with on-disk ───────────────
        let hooks_dir = self.hooks_dir()?;
        let hook_specs = claude_hook_specs();
        let hook_files: Vec<HookFilePlan> = hook_specs
            .iter()
            .map(|spec| HookFilePlan {
                spec,
                target_path: hooks_dir.join(spec.file_name),
                rendered: templates::render_hook(spec.body, &spool_bin_for_hook, &ctx.config_path),
            })
            .collect();

        // ── 4. Re-render commands and compare ─────────────────────────
        let commands_dir = self.commands_dir()?;
        let command_specs = claude_command_specs();
        let command_files: Vec<CommandFilePlan> = command_specs
            .iter()
            .map(|spec| CommandFilePlan {
                spec,
                target_path: commands_dir.join(spec.file_name),
            })
            .collect();

        // ── 5. Re-render skills and compare ──────────────────────────
        let skills_dir = self.skills_dir()?;
        let skill_specs = claude_skill_specs();
        let skill_files: Vec<SkillFilePlan> = skill_specs
            .iter()
            .map(|spec| SkillFilePlan {
                spec,
                target_path: skills_dir.join(spec.dir_name).join("SKILL.md"),
            })
            .collect();

        // ── 6. Collect diffs ──────────────────────────────────────────
        let mut diffs: Vec<PathBuf> = Vec::new();
        for plan in &hook_files {
            if !file_has_exact_contents(&plan.target_path, &plan.rendered) {
                diffs.push(plan.target_path.clone());
            }
        }
        for plan in &command_files {
            if !file_has_exact_contents(&plan.target_path, plan.spec.body) {
                diffs.push(plan.target_path.clone());
            }
        }
        for plan in &skill_files {
            if !file_has_exact_contents(&plan.target_path, plan.spec.body) {
                diffs.push(plan.target_path.clone());
            }
        }

        if diffs.is_empty() {
            return Ok(UpdateReport {
                client: ClientId::Claude.as_str().to_string(),
                status: UpdateStatus::Unchanged,
                updated_paths,
                notes,
            });
        }

        // ── 7. Dry-run: report what would change ─────────────────────
        if ctx.dry_run {
            return Ok(UpdateReport {
                client: ClientId::Claude.as_str().to_string(),
                status: UpdateStatus::DryRun,
                updated_paths: diffs,
                notes,
            });
        }

        // ── 8. Apply hook script writes ──────────────────────────────
        if !hooks_dir.exists() {
            std::fs::create_dir_all(&hooks_dir)
                .with_context(|| format!("creating {}", hooks_dir.display()))?;
        }
        for plan in &hook_files {
            if file_has_exact_contents(&plan.target_path, &plan.rendered) {
                continue;
            }
            std::fs::write(&plan.target_path, &plan.rendered)
                .with_context(|| format!("writing {}", plan.target_path.display()))?;
            set_executable(&plan.target_path)?;
            updated_paths.push(plan.target_path.clone());
        }

        // ── 9. Apply command writes ──────────────────────────────────
        if !commands_dir.exists() {
            std::fs::create_dir_all(&commands_dir)
                .with_context(|| format!("creating {}", commands_dir.display()))?;
        }
        for plan in &command_files {
            if file_has_exact_contents(&plan.target_path, plan.spec.body) {
                continue;
            }
            std::fs::write(&plan.target_path, plan.spec.body)
                .with_context(|| format!("writing {}", plan.target_path.display()))?;
            updated_paths.push(plan.target_path.clone());
        }

        // ── 10. Apply skill writes ──────────────────────────────────
        for plan in &skill_files {
            if file_has_exact_contents(&plan.target_path, plan.spec.body) {
                continue;
            }
            if let Some(parent) = plan.target_path.parent()
                && !parent.exists()
            {
                std::fs::create_dir_all(parent)
                    .with_context(|| format!("creating {}", parent.display()))?;
            }
            std::fs::write(&plan.target_path, plan.spec.body)
                .with_context(|| format!("writing {}", plan.target_path.display()))?;
            updated_paths.push(plan.target_path.clone());
        }

        if !updated_paths.is_empty() {
            notes.push(format!(
                "{} file(s) updated to latest templates.",
                updated_paths.len()
            ));
        }

        Ok(UpdateReport {
            client: ClientId::Claude.as_str().to_string(),
            status: UpdateStatus::Updated,
            updated_paths,
            notes,
        })
    }

    fn uninstall(&self, ctx: &InstallContext) -> Result<UninstallReport> {
        let claude_config_path = self.claude_config_path()?;
        let settings_path = self.settings_path()?;
        let hooks_dir = self.hooks_dir()?;
        let commands_dir = self.commands_dir()?;
        let skills_dir = self.skills_dir()?;

        let mut notes: Vec<String> = Vec::new();
        let mut removed_paths: Vec<PathBuf> = Vec::new();
        let mut backups: Vec<PathBuf> = Vec::new();
        let mut any_change = false;

        // ── 1. mcpServers ──────────────────────────────────────────
        let claude_doc_after_purge = if claude_config_path.exists() {
            let mut doc = shared::read_json_or_empty(&claude_config_path)?;
            match remove_mcp_entry(&mut doc, "spool") {
                McpRemoveOutcome::Removed => {
                    any_change = true;
                    Some(doc)
                }
                McpRemoveOutcome::NotPresent => None,
            }
        } else {
            None
        };

        // ── 2. settings.json hooks purge ───────────────────────────
        let settings_doc_after_purge = if settings_path.exists() {
            let mut doc = shared::read_json_or_empty(&settings_path)?;
            let removed = purge_settings_hook_entries(&mut doc, "spool-");
            if removed > 0 {
                any_change = true;
                Some(doc)
            } else {
                None
            }
        } else {
            None
        };

        // ── 3. plan file removals ──────────────────────────────────
        let hook_files: Vec<PathBuf> = claude_hook_specs()
            .iter()
            .map(|s| hooks_dir.join(s.file_name))
            .filter(|p| p.exists())
            .collect();
        if !hook_files.is_empty() {
            any_change = true;
        }

        let command_files: Vec<PathBuf> = claude_command_specs()
            .iter()
            .map(|s| commands_dir.join(s.file_name))
            .filter(|p| p.exists())
            .collect();
        if !command_files.is_empty() {
            any_change = true;
        }

        let skill_dirs: Vec<PathBuf> = claude_skill_specs()
            .iter()
            .map(|s| skills_dir.join(s.dir_name))
            .filter(|p| p.exists())
            .collect();
        if !skill_dirs.is_empty() {
            any_change = true;
        }

        if !any_change {
            notes.push("nothing to uninstall — no spool artifacts found.".to_string());
            return Ok(UninstallReport {
                client: ClientId::Claude.as_str().to_string(),
                status: UninstallStatus::NotInstalled,
                removed_paths,
                backups,
                notes,
            });
        }

        if ctx.dry_run {
            if claude_doc_after_purge.is_some() {
                removed_paths.push(claude_config_path);
            }
            if settings_doc_after_purge.is_some() {
                removed_paths.push(settings_path);
            }
            removed_paths.extend(hook_files);
            removed_paths.extend(command_files);
            removed_paths.extend(skill_dirs);
            return Ok(UninstallReport {
                client: ClientId::Claude.as_str().to_string(),
                status: UninstallStatus::DryRun,
                removed_paths,
                backups,
                notes,
            });
        }

        // ── 4. apply mcpServers purge ──────────────────────────────
        if let Some(doc) = claude_doc_after_purge {
            if let Some(b) = shared::backup_file(&claude_config_path)? {
                backups.push(b);
            }
            shared::write_json_atomic(&claude_config_path, &doc)?;
            removed_paths.push(claude_config_path);
        }

        // ── 5. apply settings.json purge ───────────────────────────
        if let Some(doc) = settings_doc_after_purge {
            if let Some(b) = shared::backup_file(&settings_path)? {
                backups.push(b);
            }
            shared::write_json_atomic(&settings_path, &doc)?;
            removed_paths.push(settings_path);
        }

        // ── 6. delete hook scripts / commands / skills ─────────────
        for p in hook_files {
            std::fs::remove_file(&p).with_context(|| format!("removing {}", p.display()))?;
            removed_paths.push(p);
        }
        for p in command_files {
            std::fs::remove_file(&p).with_context(|| format!("removing {}", p.display()))?;
            removed_paths.push(p);
        }
        for p in skill_dirs {
            std::fs::remove_dir_all(&p).with_context(|| format!("removing {}", p.display()))?;
            removed_paths.push(p);
        }

        Ok(UninstallReport {
            client: ClientId::Claude.as_str().to_string(),
            status: UninstallStatus::Removed,
            removed_paths,
            backups,
            notes,
        })
    }

    fn diagnose(&self, ctx: &InstallContext) -> Result<DiagnosticReport> {
        let mut checks = Vec::new();

        // Claude config presence
        let config_doc_path = self.claude_config_path()?;
        let config_status = if config_doc_path.exists() {
            DiagnosticStatus::Ok
        } else {
            DiagnosticStatus::Warn
        };
        checks.push(DiagnosticCheck {
            name: "claude_config_exists".into(),
            status: config_status,
            detail: format!("{}", config_doc_path.display()),
        });

        // mcpServers.spool registration
        let registration_status = if config_doc_path.exists() {
            let doc = shared::read_json_or_empty(&config_doc_path)?;
            if doc.get("mcpServers").and_then(|v| v.get("spool")).is_some() {
                DiagnosticStatus::Ok
            } else {
                DiagnosticStatus::Warn
            }
        } else {
            DiagnosticStatus::NotApplicable
        };
        checks.push(DiagnosticCheck {
            name: "mcp_servers_spool_registered".into(),
            status: registration_status,
            detail: "mcpServers.spool entry presence".into(),
        });

        // Binary path
        let binary_path = self.resolve_binary_path(ctx)?;
        let binary_status = if binary_path.exists() {
            DiagnosticStatus::Ok
        } else {
            DiagnosticStatus::Fail
        };
        checks.push(DiagnosticCheck {
            name: "spool_mcp_binary".into(),
            status: binary_status,
            detail: format!("{}", binary_path.display()),
        });

        // Config TOML readable
        let toml_status = if ctx.config_path.exists() {
            DiagnosticStatus::Ok
        } else {
            DiagnosticStatus::Fail
        };
        checks.push(DiagnosticCheck {
            name: "spool_config_readable".into(),
            status: toml_status,
            detail: format!("{}", ctx.config_path.display()),
        });

        // settings.json hooks have at least one spool- entry
        let settings_path = self.settings_path()?;
        let hooks_registered_status = if settings_path.exists() {
            let doc = shared::read_json_or_empty(&settings_path)?;
            if has_any_spool_hook_entry(&doc) {
                DiagnosticStatus::Ok
            } else {
                DiagnosticStatus::Warn
            }
        } else {
            DiagnosticStatus::Warn
        };
        checks.push(DiagnosticCheck {
            name: "claude_settings_hooks_registered".into(),
            status: hooks_registered_status,
            detail: format!("{}", settings_path.display()),
        });

        // hook script files present
        let hooks_dir = self.hooks_dir()?;
        let mut missing: Vec<String> = Vec::new();
        for spec in claude_hook_specs() {
            let p = hooks_dir.join(spec.file_name);
            if !p.exists() {
                missing.push(spec.file_name.to_string());
            }
        }
        let hook_files_status = if missing.is_empty() {
            DiagnosticStatus::Ok
        } else {
            DiagnosticStatus::Warn
        };
        let hook_files_detail = if missing.is_empty() {
            format!("{} (5/5 present)", hooks_dir.display())
        } else {
            format!("{} missing: {}", hooks_dir.display(), missing.join(", "))
        };
        checks.push(DiagnosticCheck {
            name: "spool_hook_scripts".into(),
            status: hook_files_status,
            detail: hook_files_detail,
        });

        // skill present
        let skills_dir = self.skills_dir()?;
        let skill_present = claude_skill_specs()
            .iter()
            .all(|s| skills_dir.join(s.dir_name).join("SKILL.md").exists());
        checks.push(DiagnosticCheck {
            name: "spool_skill_present".into(),
            status: if skill_present {
                DiagnosticStatus::Ok
            } else {
                DiagnosticStatus::Warn
            },
            detail: format!("{}", skills_dir.display()),
        });

        Ok(DiagnosticReport {
            client: ClientId::Claude.as_str().to_string(),
            checks,
        })
    }
}

// ─────────────────────────────────────────────────────────────────────
// internal plan / merge types
// ─────────────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MergeStatus {
    Changed,
    Unchanged,
    Conflict,
}

struct HookFilePlan<'a> {
    #[allow(dead_code)] // kept for symmetry with command/skill plans + future debug
    spec: &'a HookSpec,
    target_path: PathBuf,
    rendered: String,
}

struct CommandFilePlan<'a> {
    spec: &'a CommandSpec,
    target_path: PathBuf,
}

struct SkillFilePlan<'a> {
    spec: &'a SkillSpec,
    target_path: PathBuf,
}

fn file_has_exact_contents(path: &Path, expected: &str) -> bool {
    if !path.exists() {
        return false;
    }
    match std::fs::read_to_string(path) {
        Ok(actual) => actual == expected,
        Err(_) => false,
    }
}

fn has_any_spool_hook_entry(doc: &serde_json::Value) -> bool {
    let Some(hooks) = doc.get("hooks").and_then(|v| v.as_object()) else {
        return false;
    };
    for entries in hooks.values() {
        let Some(arr) = entries.as_array() else {
            continue;
        };
        for entry in arr {
            let Some(inner) = entry.get("hooks").and_then(|v| v.as_array()) else {
                continue;
            };
            for h in inner {
                if let Some(cmd) = h.get("command").and_then(|c| c.as_str())
                    && cmd.contains("spool-")
                {
                    return true;
                }
            }
        }
    }
    false
}

#[cfg(unix)]
fn set_executable(path: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;
    let mut perms = std::fs::metadata(path)
        .with_context(|| format!("stat {}", path.display()))?
        .permissions();
    perms.set_mode(0o755);
    std::fs::set_permissions(path, perms).with_context(|| format!("chmod {}", path.display()))?;
    Ok(())
}

#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<()> {
    // Hooks on Windows aren't expected to be invoked via shebang;
    // Claude Code uses CMD/PowerShell. Out of scope for R2.
    Ok(())
}

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

    fn setup() -> (tempfile::TempDir, ClaudeInstaller, InstallContext) {
        let temp = tempdir().unwrap();
        let home = temp.path().to_path_buf();
        let installer = ClaudeInstaller::with_home_root(home.clone());

        let config_path = home.join("spool.toml");
        fs::write(&config_path, "[vault]\nroot=\"/tmp\"\n").unwrap();
        let binary_path = home.join("fake-spool-mcp");
        fs::write(&binary_path, "#!/bin/sh\nexit 0\n").unwrap();

        let ctx = InstallContext {
            binary_path: Some(binary_path),
            config_path,
            dry_run: false,
            force: false,
        };
        (temp, installer, ctx)
    }

    #[test]
    fn detect_returns_false_when_no_claude_dir() {
        let temp = tempdir().unwrap();
        let installer = ClaudeInstaller::with_home_root(temp.path().to_path_buf());
        assert!(!installer.detect().unwrap());
    }

    #[test]
    fn detect_returns_true_when_claude_dir_present() {
        let temp = tempdir().unwrap();
        fs::create_dir_all(temp.path().join(".claude")).unwrap();
        let installer = ClaudeInstaller::with_home_root(temp.path().to_path_buf());
        assert!(installer.detect().unwrap());
    }

    #[test]
    fn install_writes_full_payload_on_first_run() {
        let (temp, installer, ctx) = setup();
        let report = installer.install(&ctx).unwrap();
        assert_eq!(report.status, InstallStatus::Installed);

        // Files written:
        assert!(temp.path().join(".claude.json").exists());
        assert!(temp.path().join(".claude").join("settings.json").exists());
        for spec in claude_hook_specs() {
            let p = temp
                .path()
                .join(".claude")
                .join("hooks")
                .join(spec.file_name);
            assert!(p.exists(), "{} missing", p.display());
        }
        for spec in claude_command_specs() {
            let p = temp
                .path()
                .join(".claude")
                .join("commands")
                .join(spec.file_name);
            assert!(p.exists(), "{} missing", p.display());
        }
        let skill_path = temp
            .path()
            .join(".claude")
            .join("skills")
            .join("spool-runtime")
            .join("SKILL.md");
        assert!(skill_path.exists());

        // mcpServers.spool written
        let claude: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(temp.path().join(".claude.json")).unwrap())
                .unwrap();
        assert!(claude["mcpServers"]["spool"].is_object());

        // settings.json hooks each have one spool- entry
        let settings: serde_json::Value = serde_json::from_str(
            &fs::read_to_string(temp.path().join(".claude").join("settings.json")).unwrap(),
        )
        .unwrap();
        for spec in claude_hook_specs() {
            let entries = settings["hooks"][spec.hook_event].as_array().unwrap();
            assert!(
                entries.iter().any(|e| e["hooks"][0]["command"]
                    .as_str()
                    .is_some_and(|c| c.contains("spool-"))),
                "{} entry missing in settings.json",
                spec.hook_event
            );
        }
    }

    #[test]
    fn install_preserves_existing_session_start_hook() {
        let (temp, installer, ctx) = setup();
        // Pre-seed `bd prime` style entry — same shape as the real
        // user has on their machine.
        fs::create_dir_all(temp.path().join(".claude")).unwrap();
        let preexisting = json!({
            "hooks": {
                "SessionStart": [
                    {"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]}
                ]
            }
        });
        fs::write(
            temp.path().join(".claude").join("settings.json"),
            serde_json::to_string_pretty(&preexisting).unwrap(),
        )
        .unwrap();

        let report = installer.install(&ctx).unwrap();
        assert_eq!(report.status, InstallStatus::Installed);

        let settings: serde_json::Value = serde_json::from_str(
            &fs::read_to_string(temp.path().join(".claude").join("settings.json")).unwrap(),
        )
        .unwrap();
        let session_entries = settings["hooks"]["SessionStart"].as_array().unwrap();
        assert_eq!(session_entries.len(), 2);
        assert_eq!(session_entries[0]["hooks"][0]["command"], "bd prime");
        let spool_cmd = session_entries[1]["hooks"][0]["command"].as_str().unwrap();
        assert!(spool_cmd.contains("spool-SessionStart.sh"));
    }

    #[test]
    fn install_dry_run_does_not_write_anything() {
        let (temp, installer, mut ctx) = setup();
        ctx.dry_run = true;
        let report = installer.install(&ctx).unwrap();
        assert_eq!(report.status, InstallStatus::DryRun);
        assert!(!report.planned_writes.is_empty());
        // No directories should be created.
        assert!(!temp.path().join(".claude").exists());
        assert!(!temp.path().join(".claude.json").exists());
    }

    #[test]
    fn install_unchanged_on_repeat() {
        let (_temp, installer, ctx) = setup();
        let _ = installer.install(&ctx).unwrap();
        let second = installer.install(&ctx).unwrap();
        assert_eq!(second.status, InstallStatus::Unchanged);
    }

    #[test]
    fn install_re_renders_hook_when_template_drifts() {
        let (temp, installer, ctx) = setup();
        let _ = installer.install(&ctx).unwrap();
        let stop_path = temp
            .path()
            .join(".claude")
            .join("hooks")
            .join("spool-Stop.sh");
        fs::write(&stop_path, "tampered\n").unwrap();
        let report = installer.install(&ctx).unwrap();
        assert_eq!(report.status, InstallStatus::Installed);
        let restored = fs::read_to_string(&stop_path).unwrap();
        assert!(!restored.contains("tampered"));
        assert!(restored.contains("hook stop"));
    }

    #[test]
    fn install_marks_conflict_when_existing_mcp_entry_differs() {
        let (temp, installer, ctx) = setup();
        let claude_json = temp.path().join(".claude.json");
        let preexisting = json!({
            "mcpServers": {
                "spool": {"type": "stdio", "command": "/old/path", "args": []}
            }
        });
        fs::write(
            &claude_json,
            serde_json::to_string_pretty(&preexisting).unwrap(),
        )
        .unwrap();

        let report = installer.install(&ctx).unwrap();
        assert_eq!(report.status, InstallStatus::Conflict);
        // Conflict path short-circuits — no hook scripts get written.
        assert!(!temp.path().join(".claude").join("hooks").exists());
    }

    #[test]
    fn install_force_overrides_conflict() {
        let (temp, installer, mut ctx) = setup();
        ctx.force = true;
        let claude_json = temp.path().join(".claude.json");
        let preexisting = json!({
            "mcpServers": {
                "spool": {"type": "stdio", "command": "/old/path", "args": []}
            }
        });
        fs::write(
            &claude_json,
            serde_json::to_string_pretty(&preexisting).unwrap(),
        )
        .unwrap();
        let report = installer.install(&ctx).unwrap();
        assert_eq!(report.status, InstallStatus::Installed);
        assert!(temp.path().join(".claude").join("hooks").exists());
    }

    #[test]
    fn install_records_warning_when_binary_missing() {
        let temp = tempdir().unwrap();
        let installer = ClaudeInstaller::with_home_root(temp.path().to_path_buf());
        let config_path = temp.path().join("spool.toml");
        fs::write(&config_path, "x=1").unwrap();
        let ctx = InstallContext {
            binary_path: Some(temp.path().join("nope")),
            config_path,
            dry_run: false,
            force: false,
        };
        let report = installer.install(&ctx).unwrap();
        assert!(report.notes.iter().any(|n| n.contains("not found")));
    }

    #[test]
    fn install_rejects_relative_binary_path() {
        let (_temp, installer, mut ctx) = setup();
        ctx.binary_path = Some(PathBuf::from("relative/path"));
        let err = installer.install(&ctx).unwrap_err();
        assert!(err.to_string().contains("binary path must be absolute"));
    }

    #[test]
    fn install_rejects_relative_config_path() {
        let (_temp, installer, mut ctx) = setup();
        ctx.config_path = PathBuf::from("relative/spool.toml");
        let err = installer.install(&ctx).unwrap_err();
        assert!(err.to_string().contains("config path must be absolute"));
    }

    #[cfg(unix)]
    #[test]
    fn install_makes_hook_scripts_executable() {
        use std::os::unix::fs::PermissionsExt;
        let (temp, installer, ctx) = setup();
        let _ = installer.install(&ctx).unwrap();
        let session = temp
            .path()
            .join(".claude")
            .join("hooks")
            .join("spool-SessionStart.sh");
        let perms = fs::metadata(&session).unwrap().permissions();
        assert_eq!(perms.mode() & 0o777, 0o755);
    }

    #[test]
    fn uninstall_removes_full_payload() {
        let (temp, installer, ctx) = setup();
        let _ = installer.install(&ctx).unwrap();

        let report = installer.uninstall(&ctx).unwrap();
        assert_eq!(report.status, UninstallStatus::Removed);

        assert!(
            !temp
                .path()
                .join(".claude")
                .join("hooks")
                .join("spool-SessionStart.sh")
                .exists()
        );
        assert!(
            !temp
                .path()
                .join(".claude")
                .join("commands")
                .join("spool-wakeup.md")
                .exists()
        );
        assert!(
            !temp
                .path()
                .join(".claude")
                .join("skills")
                .join("spool-runtime")
                .exists()
        );
        let claude: serde_json::Value =
            serde_json::from_str(&fs::read_to_string(temp.path().join(".claude.json")).unwrap())
                .unwrap();
        assert!(claude["mcpServers"].get("spool").is_none());
    }

    #[test]
    fn uninstall_preserves_other_hooks() {
        let (temp, installer, ctx) = setup();
        // Pre-seed bd prime, install spool, uninstall, expect bd prime
        // still there.
        fs::create_dir_all(temp.path().join(".claude")).unwrap();
        let preexisting = json!({
            "hooks": {
                "SessionStart": [
                    {"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]}
                ]
            }
        });
        fs::write(
            temp.path().join(".claude").join("settings.json"),
            serde_json::to_string_pretty(&preexisting).unwrap(),
        )
        .unwrap();
        let _ = installer.install(&ctx).unwrap();
        let _ = installer.uninstall(&ctx).unwrap();

        let settings: serde_json::Value = serde_json::from_str(
            &fs::read_to_string(temp.path().join(".claude").join("settings.json")).unwrap(),
        )
        .unwrap();
        let entries = settings["hooks"]["SessionStart"].as_array().unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0]["hooks"][0]["command"], "bd prime");
    }

    #[test]
    fn uninstall_not_installed_when_clean() {
        let (_temp, installer, ctx) = setup();
        let report = installer.uninstall(&ctx).unwrap();
        assert_eq!(report.status, UninstallStatus::NotInstalled);
    }

    #[test]
    fn uninstall_dry_run_changes_nothing() {
        let (temp, installer, mut ctx) = setup();
        let _ = installer.install(&ctx).unwrap();
        ctx.dry_run = true;
        let report = installer.uninstall(&ctx).unwrap();
        assert_eq!(report.status, UninstallStatus::DryRun);
        assert!(
            temp.path()
                .join(".claude")
                .join("hooks")
                .join("spool-SessionStart.sh")
                .exists(),
            "dry-run must keep file"
        );
    }

    #[test]
    fn diagnose_reports_full_check_set_after_install() {
        let (_temp, installer, ctx) = setup();
        let _ = installer.install(&ctx).unwrap();
        let report = installer.diagnose(&ctx).unwrap();
        let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
        for expected in [
            "claude_config_exists",
            "mcp_servers_spool_registered",
            "spool_mcp_binary",
            "spool_config_readable",
            "claude_settings_hooks_registered",
            "spool_hook_scripts",
            "spool_skill_present",
        ] {
            assert!(names.contains(&expected), "missing check {}", expected);
        }
        let hooks_check = report
            .checks
            .iter()
            .find(|c| c.name == "claude_settings_hooks_registered")
            .unwrap();
        assert_eq!(hooks_check.status, DiagnosticStatus::Ok);
    }

    #[test]
    fn diagnose_warns_when_hooks_not_registered() {
        let temp = tempdir().unwrap();
        let installer = ClaudeInstaller::with_home_root(temp.path().to_path_buf());
        fs::create_dir_all(temp.path().join(".claude")).unwrap();
        fs::write(temp.path().join(".claude.json"), r#"{"mcpServers":{}}"#).unwrap();
        let config_path = temp.path().join("spool.toml");
        fs::write(&config_path, "x=1").unwrap();
        let binary_path = temp.path().join("fake-spool-mcp");
        fs::write(&binary_path, "").unwrap();
        let ctx = InstallContext {
            binary_path: Some(binary_path),
            config_path,
            dry_run: false,
            force: false,
        };
        let report = installer.diagnose(&ctx).unwrap();
        let hooks_check = report
            .checks
            .iter()
            .find(|c| c.name == "claude_settings_hooks_registered")
            .unwrap();
        assert_eq!(hooks_check.status, DiagnosticStatus::Warn);
    }

    // ─── update tests ─────────────────────────────────────────────────

    #[test]
    fn update_returns_not_installed_when_no_mcp_entry() {
        let (_temp, installer, ctx) = setup();
        // No install has been done, so no mcpServers entry exists.
        let report = installer.update(&ctx).unwrap();
        assert_eq!(report.status, UpdateStatus::NotInstalled);
        assert!(
            report
                .notes
                .iter()
                .any(|n| n.contains("not installed") || n.contains("not registered"))
        );
    }

    #[test]
    fn update_re_renders_drifted_hooks() {
        let (temp, installer, ctx) = setup();
        // First install to set up everything.
        let _ = installer.install(&ctx).unwrap();

        // Tamper with a hook script to simulate template drift.
        let stop_path = temp
            .path()
            .join(".claude")
            .join("hooks")
            .join("spool-Stop.sh");
        fs::write(&stop_path, "tampered content\n").unwrap();

        let report = installer.update(&ctx).unwrap();
        assert_eq!(report.status, UpdateStatus::Updated);
        assert!(report.updated_paths.contains(&stop_path));

        // Verify the file was restored.
        let restored = fs::read_to_string(&stop_path).unwrap();
        assert!(!restored.contains("tampered"));
        assert!(restored.contains("hook stop"));
    }

    #[test]
    fn update_unchanged_when_templates_match() {
        let (_temp, installer, ctx) = setup();
        // Install, then update — should be unchanged.
        let _ = installer.install(&ctx).unwrap();
        let report = installer.update(&ctx).unwrap();
        assert_eq!(report.status, UpdateStatus::Unchanged);
        assert!(report.updated_paths.is_empty());
    }

    #[test]
    fn update_dry_run_does_not_write() {
        let (temp, installer, mut ctx) = setup();
        // Install first.
        let _ = installer.install(&ctx).unwrap();

        // Tamper with a hook.
        let stop_path = temp
            .path()
            .join(".claude")
            .join("hooks")
            .join("spool-Stop.sh");
        fs::write(&stop_path, "tampered\n").unwrap();

        // Update with dry_run.
        ctx.dry_run = true;
        let report = installer.update(&ctx).unwrap();
        assert_eq!(report.status, UpdateStatus::DryRun);
        assert!(!report.updated_paths.is_empty());

        // File should still be tampered (not written).
        let content = fs::read_to_string(&stop_path).unwrap();
        assert!(content.contains("tampered"));
    }

    #[test]
    fn update_uses_binary_path_from_existing_mcp_entry() {
        let (temp, installer, ctx) = setup();
        // Install with a specific binary path.
        let _ = installer.install(&ctx).unwrap();

        // Tamper with a hook to force re-render.
        let session_path = temp
            .path()
            .join(".claude")
            .join("hooks")
            .join("spool-SessionStart.sh");
        fs::write(&session_path, "old\n").unwrap();

        // Update without specifying binary_path — should read from mcpServers.
        let update_ctx = InstallContext {
            binary_path: None,
            config_path: ctx.config_path.clone(),
            dry_run: false,
            force: false,
        };
        let report = installer.update(&update_ctx).unwrap();
        assert_eq!(report.status, UpdateStatus::Updated);

        // The restored hook should contain the original binary path.
        let restored = fs::read_to_string(&session_path).unwrap();
        let expected_bin = ctx.binary_path.unwrap();
        let expected_spool = expected_bin.parent().unwrap().join("spool");
        assert!(restored.contains(&expected_spool.to_string_lossy().to_string()));
    }
}
