//! Codex CLI installer adapter.
//!
//! ## What we touch
//! - `~/.codex/config.json`    — `mcpServers.spool` entry
//! - `~/.codex/hooks.json`     — hook entries for session_start,
//!   post_tool_use, session_end pointing to our hook scripts
//! - `~/.codex/hooks/spool-*.sh` — three hook scripts
//!
//! ## Codex hook format
//! Codex uses a flat `hooks.json` with the shape:
//! ```json
//! {
//!   "hooks": {
//!     "session_start": [{"command": "...", "timeout_ms": 5000}],
//!     "post_tool_use": [{"command": "...", "timeout_ms": 5000}],
//!     "session_end":   [{"command": "...", "timeout_ms": 10000}]
//!   }
//! }
//! ```
//!
//! Unlike Claude Code's `settings.json` (which wraps each entry in a
//! `{matcher, hooks: [...]}` envelope), Codex entries are flat objects
//! with `command` + `timeout_ms`.
//!
//! ## Binary resolution
//! Same strategy as Claude: default to `~/.cargo/bin/spool-mcp`, with
//! `--binary-path` as an escape hatch.

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

use super::shared::{
    self, McpMergeOutcome, McpRemoveOutcome, build_mcp_entry, merge_mcp_entry, remove_mcp_entry,
};
use super::templates::{self, codex_hook_specs};
use super::{
    ClientId, DiagnosticCheck, DiagnosticReport, DiagnosticStatus, InstallContext, InstallReport,
    InstallStatus, Installer, UninstallReport, UninstallStatus, UpdateReport, UpdateStatus,
};

/// Default timeout for session_start and post_tool_use hooks (ms).
const HOOK_TIMEOUT_DEFAULT_MS: u64 = 5000;
/// Longer timeout for session_end (distill pipeline may take longer).
const HOOK_TIMEOUT_SESSION_END_MS: u64 = 10000;

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

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

    #[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 codex_dir(&self) -> Result<PathBuf> {
        Ok(self.home()?.join(".codex"))
    }

    fn config_json_path(&self) -> Result<PathBuf> {
        Ok(self.codex_dir()?.join("config.json"))
    }

    fn hooks_json_path(&self) -> Result<PathBuf> {
        Ok(self.codex_dir()?.join("hooks.json"))
    }

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

    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 CodexInstaller {
    fn default() -> Self {
        Self::new()
    }
}

impl Installer for CodexInstaller {
    fn id(&self) -> ClientId {
        ClientId::Codex
    }

    fn detect(&self) -> Result<bool> {
        let codex_dir = self.codex_dir()?;
        Ok(codex_dir.exists())
    }

    fn install(&self, ctx: &InstallContext) -> Result<InstallReport> {
        let binary_path = self.resolve_binary_path(ctx)?;
        self.validate_inputs(ctx, &binary_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 ~/.codex/config.json ───────────
        let config_json = self.config_json_path()?;
        let mut config_doc = shared::read_json_or_empty(&config_json)?;
        let desired = build_mcp_entry(&binary_path, &ctx.config_path);
        let mcp_outcome = merge_mcp_entry(&mut config_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 {}.",
                    config_json.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
            }
        };

        if matches!(mcp_status, MergeStatus::Conflict) {
            return Ok(InstallReport {
                client: ClientId::Codex.as_str().to_string(),
                binary_path,
                config_path: ctx.config_path.clone(),
                status: InstallStatus::Conflict,
                planned_writes,
                backups,
                notes,
            });
        }

        // ── 2. hooks.json entries ─────────────────────────────────
        let hooks_json = self.hooks_json_path()?;
        let mut hooks_doc = shared::read_json_or_empty(&hooks_json)?;
        let hooks_dir = self.hooks_dir()?;
        let hook_specs = codex_hook_specs();
        let mut hooks_json_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();
            let timeout = timeout_for_event(spec.hook_event);
            match upsert_codex_hook_entry(&mut hooks_doc, spec.hook_event, &target_str, timeout) {
                CodexHookOutcome::Appended => hooks_json_changed = true,
                CodexHookOutcome::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 {
                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. dry-run preview ────────────────────────────────────
        let any_change =
            matches!(mcp_status, MergeStatus::Changed) || hooks_json_changed || hook_files_changed;

        if ctx.dry_run {
            if matches!(mcp_status, MergeStatus::Changed) {
                planned_writes.push(config_json.clone());
            }
            if hooks_json_changed {
                planned_writes.push(hooks_json.clone());
            }
            for plan in &hook_files {
                if !file_has_exact_contents(&plan.target_path, &plan.rendered) {
                    planned_writes.push(plan.target_path.clone());
                }
            }
            return Ok(InstallReport {
                client: ClientId::Codex.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,
            });
        }

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

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

        // ── 7. 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());
        }

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

        Ok(InstallReport {
            client: ClientId::Codex.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 report = self.install(ctx)?;
        let status = match report.status {
            InstallStatus::Installed => UpdateStatus::Updated,
            InstallStatus::Unchanged => UpdateStatus::Unchanged,
            InstallStatus::DryRun => UpdateStatus::DryRun,
            InstallStatus::Conflict => UpdateStatus::NotInstalled,
        };
        Ok(UpdateReport {
            client: report.client,
            status,
            updated_paths: report.planned_writes,
            notes: report.notes,
        })
    }

    fn uninstall(&self, ctx: &InstallContext) -> Result<UninstallReport> {
        let config_json = self.config_json_path()?;
        let hooks_json = self.hooks_json_path()?;
        let hooks_dir = self.hooks_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 config_doc_after_purge = if config_json.exists() {
            let mut doc = shared::read_json_or_empty(&config_json)?;
            match remove_mcp_entry(&mut doc, "spool") {
                McpRemoveOutcome::Removed => {
                    any_change = true;
                    Some(doc)
                }
                McpRemoveOutcome::NotPresent => None,
            }
        } else {
            None
        };

        // ── 2. hooks.json purge ───────────────────────────────────
        let hooks_doc_after_purge = if hooks_json.exists() {
            let mut doc = shared::read_json_or_empty(&hooks_json)?;
            let removed = purge_codex_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> = codex_hook_specs()
            .iter()
            .map(|s| hooks_dir.join(s.file_name))
            .filter(|p| p.exists())
            .collect();
        if !hook_files.is_empty() {
            any_change = true;
        }

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

        if ctx.dry_run {
            if config_doc_after_purge.is_some() {
                removed_paths.push(config_json);
            }
            if hooks_doc_after_purge.is_some() {
                removed_paths.push(hooks_json);
            }
            removed_paths.extend(hook_files);
            return Ok(UninstallReport {
                client: ClientId::Codex.as_str().to_string(),
                status: UninstallStatus::DryRun,
                removed_paths,
                backups,
                notes,
            });
        }

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

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

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

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

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

        // Codex dir presence
        let codex_dir = self.codex_dir()?;
        checks.push(DiagnosticCheck {
            name: "codex_dir_exists".into(),
            status: if codex_dir.exists() {
                DiagnosticStatus::Ok
            } else {
                DiagnosticStatus::Warn
            },
            detail: format!("{}", codex_dir.display()),
        });

        // mcpServers.spool registration
        let config_json = self.config_json_path()?;
        let registration_status = if config_json.exists() {
            let doc = shared::read_json_or_empty(&config_json)?;
            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)?;
        checks.push(DiagnosticCheck {
            name: "spool_mcp_binary".into(),
            status: if binary_path.exists() {
                DiagnosticStatus::Ok
            } else {
                DiagnosticStatus::Fail
            },
            detail: format!("{}", binary_path.display()),
        });

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

        // hooks.json has spool entries
        let hooks_json = self.hooks_json_path()?;
        let hooks_registered_status = if hooks_json.exists() {
            let doc = shared::read_json_or_empty(&hooks_json)?;
            if has_any_spool_hook_entry(&doc) {
                DiagnosticStatus::Ok
            } else {
                DiagnosticStatus::Warn
            }
        } else {
            DiagnosticStatus::Warn
        };
        checks.push(DiagnosticCheck {
            name: "codex_hooks_registered".into(),
            status: hooks_registered_status,
            detail: format!("{}", hooks_json.display()),
        });

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

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

// ─────────────────────────────────────────────────────────────────────
// internal helpers
// ─────────────────────────────────────────────────────────────────────

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

struct HookFilePlan {
    target_path: PathBuf,
    rendered: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum CodexHookOutcome {
    Appended,
    Unchanged,
}

fn timeout_for_event(event: &str) -> u64 {
    match event {
        "session_end" => HOOK_TIMEOUT_SESSION_END_MS,
        _ => HOOK_TIMEOUT_DEFAULT_MS,
    }
}

/// Ensure `doc.hooks.{event}` contains an entry with `command_path`.
/// Codex hook entries are flat: `{"command": "...", "timeout_ms": N}`.
fn upsert_codex_hook_entry(
    doc: &mut Value,
    event: &str,
    command_path: &str,
    timeout_ms: u64,
) -> CodexHookOutcome {
    let root = match doc.as_object_mut() {
        Some(obj) => obj,
        None => {
            *doc = json!({});
            doc.as_object_mut().expect("just inserted")
        }
    };
    let hooks = root.entry("hooks").or_insert_with(|| json!({}));
    if !hooks.is_object() {
        *hooks = json!({});
    }
    let hooks_obj = hooks.as_object_mut().expect("hooks must be object");
    let entries = hooks_obj
        .entry(event)
        .or_insert_with(|| Value::Array(Vec::new()));
    if !entries.is_array() {
        *entries = Value::Array(Vec::new());
    }
    let array = entries.as_array_mut().expect("entries must be array");

    for entry in array.iter() {
        if entry.get("command").and_then(Value::as_str) == Some(command_path) {
            return CodexHookOutcome::Unchanged;
        }
    }

    array.push(json!({
        "command": command_path,
        "timeout_ms": timeout_ms,
    }));
    CodexHookOutcome::Appended
}

/// Remove entries from `doc.hooks.{event}` whose `command` contains
/// `marker_substring`. Returns the number of entries removed.
fn purge_codex_hook_entries(doc: &mut Value, marker_substring: &str) -> usize {
    let mut removed = 0usize;
    let Some(root) = doc.as_object_mut() else {
        return 0;
    };
    let Some(hooks) = root.get_mut("hooks").and_then(|v| v.as_object_mut()) else {
        return 0;
    };
    for (_event, entries) in hooks.iter_mut() {
        let Some(array) = entries.as_array_mut() else {
            continue;
        };
        let before = array.len();
        array.retain(|entry| {
            !entry
                .get("command")
                .and_then(Value::as_str)
                .is_some_and(|c| c.contains(marker_substring))
        });
        removed += before - array.len();
    }
    hooks.retain(|_event, entries| !entries.as_array().is_some_and(|a| a.is_empty()));
    if hooks.is_empty() {
        root.remove("hooks");
    }
    removed
}

fn has_any_spool_hook_entry(doc: &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 {
            if entry
                .get("command")
                .and_then(Value::as_str)
                .is_some_and(|c| c.contains("spool-"))
            {
                return true;
            }
        }
    }
    false
}

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,
    }
}

#[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<()> {
    Ok(())
}

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

    fn setup() -> (tempfile::TempDir, CodexInstaller, InstallContext) {
        let temp = tempdir().unwrap();
        let home = temp.path().to_path_buf();
        let installer = CodexInstaller::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_codex_dir() {
        let temp = tempdir().unwrap();
        let installer = CodexInstaller::with_home_root(temp.path().to_path_buf());
        assert!(!installer.detect().unwrap());
    }

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

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

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

        // hooks.json written with 3 events
        let hooks_json = temp.path().join(".codex").join("hooks.json");
        assert!(hooks_json.exists());
        let hooks_doc: Value =
            serde_json::from_str(&fs::read_to_string(&hooks_json).unwrap()).unwrap();
        assert!(hooks_doc["hooks"]["session_start"].is_array());
        assert!(hooks_doc["hooks"]["post_tool_use"].is_array());
        assert!(hooks_doc["hooks"]["session_end"].is_array());

        // session_end has longer timeout
        let end_entry = &hooks_doc["hooks"]["session_end"][0];
        assert_eq!(end_entry["timeout_ms"], HOOK_TIMEOUT_SESSION_END_MS);

        // hook script files exist
        for spec in codex_hook_specs() {
            let p = temp
                .path()
                .join(".codex")
                .join("hooks")
                .join(spec.file_name);
            assert!(p.exists(), "{} missing", p.display());
        }
    }

    #[test]
    fn install_dry_run_does_not_write() {
        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());
        assert!(!temp.path().join(".codex").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_marks_conflict_when_existing_mcp_entry_differs() {
        let (temp, installer, ctx) = setup();
        let codex_dir = temp.path().join(".codex");
        fs::create_dir_all(&codex_dir).unwrap();
        let preexisting = json!({
            "mcpServers": {
                "spool": {"type": "stdio", "command": "/old/path", "args": []}
            }
        });
        fs::write(
            codex_dir.join("config.json"),
            serde_json::to_string_pretty(&preexisting).unwrap(),
        )
        .unwrap();

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

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

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

        // hook scripts gone
        for spec in codex_hook_specs() {
            let p = temp
                .path()
                .join(".codex")
                .join("hooks")
                .join(spec.file_name);
            assert!(!p.exists(), "{} should be removed", p.display());
        }

        // mcpServers.spool gone
        let config_json = temp.path().join(".codex").join("config.json");
        let doc: Value = serde_json::from_str(&fs::read_to_string(&config_json).unwrap()).unwrap();
        assert!(doc["mcpServers"].get("spool").is_none());
    }

    #[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_preserves_other_hooks() {
        let (temp, installer, ctx) = setup();
        // Pre-seed another tool's hook
        let codex_dir = temp.path().join(".codex");
        fs::create_dir_all(&codex_dir).unwrap();
        let preexisting = json!({
            "hooks": {
                "session_start": [
                    {"command": "/other/tool", "timeout_ms": 3000}
                ]
            }
        });
        fs::write(
            codex_dir.join("hooks.json"),
            serde_json::to_string_pretty(&preexisting).unwrap(),
        )
        .unwrap();

        let _ = installer.install(&ctx).unwrap();
        let _ = installer.uninstall(&ctx).unwrap();

        let hooks_doc: Value =
            serde_json::from_str(&fs::read_to_string(codex_dir.join("hooks.json")).unwrap())
                .unwrap();
        let entries = hooks_doc["hooks"]["session_start"].as_array().unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0]["command"], "/other/tool");
    }

    #[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 [
            "codex_dir_exists",
            "mcp_servers_spool_registered",
            "spool_mcp_binary",
            "spool_config_readable",
            "codex_hooks_registered",
            "spool_hook_scripts",
        ] {
            assert!(names.contains(&expected), "missing check {}", expected);
        }
    }

    #[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(".codex")
            .join("hooks")
            .join("spool-session_start.sh");
        let perms = fs::metadata(&session).unwrap().permissions();
        assert_eq!(perms.mode() & 0o777, 0o755);
    }

    #[test]
    fn codex_hook_entries_use_flat_format() {
        let mut doc = json!({});
        upsert_codex_hook_entry(
            &mut doc,
            "session_start",
            "/abs/spool-session_start.sh",
            5000,
        );
        let entries = doc["hooks"]["session_start"].as_array().unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0]["command"], "/abs/spool-session_start.sh");
        assert_eq!(entries[0]["timeout_ms"], 5000);
        // No "matcher" or nested "hooks" array — flat format.
        assert!(entries[0].get("matcher").is_none());
    }

    #[test]
    fn upsert_codex_hook_unchanged_on_repeat() {
        let mut doc = json!({});
        upsert_codex_hook_entry(&mut doc, "session_start", "/abs/hook.sh", 5000);
        let outcome = upsert_codex_hook_entry(&mut doc, "session_start", "/abs/hook.sh", 5000);
        assert_eq!(outcome, CodexHookOutcome::Unchanged);
        assert_eq!(doc["hooks"]["session_start"].as_array().unwrap().len(), 1);
    }

    #[test]
    fn purge_codex_hook_entries_removes_spool_only() {
        let mut doc = json!({
            "hooks": {
                "session_start": [
                    {"command": "/other/tool", "timeout_ms": 3000},
                    {"command": "/abs/.codex/hooks/spool-session_start.sh", "timeout_ms": 5000}
                ],
                "session_end": [
                    {"command": "/abs/spool-session_end.sh", "timeout_ms": 10000}
                ]
            }
        });
        let removed = purge_codex_hook_entries(&mut doc, "spool-");
        assert_eq!(removed, 2);
        let entries = doc["hooks"]["session_start"].as_array().unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0]["command"], "/other/tool");
        // session_end is now empty → swept.
        assert!(doc["hooks"].get("session_end").is_none());
    }
}
