//! Bootstrap helpers for IronClaw.
//!
//! The only setting that truly needs disk persistence before the database is
//! available is `DATABASE_URL` (chicken-and-egg: can't connect to DB without
//! it). Everything else is auto-detected or read from env vars.
//!
//! File: `~/.ironclaw/.env` (standard dotenvy format)

use std::path::PathBuf;
use std::sync::LazyLock;

const IRONCLAW_BASE_DIR_ENV: &str = "IRONCLAW_BASE_DIR";

/// Lazily computed IronClaw base directory, cached for the lifetime of the process.
static IRONCLAW_BASE_DIR: LazyLock<PathBuf> = LazyLock::new(compute_ironclaw_base_dir);

/// Compute the IronClaw base directory from environment.
///
/// This is the underlying implementation used by both the public
/// `ironclaw_base_dir()` function (which caches the result) and tests
/// (which need to verify different configurations).
pub fn compute_ironclaw_base_dir() -> PathBuf {
    std::env::var(IRONCLAW_BASE_DIR_ENV)
        .map(PathBuf::from)
        .map(|path| {
            if path.as_os_str().is_empty() {
                default_base_dir()
            } else if !path.is_absolute() {
                eprintln!(
                    "Warning: IRONCLAW_BASE_DIR is a relative path '{}', resolved against current directory",
                    path.display()
                );
                path
            } else {
                path
            }
        })
        .unwrap_or_else(|_| default_base_dir())
}

/// Get the default IronClaw base directory (~/.ironclaw).
///
/// Logs a warning if the home directory cannot be determined and falls back to
/// the current directory.
fn default_base_dir() -> PathBuf {
    if let Some(home) = dirs::home_dir() {
        home.join(".ironclaw")
    } else {
        eprintln!("Warning: Could not determine home directory, using current directory");
        std::env::current_dir()
            .unwrap_or_else(|_| PathBuf::from("/tmp"))
            .join(".ironclaw")
    }
}

/// Get the IronClaw base directory.
///
/// Override with `IRONCLAW_BASE_DIR` environment variable.
/// Defaults to `~/.ironclaw` (or `./.ironclaw` if home directory cannot be determined).
///
/// Thread-safe: the value is computed once and cached in a `LazyLock`.
///
/// # Environment Variable Behavior
/// - If `IRONCLAW_BASE_DIR` is set to a non-empty path, that path is used.
/// - If `IRONCLAW_BASE_DIR` is set to an empty string, it is treated as unset.
/// - If `IRONCLAW_BASE_DIR` contains null bytes, a warning is printed and the default is used.
/// - If the home directory cannot be determined, a warning is printed and the current directory is used.
///
/// # Returns
/// A `PathBuf` pointing to the base directory. The path is not validated
/// for existence.
pub fn ironclaw_base_dir() -> PathBuf {
    IRONCLAW_BASE_DIR.clone()
}

/// Path to the IronClaw-specific `.env` file: `~/.ironclaw/.env`.
pub fn ironclaw_env_path() -> PathBuf {
    ironclaw_base_dir().join(".env")
}

/// Load env vars from `~/.ironclaw/.env` (in addition to the standard `.env`).
///
/// Call this **after** `dotenvy::dotenv()` so that the standard `./.env`
/// takes priority over `~/.ironclaw/.env`. dotenvy never overwrites
/// existing env vars, so the effective priority is:
///
///   explicit env vars > `./.env` > `~/.ironclaw/.env` > auto-detect
///
/// If `~/.ironclaw/.env` doesn't exist but the legacy `bootstrap.json` does,
/// extracts `DATABASE_URL` from it and writes the `.env` file (one-time
/// upgrade from the old config format).
///
/// After loading the `.env` file, auto-detects the libsql backend: if
/// `DATABASE_BACKEND` is still unset and `~/.ironclaw/ironclaw.db` exists,
/// defaults to `libsql` so cloud instances work out of the box without any
/// manual configuration.
pub fn load_ironclaw_env() {
    let path = ironclaw_env_path();

    if !path.exists() {
        // One-time upgrade: extract DATABASE_URL from legacy bootstrap.json
        migrate_bootstrap_json_to_env(&path);
    }

    if path.exists() {
        let _ = dotenvy::from_path(&path);
    }

    // Auto-detect libsql: if DATABASE_BACKEND is still unset after loading
    // all env files, and the local SQLite DB exists, default to libsql.
    // This avoids the chicken-and-egg problem on cloud instances where no
    // DATABASE_URL is configured but ironclaw.db is already present.
    if std::env::var("DATABASE_BACKEND").is_err() {
        let default_db = dirs::home_dir()
            .unwrap_or_default()
            .join(".ironclaw")
            .join("ironclaw.db");
        if default_db.exists() {
            if tokio::runtime::Handle::try_current().is_ok() {
                // Tokio runtime is active (multi-threaded); std::env::set_var is UB here.
                // Fall back to the thread-safe runtime overlay so the value is always set.
                tracing::warn!(
                    "load_ironclaw_env called with active Tokio runtime; \
                     using runtime env overlay for DATABASE_BACKEND"
                );
                crate::config::set_runtime_env("DATABASE_BACKEND", "libsql");
            } else {
                // SAFETY: No Tokio runtime = no other threads = safe to call set_var.
                unsafe { std::env::set_var("DATABASE_BACKEND", "libsql") };
            }
        }
    }
}

/// If `bootstrap.json` exists, pull `database_url` out of it and write `.env`.
fn migrate_bootstrap_json_to_env(env_path: &std::path::Path) {
    let ironclaw_dir = env_path
        .parent()
        .unwrap_or_else(|| std::path::Path::new("."));
    let bootstrap_path = ironclaw_dir.join("bootstrap.json");

    if !bootstrap_path.exists() {
        return;
    }

    let content = match std::fs::read_to_string(&bootstrap_path) {
        Ok(c) => c,
        Err(_) => return,
    };

    // Minimal parse: just grab database_url from the JSON
    let parsed: serde_json::Value = match serde_json::from_str(&content) {
        Ok(v) => v,
        Err(_) => return,
    };

    if let Some(url) = parsed.get("database_url").and_then(|v| v.as_str()) {
        if let Some(parent) = env_path.parent()
            && let Err(e) = std::fs::create_dir_all(parent)
        {
            eprintln!("Warning: failed to create {}: {}", parent.display(), e);
            return;
        }
        if let Err(e) = std::fs::write(env_path, format!("DATABASE_URL=\"{}\"\n", url)) {
            eprintln!("Warning: failed to migrate bootstrap.json to .env: {}", e);
            return;
        }
        rename_to_migrated(&bootstrap_path);
        eprintln!(
            "Migrated DATABASE_URL from bootstrap.json to {}",
            env_path.display()
        );
    }
}

/// Write database bootstrap vars to `~/.ironclaw/.env`.
///
/// These settings form the chicken-and-egg layer: they must be available
/// from the filesystem (env vars) BEFORE any database connection, because
/// they determine which database to connect to. Everything else is stored
/// in the database itself.
///
/// Creates the parent directory if it doesn't exist.
/// Values are double-quoted so that `#` (common in URL-encoded passwords)
/// and other shell-special characters are preserved by dotenvy.
pub fn save_bootstrap_env(vars: &[(&str, &str)]) -> std::io::Result<()> {
    save_bootstrap_env_to(&ironclaw_env_path(), vars)
}

/// Write bootstrap vars to an arbitrary path (testable variant).
///
/// Values are double-quoted and escaped so that `#`, `"`, `\` and other
/// shell-special characters are preserved by dotenvy.
pub fn save_bootstrap_env_to(path: &std::path::Path, vars: &[(&str, &str)]) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let mut content = String::new();
    for (key, value) in vars {
        // Escape backslashes and double quotes to prevent env var injection
        // (e.g. a value containing `"\nINJECTED="x` would break out of quotes).
        let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
        content.push_str(&format!("{}=\"{}\"\n", key, escaped));
    }
    std::fs::write(path, &content)?;
    restrict_file_permissions(path)?;
    Ok(())
}

/// Update or add multiple variables in `~/.ironclaw/.env`, preserving existing content.
///
/// Like `upsert_bootstrap_var` but batched — replaces lines for any key in `vars`
/// and preserves all other existing lines. Use this instead of `save_bootstrap_env`
/// when you want to update specific keys without destroying user-added variables.
pub fn upsert_bootstrap_vars(vars: &[(&str, &str)]) -> std::io::Result<()> {
    upsert_bootstrap_vars_to(&ironclaw_env_path(), vars)
}

/// Update or add multiple variables at an arbitrary path (testable variant).
pub fn upsert_bootstrap_vars_to(
    path: &std::path::Path,
    vars: &[(&str, &str)],
) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }

    let keys_being_written: std::collections::HashSet<&str> =
        vars.iter().map(|(k, _)| *k).collect();

    let existing = match std::fs::read_to_string(path) {
        Ok(contents) => contents,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
        Err(e) => return Err(e),
    };

    let mut result = String::new();
    for line in existing.lines() {
        // Extract key from lines matching `KEY=...`
        let is_overwritten = line
            .split_once('=')
            .map(|(k, _)| keys_being_written.contains(k.trim()))
            .unwrap_or(false);

        if !is_overwritten {
            result.push_str(line);
            result.push('\n');
        }
    }

    // Append all new key=value pairs
    for (key, value) in vars {
        let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
        result.push_str(&format!("{}=\"{}\"\n", key, escaped));
    }

    std::fs::write(path, &result)?;
    restrict_file_permissions(path)?;
    Ok(())
}

/// Update or add a single variable in `~/.ironclaw/.env`, preserving existing content.
///
/// Unlike `save_bootstrap_env` (which overwrites the entire file), this
/// reads the current `.env`, replaces the line for `key` if it exists,
/// or appends it otherwise. Use this when writing a single bootstrap var
/// outside the wizard (which manages the full set via `save_bootstrap_env`).
pub fn upsert_bootstrap_var(key: &str, value: &str) -> std::io::Result<()> {
    upsert_bootstrap_var_to(&ironclaw_env_path(), key, value)
}

/// Update or add a single variable at an arbitrary path (testable variant).
pub fn upsert_bootstrap_var_to(
    path: &std::path::Path,
    key: &str,
    value: &str,
) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }

    let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
    let new_line = format!("{}=\"{}\"", key, escaped);
    let prefix = format!("{}=", key);

    let existing = std::fs::read_to_string(path).unwrap_or_default();

    let mut found = false;
    let mut result = String::new();
    for line in existing.lines() {
        if line.starts_with(&prefix) {
            if !found {
                result.push_str(&new_line);
                result.push('\n');
                found = true;
            }
            // Skip duplicate lines for this key
            continue;
        }
        result.push_str(line);
        result.push('\n');
    }

    if !found {
        result.push_str(&new_line);
        result.push('\n');
    }

    std::fs::write(path, result)?;
    restrict_file_permissions(path)?;
    Ok(())
}

/// Set restrictive file permissions (0o600) on Unix systems.
///
/// The `.env` file may contain database credentials and API keys,
/// so it should only be readable by the owner.
fn restrict_file_permissions(_path: &std::path::Path) -> std::io::Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let perms = std::fs::Permissions::from_mode(0o600);
        std::fs::set_permissions(_path, perms)?;
    }
    Ok(())
}

/// Write `DATABASE_URL` to `~/.ironclaw/.env`.
///
/// Convenience wrapper around `save_bootstrap_env` for single-value migration
/// paths. Prefer `save_bootstrap_env` for new code.
pub fn save_database_url(url: &str) -> std::io::Result<()> {
    save_bootstrap_env(&[("DATABASE_URL", url)])
}

/// One-time migration of legacy `~/.ironclaw/settings.json` into the database.
///
/// Only runs when a `settings.json` exists on disk AND the DB has no settings
/// yet. After the wizard writes directly to the DB, this path is only hit by
/// users upgrading from the old disk-only configuration.
///
/// After syncing, renames `settings.json` to `.migrated` so it won't trigger again.
pub async fn migrate_disk_to_db(
    store: &dyn crate::db::Database,
    user_id: &str,
) -> Result<(), MigrationError> {
    let ironclaw_dir = ironclaw_base_dir();
    let legacy_settings_path = ironclaw_dir.join("settings.json");

    if !legacy_settings_path.exists() {
        tracing::debug!("No legacy settings.json found, skipping disk-to-DB migration");
        return Ok(());
    }

    // If DB already has settings, this is not a first boot, the wizard already
    // wrote directly to the DB. Just clean up the stale file.
    let has_settings = store.has_settings(user_id).await.map_err(|e| {
        MigrationError::Database(format!("Failed to check existing settings: {}", e))
    })?;
    if has_settings {
        tracing::info!("DB already has settings, renaming stale settings.json");
        rename_to_migrated(&legacy_settings_path);
        return Ok(());
    }

    tracing::info!("Migrating disk settings to database...");

    // 1. Load and migrate settings.json
    let settings = crate::settings::Settings::load_from(&legacy_settings_path);
    let db_map = settings.to_db_map();
    if !db_map.is_empty() {
        store
            .set_all_settings(user_id, &db_map)
            .await
            .map_err(|e| {
                MigrationError::Database(format!("Failed to write settings to DB: {}", e))
            })?;
        tracing::info!("Migrated {} settings to database", db_map.len());
    }

    // 2. Write DATABASE_URL to ~/.ironclaw/.env
    if let Some(ref url) = settings.database_url {
        save_database_url(url)
            .map_err(|e| MigrationError::Io(format!("Failed to write .env: {}", e)))?;
        tracing::info!("Wrote DATABASE_URL to {}", ironclaw_env_path().display());
    }

    // 3. Migrate mcp-servers.json if it exists
    let mcp_path = ironclaw_dir.join("mcp-servers.json");
    if mcp_path.exists() {
        match std::fs::read_to_string(&mcp_path) {
            Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
                Ok(value) => {
                    store
                        .set_setting(user_id, "mcp_servers", &value)
                        .await
                        .map_err(|e| {
                            MigrationError::Database(format!(
                                "Failed to write MCP servers to DB: {}",
                                e
                            ))
                        })?;
                    tracing::info!("Migrated mcp-servers.json to database");

                    rename_to_migrated(&mcp_path);
                }
                Err(e) => {
                    tracing::warn!("Failed to parse mcp-servers.json: {}", e);
                }
            },
            Err(e) => {
                tracing::warn!("Failed to read mcp-servers.json: {}", e);
            }
        }
    }

    // 4. Migrate session.json if it exists
    let session_path = ironclaw_dir.join("session.json");
    if session_path.exists() {
        match std::fs::read_to_string(&session_path) {
            Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
                Ok(value) => {
                    store
                        .set_setting(user_id, "nearai.session_token", &value)
                        .await
                        .map_err(|e| {
                            MigrationError::Database(format!(
                                "Failed to write session to DB: {}",
                                e
                            ))
                        })?;
                    tracing::info!("Migrated session.json to database");

                    rename_to_migrated(&session_path);
                }
                Err(e) => {
                    tracing::warn!("Failed to parse session.json: {}", e);
                }
            },
            Err(e) => {
                tracing::warn!("Failed to read session.json: {}", e);
            }
        }
    }

    // 5. Rename settings.json to .migrated (don't delete, safety net)
    rename_to_migrated(&legacy_settings_path);

    // 6. Clean up old bootstrap.json if it exists (superseded by .env)
    let old_bootstrap = ironclaw_dir.join("bootstrap.json");
    if old_bootstrap.exists() {
        rename_to_migrated(&old_bootstrap);
        tracing::info!("Renamed old bootstrap.json to .migrated");
    }

    tracing::info!("Disk-to-DB migration complete");
    Ok(())
}

/// Rename a file to `<name>.migrated` as a safety net.
fn rename_to_migrated(path: &std::path::Path) {
    let mut migrated = path.as_os_str().to_owned();
    migrated.push(".migrated");
    if let Err(e) = std::fs::rename(path, &migrated) {
        tracing::warn!("Failed to rename {} to .migrated: {}", path.display(), e);
    }
}

/// Errors that can occur during disk-to-DB migration.
#[derive(Debug, thiserror::Error)]
pub enum MigrationError {
    #[error("Database error: {0}")]
    Database(String),
    #[error("IO error: {0}")]
    Io(String),
}

// ── PID Lock ──────────────────────────────────────────────────────────────

/// Path to the PID lock file: `~/.ironclaw/ironclaw.pid`.
pub fn pid_lock_path() -> PathBuf {
    ironclaw_base_dir().join("ironclaw.pid")
}

/// A PID-based lock that prevents multiple IronClaw instances from running
/// simultaneously.
///
/// Uses `fs4::try_lock_exclusive()` for atomic locking (no TOCTOU race),
/// then writes the current PID into the locked file for diagnostics.
/// The OS-level lock is held for the lifetime of this struct and
/// automatically released on drop (along with the PID file cleanup).
#[derive(Debug)]
pub struct PidLock {
    path: PathBuf,
    /// Held open to maintain the OS-level exclusive lock.
    _file: std::fs::File,
}

/// Errors from PID lock acquisition.
#[derive(Debug, thiserror::Error)]
pub enum PidLockError {
    #[error("Another IronClaw instance is already running (PID {pid})")]
    AlreadyRunning { pid: u32 },
    #[error("Failed to acquire PID lock: {0}")]
    Io(#[from] std::io::Error),
}

impl PidLock {
    /// Try to acquire the PID lock.
    ///
    /// Uses an exclusive file lock (`flock`/`LockFileEx`) so that two
    /// concurrent processes cannot both acquire the lock — no TOCTOU race.
    /// If the lock file exists but the holding process is gone (stale),
    /// the lock is reclaimed automatically by the OS.
    pub fn acquire() -> Result<Self, PidLockError> {
        Self::acquire_at(pid_lock_path())
    }

    /// Acquire at a specific path (for testing).
    fn acquire_at(path: PathBuf) -> Result<Self, PidLockError> {
        use fs4::FileExt;
        use std::fs::OpenOptions;
        use std::io::Write;

        // Ensure parent directory exists
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }

        // Open (or create) the lock file
        let mut file = OpenOptions::new()
            .read(true)
            .write(true)
            .create(true)
            .truncate(false)
            .open(&path)?;

        // Try non-blocking exclusive lock — if another process holds it,
        // this fails immediately instead of blocking.
        if let Err(e) = file.try_lock_exclusive() {
            if e.kind() == std::io::ErrorKind::WouldBlock {
                // Lock held by another process — read its PID for the error message
                let pid = std::fs::read_to_string(&path)
                    .ok()
                    .and_then(|s| s.trim().parse::<u32>().ok())
                    .unwrap_or(0);
                return Err(PidLockError::AlreadyRunning { pid });
            }
            // Other errors (permissions, unsupported filesystem, etc.)
            return Err(PidLockError::Io(e));
        }

        // We hold the exclusive lock — write our PID
        file.set_len(0)?; // truncate
        write!(file, "{}", std::process::id())?;

        Ok(PidLock { path, _file: file })
    }
}

impl Drop for PidLock {
    fn drop(&mut self) {
        // Remove the PID file; the OS-level lock is released when _file is dropped.
        let _ = std::fs::remove_file(&self.path);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::process::Command;
    use std::sync::Mutex;
    use std::thread;
    use std::time::{Duration, Instant};
    use tempfile::tempdir;

    static ENV_MUTEX: Mutex<()> = Mutex::new(());

    #[test]
    fn test_save_and_load_database_url() {
        let dir = tempdir().unwrap();
        let env_path = dir.path().join(".env");

        // Write in the quoted format that save_database_url uses
        let url = "postgres://localhost:5432/ironclaw_test";
        std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap();

        // Verify the content is a valid dotenv line (quoted)
        let content = std::fs::read_to_string(&env_path).unwrap();
        assert_eq!(
            content,
            "DATABASE_URL=\"postgres://localhost:5432/ironclaw_test\"\n"
        );

        // Verify dotenvy can parse it (strips quotes automatically)
        let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
            .unwrap()
            .filter_map(|r| r.ok())
            .collect();
        assert_eq!(parsed.len(), 1);
        assert_eq!(parsed[0].0, "DATABASE_URL");
        assert_eq!(parsed[0].1, url);
    }

    #[test]
    fn test_save_database_url_with_hash_in_password() {
        let dir = tempdir().unwrap();
        let env_path = dir.path().join(".env");

        // URLs with # in the password are common (URL-encoded special chars).
        // Without quoting, dotenvy treats # as a comment delimiter.
        let url = "postgres://user:p%23ss@localhost:5432/ironclaw";
        std::fs::write(&env_path, format!("DATABASE_URL=\"{}\"\n", url)).unwrap();

        let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
            .unwrap()
            .filter_map(|r| r.ok())
            .collect();
        assert_eq!(parsed.len(), 1);
        assert_eq!(parsed[0].0, "DATABASE_URL");
        assert_eq!(parsed[0].1, url);
    }

    #[test]
    fn test_save_database_url_creates_parent_dirs() {
        let dir = tempdir().unwrap();
        let nested = dir.path().join("deep").join("nested");
        let env_path = nested.join(".env");

        // Parent doesn't exist yet
        assert!(!nested.exists());

        // The global function uses a fixed path, so we test the logic directly
        std::fs::create_dir_all(&nested).unwrap();
        std::fs::write(&env_path, "DATABASE_URL=postgres://test\n").unwrap();

        assert!(env_path.exists());
        let content = std::fs::read_to_string(&env_path).unwrap();
        assert!(content.contains("DATABASE_URL=postgres://test"));
    }

    #[test]
    fn test_save_bootstrap_env_escapes_quotes() {
        let dir = tempdir().unwrap();
        let env_path = dir.path().join(".env");

        // A malicious URL attempting to inject a second env var
        let malicious = r#"http://evil.com"
INJECTED="pwned"#;
        let mut content = String::new();
        let escaped = malicious.replace('\\', "\\\\").replace('"', "\\\"");
        content.push_str(&format!("LLM_BASE_URL=\"{}\"\n", escaped));
        std::fs::write(&env_path, &content).unwrap();

        let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
            .unwrap()
            .filter_map(|r| r.ok())
            .collect();

        // Must parse as exactly one variable, not two
        assert_eq!(parsed.len(), 1, "injection must not create extra vars");
        assert_eq!(parsed[0].0, "LLM_BASE_URL");
        // The value should contain the original malicious content (unescaped by dotenvy)
        assert!(
            parsed[0].1.contains("INJECTED"),
            "value should contain the literal injection attempt, not execute it"
        );
    }

    #[test]
    fn test_ironclaw_env_path() {
        let path = ironclaw_env_path();
        assert!(path.ends_with(".ironclaw/.env"));
    }

    #[test]
    fn test_migrate_bootstrap_json_to_env() {
        let dir = tempdir().unwrap();
        let env_path = dir.path().join(".env");
        let bootstrap_path = dir.path().join("bootstrap.json");

        // Write a legacy bootstrap.json
        let bootstrap_json = serde_json::json!({
            "database_url": "postgres://localhost/ironclaw_upgrade",
            "database_pool_size": 5,
            "secrets_master_key_source": "keychain",
            "onboard_completed": true
        });
        std::fs::write(
            &bootstrap_path,
            serde_json::to_string_pretty(&bootstrap_json).unwrap(),
        )
        .unwrap();

        assert!(!env_path.exists());
        assert!(bootstrap_path.exists());

        // Run the migration
        migrate_bootstrap_json_to_env(&env_path);

        // .env should now exist with DATABASE_URL
        assert!(env_path.exists());
        let content = std::fs::read_to_string(&env_path).unwrap();
        assert_eq!(
            content,
            "DATABASE_URL=\"postgres://localhost/ironclaw_upgrade\"\n"
        );

        // bootstrap.json should be renamed to .migrated
        assert!(!bootstrap_path.exists());
        assert!(dir.path().join("bootstrap.json.migrated").exists());
    }

    #[test]
    fn test_migrate_bootstrap_json_no_database_url() {
        let dir = tempdir().unwrap();
        let env_path = dir.path().join(".env");
        let bootstrap_path = dir.path().join("bootstrap.json");

        // bootstrap.json with no database_url
        let bootstrap_json = serde_json::json!({
            "onboard_completed": false
        });
        std::fs::write(
            &bootstrap_path,
            serde_json::to_string_pretty(&bootstrap_json).unwrap(),
        )
        .unwrap();

        migrate_bootstrap_json_to_env(&env_path);

        // .env should NOT be created
        assert!(!env_path.exists());
        // bootstrap.json should remain (no migration happened)
        assert!(bootstrap_path.exists());
    }

    #[test]
    fn test_migrate_bootstrap_json_missing() {
        let dir = tempdir().unwrap();
        let env_path = dir.path().join(".env");

        // No bootstrap.json at all
        migrate_bootstrap_json_to_env(&env_path);

        // Nothing should happen
        assert!(!env_path.exists());
    }

    #[test]
    fn test_save_bootstrap_env_multiple_vars() {
        let dir = tempdir().unwrap();
        let env_path = dir.path().join("nested").join(".env");

        std::fs::create_dir_all(env_path.parent().unwrap()).unwrap();

        let vars = [
            ("DATABASE_BACKEND", "libsql"),
            ("LIBSQL_PATH", "/home/user/.ironclaw/ironclaw.db"),
        ];

        // Write manually to the temp path (save_bootstrap_env uses the global path)
        let mut content = String::new();
        for (key, value) in &vars {
            content.push_str(&format!("{}=\"{}\"\n", key, value));
        }
        std::fs::write(&env_path, &content).unwrap();

        // Verify dotenvy can parse all entries
        let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
            .unwrap()
            .filter_map(|r| r.ok())
            .collect();
        assert_eq!(parsed.len(), 2);
        assert_eq!(
            parsed[0],
            ("DATABASE_BACKEND".to_string(), "libsql".to_string())
        );
        assert_eq!(
            parsed[1],
            (
                "LIBSQL_PATH".to_string(),
                "/home/user/.ironclaw/ironclaw.db".to_string()
            )
        );
    }

    #[test]
    fn test_save_bootstrap_env_overwrites_previous() {
        let dir = tempdir().unwrap();
        let env_path = dir.path().join(".env");

        // Write initial content
        std::fs::write(&env_path, "DATABASE_URL=\"postgres://old\"\n").unwrap();

        // Overwrite with new vars (simulating save_bootstrap_env behavior)
        let content = "DATABASE_BACKEND=\"libsql\"\nLIBSQL_PATH=\"/new/path.db\"\n";
        std::fs::write(&env_path, content).unwrap();

        let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
            .unwrap()
            .filter_map(|r| r.ok())
            .collect();
        // Old DATABASE_URL should be gone
        assert_eq!(parsed.len(), 2);
        assert!(parsed.iter().all(|(k, _)| k != "DATABASE_URL"));
    }

    #[test]
    fn test_onboard_completed_round_trips_through_env() {
        let dir = tempdir().unwrap();
        let env_path = dir.path().join(".env");

        // Simulate what the wizard writes: bootstrap vars + ONBOARD_COMPLETED
        let vars = [
            ("DATABASE_BACKEND", "libsql"),
            ("ONBOARD_COMPLETED", "true"),
        ];
        let mut content = String::new();
        for (key, value) in &vars {
            let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
            content.push_str(&format!("{}=\"{}\"\n", key, escaped));
        }
        std::fs::write(&env_path, &content).unwrap();

        // Verify dotenvy parses ONBOARD_COMPLETED correctly
        let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
            .unwrap()
            .filter_map(|r| r.ok())
            .collect();
        assert_eq!(parsed.len(), 2);
        let onboard = parsed.iter().find(|(k, _)| k == "ONBOARD_COMPLETED");
        assert!(onboard.is_some(), "ONBOARD_COMPLETED must be present");
        assert_eq!(onboard.unwrap().1, "true");
    }

    #[test]
    fn test_libsql_autodetect_sets_backend_when_db_exists() {
        let _guard = ENV_MUTEX.lock().unwrap();
        let old_val = std::env::var("DATABASE_BACKEND").ok();
        // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
        unsafe { std::env::remove_var("DATABASE_BACKEND") };

        let dir = tempdir().unwrap();
        let db_path = dir.path().join("ironclaw.db");

        // No DB file — auto-detect guard should not trigger.
        assert!(!db_path.exists());
        let would_trigger = std::env::var("DATABASE_BACKEND").is_err() && db_path.exists();
        assert!(
            !would_trigger,
            "should not auto-detect when db file is absent"
        );

        // Create the DB file — guard should now trigger.
        std::fs::write(&db_path, "").unwrap();
        assert!(db_path.exists());

        // Simulate the detection logic (DATABASE_BACKEND unset + db exists).
        let detected = std::env::var("DATABASE_BACKEND").is_err() && db_path.exists();
        assert!(
            detected,
            "should detect libsql when db file is present and backend unset"
        );

        // Restore.
        if let Some(val) = old_val {
            // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
            unsafe { std::env::set_var("DATABASE_BACKEND", val) };
        }
    }

    // === QA Plan P1 - 1.2: Bootstrap .env round-trip tests ===

    #[test]
    fn bootstrap_env_round_trips_llm_backend() {
        let dir = tempdir().unwrap();
        let env_path = dir.path().join(".env");

        // Simulate what the wizard writes for LLM backend selection
        let vars = [
            ("DATABASE_BACKEND", "libsql"),
            ("LLM_BACKEND", "openai"),
            ("ONBOARD_COMPLETED", "true"),
        ];
        let mut content = String::new();
        for (key, value) in &vars {
            let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
            content.push_str(&format!("{}=\"{}\"\n", key, escaped));
        }
        std::fs::write(&env_path, &content).unwrap();

        // Verify dotenvy parses LLM_BACKEND correctly
        let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
            .unwrap()
            .filter_map(|r| r.ok())
            .collect();

        let llm_backend = parsed.iter().find(|(k, _)| k == "LLM_BACKEND");
        assert!(llm_backend.is_some(), "LLM_BACKEND must be present");
        assert_eq!(
            llm_backend.unwrap().1,
            "openai",
            "LLM_BACKEND must survive .env round-trip"
        );
    }

    #[test]
    fn test_libsql_autodetect_does_not_override_explicit_backend() {
        let _guard = ENV_MUTEX.lock().unwrap();
        let old_val = std::env::var("DATABASE_BACKEND").ok();
        // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
        unsafe { std::env::set_var("DATABASE_BACKEND", "postgres") };

        let dir = tempdir().unwrap();
        let db_path = dir.path().join("ironclaw.db");
        std::fs::write(&db_path, "").unwrap();

        // The guard: only sets libsql if DATABASE_BACKEND is NOT already set.
        let would_override = std::env::var("DATABASE_BACKEND").is_err() && db_path.exists();
        assert!(
            !would_override,
            "must not override an explicitly set DATABASE_BACKEND"
        );

        // Restore.
        if let Some(val) = old_val {
            // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
            unsafe { std::env::set_var("DATABASE_BACKEND", val) };
        } else {
            // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
            unsafe { std::env::remove_var("DATABASE_BACKEND") };
        }
    }

    #[test]
    fn bootstrap_env_special_chars_in_url() {
        let dir = tempdir().unwrap();
        let env_path = dir.path().join(".env");

        // URLs with special characters that are common in database passwords
        let url = "postgres://user:p%23ss@host:5432/db?sslmode=require";
        let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
        let content = format!("DATABASE_URL=\"{}\"\n", escaped);
        std::fs::write(&env_path, &content).unwrap();

        let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
            .unwrap()
            .filter_map(|r| r.ok())
            .collect();

        assert_eq!(parsed.len(), 1);
        assert_eq!(parsed[0].1, url, "URL with special chars must survive");
    }

    #[test]
    fn upsert_bootstrap_var_preserves_existing() {
        let dir = tempdir().unwrap();
        let env_path = dir.path().join(".env");

        // Write initial content
        let initial = "DATABASE_BACKEND=\"libsql\"\nONBOARD_COMPLETED=\"true\"\n";
        std::fs::write(&env_path, initial).unwrap();

        // Upsert a new var
        let content = std::fs::read_to_string(&env_path).unwrap();
        let new_line = "LLM_BACKEND=\"anthropic\"";
        let mut result = content.clone();
        result.push_str(new_line);
        result.push('\n');
        std::fs::write(&env_path, &result).unwrap();

        // Parse and verify all three vars are present
        let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
            .unwrap()
            .filter_map(|r| r.ok())
            .collect();

        assert_eq!(parsed.len(), 3, "should have 3 vars after upsert");
        assert!(
            parsed
                .iter()
                .any(|(k, v)| k == "DATABASE_BACKEND" && v == "libsql"),
            "original DATABASE_BACKEND must be preserved"
        );
        assert!(
            parsed
                .iter()
                .any(|(k, v)| k == "ONBOARD_COMPLETED" && v == "true"),
            "original ONBOARD_COMPLETED must be preserved"
        );
        assert!(
            parsed
                .iter()
                .any(|(k, v)| k == "LLM_BACKEND" && v == "anthropic"),
            "new LLM_BACKEND must be present"
        );
    }

    #[test]
    fn bootstrap_env_all_wizard_vars_round_trip() {
        let dir = tempdir().unwrap();
        let env_path = dir.path().join(".env");

        // Full set of vars the wizard might write
        let vars = [
            ("DATABASE_BACKEND", "postgres"),
            ("DATABASE_URL", "postgres://u:p@h:5432/db"),
            ("LLM_BACKEND", "nearai"),
            ("ONBOARD_COMPLETED", "true"),
            ("EMBEDDING_ENABLED", "false"),
        ];
        let mut content = String::new();
        for (key, value) in &vars {
            let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
            content.push_str(&format!("{}=\"{}\"\n", key, escaped));
        }
        std::fs::write(&env_path, &content).unwrap();

        let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
            .unwrap()
            .filter_map(|r| r.ok())
            .collect();

        assert_eq!(parsed.len(), vars.len(), "all vars must survive round-trip");
        for (key, value) in &vars {
            let found = parsed.iter().find(|(k, _)| k == key);
            assert!(found.is_some(), "{key} must be present");
            assert_eq!(&found.unwrap().1, value, "{key} value mismatch");
        }
    }

    #[test]
    fn test_ironclaw_base_dir_default() {
        // This test must run first (or in isolation) before the LazyLock is initialized.
        // It verifies that when IRONCLAW_BASE_DIR is not set, the default path is used.
        let _guard = ENV_MUTEX.lock().unwrap();
        let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
        // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
        unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };

        // Force re-evaluation by calling the computation function directly
        let path = compute_ironclaw_base_dir();
        let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
        assert_eq!(path, home.join(".ironclaw"));

        if let Some(val) = old_val {
            // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
            unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
        }
    }

    #[test]
    fn test_ironclaw_base_dir_env_override() {
        // This test verifies that when IRONCLAW_BASE_DIR is set,
        // the custom path is used. Must run before LazyLock is initialized.
        let _guard = ENV_MUTEX.lock().unwrap();
        let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
        // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
        unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/custom/ironclaw/path") };

        // Force re-evaluation by calling the computation function directly
        let path = compute_ironclaw_base_dir();
        assert_eq!(path, std::path::PathBuf::from("/custom/ironclaw/path"));

        if let Some(val) = old_val {
            // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
            unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
        } else {
            // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
            unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
        }
    }

    #[test]
    fn test_compute_base_dir_env_path_join() {
        // Verifies that ironclaw_env_path correctly joins .env to the base dir.
        // Uses compute_ironclaw_base_dir directly to avoid LazyLock caching.
        let _guard = ENV_MUTEX.lock().unwrap();
        let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
        // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
        unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/my/custom/dir") };

        // Test the path construction logic directly
        let base_path = compute_ironclaw_base_dir();
        let env_path = base_path.join(".env");
        assert_eq!(env_path, std::path::PathBuf::from("/my/custom/dir/.env"));

        if let Some(val) = old_val {
            // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
            unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
        } else {
            // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
            unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
        }
    }

    #[test]
    fn test_ironclaw_base_dir_empty_env() {
        // Verifies that empty IRONCLAW_BASE_DIR falls back to default.
        let _guard = ENV_MUTEX.lock().unwrap();
        let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
        // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
        unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "") };

        // Force re-evaluation by calling the computation function directly
        let path = compute_ironclaw_base_dir();
        let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
        assert_eq!(path, home.join(".ironclaw"));

        if let Some(val) = old_val {
            // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
            unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
        } else {
            // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
            unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
        }
    }

    #[test]
    fn test_ironclaw_base_dir_special_chars() {
        // Verifies that paths with special characters are handled correctly.
        let _guard = ENV_MUTEX.lock().unwrap();
        let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
        // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
        unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/tmp/test_with-special.chars") };

        // Force re-evaluation by calling the computation function directly
        let path = compute_ironclaw_base_dir();
        assert_eq!(
            path,
            std::path::PathBuf::from("/tmp/test_with-special.chars")
        );

        if let Some(val) = old_val {
            // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
            unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
        } else {
            // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests
            unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
        }
    }

    // ── PID Lock tests ───────────────────────────────────────────────

    #[test]
    fn test_pid_lock_acquire_and_drop() {
        let dir = tempdir().unwrap();
        let pid_path = dir.path().join("ironclaw.pid");

        // Acquire lock
        let lock = PidLock::acquire_at(pid_path.clone()).unwrap();
        assert!(pid_path.exists());

        // PID file should contain our PID
        let contents = std::fs::read_to_string(&pid_path).unwrap();
        assert_eq!(contents.trim().parse::<u32>().unwrap(), std::process::id());

        // Drop should remove the file
        drop(lock);
        assert!(!pid_path.exists());
    }

    #[test]
    fn test_pid_lock_rejects_second_acquire() {
        let dir = tempdir().unwrap();
        let pid_path = dir.path().join("ironclaw.pid");

        // First lock succeeds
        let _lock1 = PidLock::acquire_at(pid_path.clone()).unwrap();

        // Second acquire on same file must fail (exclusive flock held)
        let result = PidLock::acquire_at(pid_path.clone());
        assert!(result.is_err());
        match result.unwrap_err() {
            PidLockError::AlreadyRunning { pid } => {
                assert_eq!(pid, std::process::id());
            }
            other => panic!("expected AlreadyRunning, got: {}", other),
        }
    }

    #[test]
    fn test_pid_lock_reclaims_after_drop() {
        let dir = tempdir().unwrap();
        let pid_path = dir.path().join("ironclaw.pid");

        // Acquire and release
        let lock = PidLock::acquire_at(pid_path.clone()).unwrap();
        drop(lock);

        // Should succeed — OS lock was released on drop
        let lock2 = PidLock::acquire_at(pid_path).unwrap();
        drop(lock2);
    }

    #[test]
    fn test_pid_lock_reclaims_stale_file_without_flock() {
        let dir = tempdir().unwrap();
        let pid_path = dir.path().join("ironclaw.pid");

        // Write a stale PID file manually (no flock held)
        std::fs::write(&pid_path, "4294967294").unwrap();

        // Should succeed because no OS lock is held on the file
        let lock = PidLock::acquire_at(pid_path.clone()).unwrap();
        let contents = std::fs::read_to_string(&pid_path).unwrap();
        assert_eq!(contents.trim().parse::<u32>().unwrap(), std::process::id());
        drop(lock);
    }

    #[test]
    fn test_pid_lock_handles_corrupt_pid_file() {
        let dir = tempdir().unwrap();
        let pid_path = dir.path().join("ironclaw.pid");

        // Write garbage (no flock held)
        std::fs::write(&pid_path, "not-a-number").unwrap();

        // Should succeed — no OS lock held, file is reclaimed
        let lock = PidLock::acquire_at(pid_path).unwrap();
        drop(lock);
    }

    #[test]
    fn test_pid_lock_creates_parent_dirs() {
        let dir = tempdir().unwrap();
        let pid_path = dir.path().join("nested").join("deep").join("ironclaw.pid");

        let lock = PidLock::acquire_at(pid_path.clone()).unwrap();
        assert!(pid_path.exists());
        drop(lock);
    }

    #[test]
    fn test_pid_lock_child_helper_holds_lock() {
        if std::env::var("IRONCLAW_PID_LOCK_CHILD").ok().as_deref() != Some("1") {
            return;
        }

        let pid_path = PathBuf::from(
            std::env::var("IRONCLAW_PID_LOCK_PATH").expect("IRONCLAW_PID_LOCK_PATH missing"),
        );
        let hold_ms = std::env::var("IRONCLAW_PID_LOCK_HOLD_MS")
            .ok()
            .and_then(|s| s.parse::<u64>().ok())
            .unwrap_or(3000);

        let _lock = PidLock::acquire_at(pid_path).expect("child failed to acquire pid lock");
        thread::sleep(Duration::from_millis(hold_ms));
    }

    #[test]
    fn test_pid_lock_rejects_lock_held_by_other_process() {
        let dir = tempdir().unwrap();
        let pid_path = dir.path().join("ironclaw.pid");

        let current_exe = std::env::current_exe().unwrap();
        let mut child = Command::new(current_exe)
            .args([
                "--exact",
                "bootstrap::tests::test_pid_lock_child_helper_holds_lock",
                "--nocapture",
                "--test-threads=1",
            ])
            .env("IRONCLAW_PID_LOCK_CHILD", "1")
            .env("IRONCLAW_PID_LOCK_PATH", pid_path.display().to_string())
            .env("IRONCLAW_PID_LOCK_HOLD_MS", "3000")
            .spawn()
            .unwrap();

        let started = Instant::now();
        while started.elapsed() < Duration::from_secs(2) {
            if pid_path.exists() {
                break;
            }
            if let Some(status) = child.try_wait().unwrap() {
                panic!("child exited before acquiring lock: {}", status);
            }
            thread::sleep(Duration::from_millis(20));
        }
        assert!(
            pid_path.exists(),
            "child did not create lock file in time: {}",
            pid_path.display()
        );

        let result = PidLock::acquire_at(pid_path.clone());
        match result.unwrap_err() {
            PidLockError::AlreadyRunning { .. } => {}
            other => panic!("expected AlreadyRunning, got: {}", other),
        }

        let status = child.wait().unwrap();
        assert!(status.success(), "child process failed: {}", status);

        // After the child exits, lock should be released and reacquirable.
        let lock = PidLock::acquire_at(pid_path).unwrap();
        drop(lock);
    }

    #[test]
    fn upsert_bootstrap_vars_preserves_unknown_keys() {
        let dir = tempdir().unwrap();
        let env_path = dir.path().join(".env");

        // Simulate a user-edited .env with custom vars
        let initial =
            "HTTP_HOST=\"0.0.0.0\"\nDATABASE_BACKEND=\"postgres\"\nCUSTOM_VAR=\"keep_me\"\n";
        std::fs::write(&env_path, initial).unwrap();

        // Upsert wizard vars — should preserve HTTP_HOST and CUSTOM_VAR
        let vars = [("DATABASE_BACKEND", "libsql"), ("LLM_BACKEND", "openai")];
        upsert_bootstrap_vars_to(&env_path, &vars).unwrap();

        let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
            .unwrap()
            .filter_map(|r| r.ok())
            .collect();

        assert_eq!(
            parsed.len(),
            4,
            "should have 4 vars (2 preserved + 2 upserted)"
        );

        // User-added vars must be preserved
        assert!(
            parsed
                .iter()
                .any(|(k, v)| k == "HTTP_HOST" && v == "0.0.0.0"),
            "HTTP_HOST must be preserved"
        );
        assert!(
            parsed
                .iter()
                .any(|(k, v)| k == "CUSTOM_VAR" && v == "keep_me"),
            "CUSTOM_VAR must be preserved"
        );

        // Wizard vars must be updated/added
        assert!(
            parsed
                .iter()
                .any(|(k, v)| k == "DATABASE_BACKEND" && v == "libsql"),
            "DATABASE_BACKEND must be updated to libsql"
        );
        assert!(
            parsed
                .iter()
                .any(|(k, v)| k == "LLM_BACKEND" && v == "openai"),
            "LLM_BACKEND must be added"
        );

        // Now update LLM_BACKEND and verify HTTP_HOST still preserved
        let vars2 = [("LLM_BACKEND", "anthropic")];
        upsert_bootstrap_vars_to(&env_path, &vars2).unwrap();

        let parsed2: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
            .unwrap()
            .filter_map(|r| r.ok())
            .collect();

        assert_eq!(
            parsed2.len(),
            4,
            "should still have 4 vars after second upsert"
        );
        assert!(
            parsed2
                .iter()
                .any(|(k, v)| k == "HTTP_HOST" && v == "0.0.0.0"),
            "HTTP_HOST must still be preserved after second upsert"
        );
        assert!(
            parsed2
                .iter()
                .any(|(k, v)| k == "LLM_BACKEND" && v == "anthropic"),
            "LLM_BACKEND must be updated to anthropic"
        );
    }

    #[test]
    fn upsert_bootstrap_vars_creates_file_if_missing() {
        let dir = tempdir().unwrap();
        let env_path = dir.path().join("subdir").join(".env");

        // File doesn't exist yet
        assert!(!env_path.exists());

        let vars = [("DATABASE_BACKEND", "libsql")];
        upsert_bootstrap_vars_to(&env_path, &vars).unwrap();

        assert!(env_path.exists());
        let parsed: Vec<(String, String)> = dotenvy::from_path_iter(&env_path)
            .unwrap()
            .filter_map(|r| r.ok())
            .collect();
        assert_eq!(parsed.len(), 1);
        assert_eq!(
            parsed[0],
            ("DATABASE_BACKEND".to_string(), "libsql".to_string())
        );
    }
}
