//! Cross-installer helpers.
//!
//! ## Responsibilities
//! - Resolve `~` and well-known directories without pulling external
//!   crates (`std::env::var("HOME")`).
//! - Atomic JSON read/modify/write with timestamped backup files.
//! - Merge a single mcpServers entry while preserving sibling clients
//!   (proxyman, pencil, …) untouched.
//!
//! ## Atomicity model
//! - We always read the full JSON document, mutate in-memory, then write
//!   a sibling `<file>.spool-tmp` and `rename` to the destination.
//! - We snapshot the previous bytes to `<file>.bak-spool-<unix-ts>`
//!   BEFORE writing — never overwriting an existing backup. The backup
//!   is created at most once per `install` call.
//!
//! ## Why not `serde_json::Value::as_object_mut` directly?
//! We keep the surrounding object as `serde_json::Value` to preserve
//! every key the user (or another tool) may have set. We never re-emit
//! the document with `to_string` — we use `serde_json::to_string_pretty`
//! to keep diffs readable.

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

/// Returns the absolute home directory by reading `$HOME`.
///
/// We deliberately avoid `dirs` / `home` crates: spool already keeps
/// dependencies tight (see Cargo.toml), and the only platforms we
/// support set `$HOME` reliably (macOS, Linux). On Windows we'd need
/// `USERPROFILE` — out of scope for the R1 MVP.
pub fn home_dir() -> Result<PathBuf> {
    crate::support::home_dir()
        .context("cannot locate user home directory ($HOME or %USERPROFILE% not set)")
}

/// Path to the Claude Code top-level config (`~/.claude.json`).
pub fn claude_config_path() -> Result<PathBuf> {
    Ok(home_dir()?.join(".claude.json"))
}

/// Path to the Claude Code settings file (`~/.claude/settings.json`).
pub fn claude_settings_path() -> Result<PathBuf> {
    Ok(home_dir()?.join(".claude").join("settings.json"))
}

/// Default spool-mcp install path under `~/.cargo/bin/`.
pub fn default_cargo_binary_path() -> Result<PathBuf> {
    Ok(home_dir()?.join(".cargo").join("bin").join("spool-mcp"))
}

pub fn ensure_config_exists(config_path: &Path) -> Result<()> {
    if config_path.exists() {
        return Ok(());
    }
    if let Some(parent) = config_path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let default_config = r#"[vault]
root = ""

[output]
default_format = "prompt"
max_chars = 12000
max_notes = 8
max_lifecycle = 5
"#;
    std::fs::write(config_path, default_config)?;
    Ok(())
}

/// Read a JSON document from disk; missing files yield an empty object.
pub fn read_json_or_empty(path: &Path) -> Result<Value> {
    if !path.exists() {
        return Ok(Value::Object(Map::new()));
    }
    let raw = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read {}", path.display()))?;
    if raw.trim().is_empty() {
        return Ok(Value::Object(Map::new()));
    }
    serde_json::from_str::<Value>(&raw)
        .with_context(|| format!("failed to parse JSON at {}", path.display()))
}

/// Snapshot `path` to `<path>.bak-spool-<ts>`. Returns the backup path
/// or `None` when the source file does not exist (fresh install).
pub fn backup_file(path: &Path) -> Result<Option<PathBuf>> {
    if !path.exists() {
        return Ok(None);
    }
    let ts = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let backup = path.with_file_name(format!(
        "{}.bak-spool-{}",
        path.file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("unknown"),
        ts
    ));
    std::fs::copy(path, &backup).with_context(|| {
        format!(
            "failed to back up {} to {}",
            path.display(),
            backup.display()
        )
    })?;
    Ok(Some(backup))
}

/// Write JSON to `path` atomically: write to `<path>.spool-tmp` then
/// rename. The parent directory MUST exist.
pub fn write_json_atomic(path: &Path, value: &Value) -> Result<()> {
    if let Some(parent) = path.parent()
        && !parent.exists()
    {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("failed to create parent directory {}", parent.display()))?;
    }
    let tmp = path.with_extension("spool-tmp");
    let body = serde_json::to_string_pretty(value).context("failed to serialize JSON")?;
    std::fs::write(&tmp, body)
        .with_context(|| format!("failed to write temp file {}", tmp.display()))?;
    std::fs::rename(&tmp, path)
        .with_context(|| format!("failed to atomically replace {}", path.display()))?;
    Ok(())
}

/// Build the canonical spool mcpServers entry.
///
/// We keep this as a free function so installers can render it for
/// dry-run previews without performing the merge.
pub fn build_mcp_entry(binary_path: &Path, config_path: &Path) -> Value {
    json!({
        "type": "stdio",
        "command": path_to_string(binary_path),
        "args": ["--config", path_to_string(config_path)],
    })
}

fn path_to_string(p: &Path) -> String {
    p.to_string_lossy().into_owned()
}

/// Outcome of [`merge_mcp_entry`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpMergeOutcome {
    /// Entry did not exist — was added.
    Inserted,
    /// Entry exists with identical command/args — no change required.
    Unchanged,
    /// Entry exists but differs from desired. With `force=true` it is
    /// overwritten; otherwise the document is left unchanged.
    Conflict { force_applied: bool },
}

/// Merge `desired` into `doc.mcpServers.{client_id}`.
///
/// `doc` MUST be an object (or convertible to one). Returns the merge
/// outcome and whether the doc was mutated.
pub fn merge_mcp_entry(
    doc: &mut Value,
    client_id: &str,
    desired: Value,
    force: bool,
) -> McpMergeOutcome {
    let root = match doc.as_object_mut() {
        Some(obj) => obj,
        None => {
            *doc = Value::Object(Map::new());
            doc.as_object_mut().expect("just inserted")
        }
    };
    let servers = root
        .entry("mcpServers")
        .or_insert_with(|| Value::Object(Map::new()))
        .as_object_mut()
        .expect("mcpServers must be object");

    match servers.get(client_id) {
        None => {
            servers.insert(client_id.to_string(), desired);
            McpMergeOutcome::Inserted
        }
        Some(existing) if existing == &desired => McpMergeOutcome::Unchanged,
        Some(_) if force => {
            servers.insert(client_id.to_string(), desired);
            McpMergeOutcome::Conflict {
                force_applied: true,
            }
        }
        Some(_) => McpMergeOutcome::Conflict {
            force_applied: false,
        },
    }
}

/// Outcome of [`remove_mcp_entry`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpRemoveOutcome {
    Removed,
    NotPresent,
}

/// Drop `doc.mcpServers.{client_id}` if it exists.
pub fn remove_mcp_entry(doc: &mut Value, client_id: &str) -> McpRemoveOutcome {
    let Some(root) = doc.as_object_mut() else {
        return McpRemoveOutcome::NotPresent;
    };
    let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) else {
        return McpRemoveOutcome::NotPresent;
    };
    if servers.remove(client_id).is_some() {
        McpRemoveOutcome::Removed
    } else {
        McpRemoveOutcome::NotPresent
    }
}

// ─────────────────────────────────────────────────────────────────────
// Claude Code settings.json `hooks` merge helpers.
//
// settings.json `hooks` shape (see ~/.claude/settings.json):
// {
//   "hooks": {
//     "SessionStart": [
//       { "matcher": "", "hooks": [{ "type": "command", "command": "..." }] },
//       ...
//     ],
//     "PreCompact": [...],
//     ...
//   }
// }
//
// Strategy: we APPEND a spool-specific entry instead of merging into an
// existing entry's `hooks` array, so removing spool is a clean filter
// without risking damage to sibling tools (e.g. `bd prime`, Trellis).
// ─────────────────────────────────────────────────────────────────────

/// Result of [`upsert_settings_hook_command`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SettingsHookOutcome {
    /// New entry appended.
    Appended,
    /// Entry already present with the same command — no change.
    Unchanged,
}

/// Ensure `doc.hooks.{event}` contains an entry referencing
/// `command_path`. Returns whether the document was mutated.
///
/// We never edit existing entries in place; we either find a matching
/// spool entry or append a new one. Sibling entries (other tools) are
/// untouched.
pub fn upsert_settings_hook_command(
    doc: &mut Value,
    event: &str,
    command_path: &str,
) -> SettingsHookOutcome {
    let root = match doc.as_object_mut() {
        Some(obj) => obj,
        None => {
            *doc = Value::Object(Map::new());
            doc.as_object_mut().expect("just inserted")
        }
    };
    let hooks = root
        .entry("hooks")
        .or_insert_with(|| Value::Object(Map::new()));
    if !hooks.is_object() {
        *hooks = Value::Object(Map::new());
    }
    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");

    // Look for an existing spool command pointing to the same path.
    for entry in array.iter() {
        if entry_contains_command(entry, command_path) {
            return SettingsHookOutcome::Unchanged;
        }
    }

    array.push(json!({
        "matcher": "",
        "hooks": [{
            "type": "command",
            "command": command_path,
        }]
    }));
    SettingsHookOutcome::Appended
}

/// Remove every spool-* entry from `doc.hooks.{event}`. An entry is
/// considered "spool-managed" when any of its inner `hooks[].command`
/// strings contains the marker `marker_substring` (typically
/// `spool-`). We drop the entire wrapper entry to keep the structure
/// tidy; sibling tools' entries are not touched.
pub fn purge_settings_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_contains_command_substring(entry, marker_substring));
        removed += before - array.len();
    }
    // Sweep empty event arrays for tidiness.
    hooks.retain(|_event, entries| !entries.as_array().is_some_and(|a| a.is_empty()));
    if hooks.is_empty() {
        root.remove("hooks");
    }
    removed
}

fn entry_contains_command(entry: &Value, command_path: &str) -> bool {
    entry
        .get("hooks")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter().any(|h| {
                h.get("command")
                    .and_then(|c| c.as_str())
                    .is_some_and(|c| c == command_path)
            })
        })
        .unwrap_or(false)
}

fn entry_contains_command_substring(entry: &Value, needle: &str) -> bool {
    entry
        .get("hooks")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter().any(|h| {
                h.get("command")
                    .and_then(|c| c.as_str())
                    .is_some_and(|c| c.contains(needle))
            })
        })
        .unwrap_or(false)
}

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

    #[test]
    fn read_json_or_empty_returns_object_when_missing() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("absent.json");
        let v = read_json_or_empty(&path).unwrap();
        assert!(v.as_object().unwrap().is_empty());
    }

    #[test]
    fn read_json_or_empty_returns_object_when_blank() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("blank.json");
        fs::write(&path, "   \n").unwrap();
        let v = read_json_or_empty(&path).unwrap();
        assert!(v.as_object().unwrap().is_empty());
    }

    #[test]
    fn backup_file_skips_missing_source() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("nope.json");
        let backup = backup_file(&path).unwrap();
        assert!(backup.is_none());
    }

    #[test]
    fn backup_file_creates_unique_snapshot() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("real.json");
        fs::write(&path, "{}").unwrap();
        let backup = backup_file(&path).unwrap().expect("backup expected");
        assert!(backup.exists());
        assert_eq!(fs::read_to_string(&backup).unwrap(), "{}");
    }

    #[test]
    fn write_json_atomic_creates_parent_dirs() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("nested").join("config.json");
        write_json_atomic(&path, &json!({"k": 1})).unwrap();
        assert!(path.exists());
        let raw = fs::read_to_string(&path).unwrap();
        assert!(raw.contains("\"k\""));
    }

    #[test]
    fn merge_mcp_entry_inserts_when_absent() {
        let mut doc = json!({"unrelated": true});
        let entry = json!({"command": "/bin/foo"});
        let outcome = merge_mcp_entry(&mut doc, "claude", entry.clone(), false);
        assert_eq!(outcome, McpMergeOutcome::Inserted);
        assert_eq!(doc["mcpServers"]["claude"], entry);
        assert_eq!(doc["unrelated"], json!(true));
    }

    #[test]
    fn merge_mcp_entry_unchanged_when_identical() {
        let entry = json!({"command": "/bin/foo"});
        let mut doc = json!({"mcpServers": {"claude": entry.clone()}});
        let outcome = merge_mcp_entry(&mut doc, "claude", entry, false);
        assert_eq!(outcome, McpMergeOutcome::Unchanged);
    }

    #[test]
    fn merge_mcp_entry_conflict_without_force_keeps_existing() {
        let existing = json!({"command": "/old"});
        let mut doc = json!({"mcpServers": {"claude": existing.clone()}});
        let desired = json!({"command": "/new"});
        let outcome = merge_mcp_entry(&mut doc, "claude", desired.clone(), false);
        assert_eq!(
            outcome,
            McpMergeOutcome::Conflict {
                force_applied: false
            }
        );
        assert_eq!(doc["mcpServers"]["claude"], existing);
    }

    #[test]
    fn merge_mcp_entry_conflict_with_force_overwrites() {
        let mut doc = json!({"mcpServers": {"claude": {"command": "/old"}}});
        let desired = json!({"command": "/new"});
        let outcome = merge_mcp_entry(&mut doc, "claude", desired.clone(), true);
        assert_eq!(
            outcome,
            McpMergeOutcome::Conflict {
                force_applied: true
            }
        );
        assert_eq!(doc["mcpServers"]["claude"], desired);
    }

    #[test]
    fn merge_mcp_entry_preserves_sibling_clients() {
        let mut doc = json!({
            "mcpServers": {
                "proxyman": {"command": "/bin/proxyman"},
                "pencil": {"command": "/bin/pencil"}
            }
        });
        let entry = json!({"command": "/bin/spool"});
        merge_mcp_entry(&mut doc, "claude", entry.clone(), false);
        assert_eq!(doc["mcpServers"]["proxyman"]["command"], "/bin/proxyman");
        assert_eq!(doc["mcpServers"]["pencil"]["command"], "/bin/pencil");
        assert_eq!(doc["mcpServers"]["claude"], entry);
    }

    #[test]
    fn remove_mcp_entry_drops_when_present() {
        let mut doc = json!({"mcpServers": {"claude": {"command": "/x"}, "pencil": {}}});
        let outcome = remove_mcp_entry(&mut doc, "claude");
        assert_eq!(outcome, McpRemoveOutcome::Removed);
        assert!(
            doc["mcpServers"]
                .as_object()
                .unwrap()
                .contains_key("pencil")
        );
        assert!(
            !doc["mcpServers"]
                .as_object()
                .unwrap()
                .contains_key("claude")
        );
    }

    #[test]
    fn remove_mcp_entry_not_present_when_missing() {
        let mut doc = json!({"mcpServers": {"pencil": {}}});
        let outcome = remove_mcp_entry(&mut doc, "claude");
        assert_eq!(outcome, McpRemoveOutcome::NotPresent);
    }

    #[test]
    fn build_mcp_entry_uses_absolute_paths() {
        let entry = build_mcp_entry(Path::new("/abs/spool-mcp"), Path::new("/abs/config.toml"));
        assert_eq!(entry["type"], "stdio");
        assert_eq!(entry["command"], "/abs/spool-mcp");
        assert_eq!(entry["args"], json!(["--config", "/abs/config.toml"]));
    }

    #[test]
    fn upsert_settings_hook_appends_when_absent() {
        let mut doc = json!({});
        let outcome = upsert_settings_hook_command(
            &mut doc,
            "SessionStart",
            "/abs/.claude/hooks/spool-SessionStart.sh",
        );
        assert_eq!(outcome, SettingsHookOutcome::Appended);
        let entries = doc["hooks"]["SessionStart"].as_array().unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0]["matcher"], "");
        assert_eq!(
            entries[0]["hooks"][0]["command"],
            "/abs/.claude/hooks/spool-SessionStart.sh"
        );
        assert_eq!(entries[0]["hooks"][0]["type"], "command");
    }

    #[test]
    fn upsert_settings_hook_preserves_existing_siblings() {
        let mut doc = json!({
            "hooks": {
                "SessionStart": [
                    {"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]}
                ]
            }
        });
        let outcome =
            upsert_settings_hook_command(&mut doc, "SessionStart", "/abs/spool-SessionStart.sh");
        assert_eq!(outcome, SettingsHookOutcome::Appended);
        let entries = doc["hooks"]["SessionStart"].as_array().unwrap();
        assert_eq!(entries.len(), 2);
        assert_eq!(entries[0]["hooks"][0]["command"], "bd prime");
        assert_eq!(
            entries[1]["hooks"][0]["command"],
            "/abs/spool-SessionStart.sh"
        );
    }

    #[test]
    fn upsert_settings_hook_unchanged_on_repeat() {
        let mut doc = json!({});
        let _ = upsert_settings_hook_command(&mut doc, "Stop", "/abs/spool-Stop.sh");
        let outcome = upsert_settings_hook_command(&mut doc, "Stop", "/abs/spool-Stop.sh");
        assert_eq!(outcome, SettingsHookOutcome::Unchanged);
        assert_eq!(doc["hooks"]["Stop"].as_array().unwrap().len(), 1);
    }

    #[test]
    fn purge_settings_hook_drops_spool_entries_only() {
        let mut doc = json!({
            "hooks": {
                "SessionStart": [
                    {"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]},
                    {"matcher": "", "hooks": [{"type": "command", "command": "/abs/.claude/hooks/spool-SessionStart.sh"}]}
                ],
                "Stop": [
                    {"matcher": "", "hooks": [{"type": "command", "command": "/abs/spool-Stop.sh"}]}
                ]
            }
        });
        let removed = purge_settings_hook_entries(&mut doc, "spool-");
        assert_eq!(removed, 2);
        let session_entries = doc["hooks"]["SessionStart"].as_array().unwrap();
        assert_eq!(session_entries.len(), 1);
        assert_eq!(session_entries[0]["hooks"][0]["command"], "bd prime");
        // Stop event is now empty → swept.
        assert!(doc["hooks"].get("Stop").is_none());
    }

    #[test]
    fn purge_settings_hook_removes_empty_hooks_root() {
        let mut doc = json!({
            "hooks": {
                "Stop": [
                    {"matcher": "", "hooks": [{"type": "command", "command": "/abs/spool-Stop.sh"}]}
                ]
            },
            "other": true
        });
        let removed = purge_settings_hook_entries(&mut doc, "spool-");
        assert_eq!(removed, 1);
        assert!(doc.get("hooks").is_none());
        assert_eq!(doc["other"], true);
    }

    #[test]
    fn purge_settings_hook_no_op_when_marker_absent() {
        let mut doc = json!({
            "hooks": {
                "SessionStart": [
                    {"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]}
                ]
            }
        });
        let removed = purge_settings_hook_entries(&mut doc, "spool-");
        assert_eq!(removed, 0);
        assert_eq!(doc["hooks"]["SessionStart"].as_array().unwrap().len(), 1);
    }
}
