use super::*;
use crate::openhuman::config::schema::{StreamMode, TelegramConfig};

#[test]
fn read_active_user_returns_none_when_no_file() {
    let tmp = tempfile::tempdir().unwrap();
    assert!(read_active_user_id(tmp.path()).is_none());
}

#[test]
fn read_active_user_returns_none_when_empty() {
    let tmp = tempfile::tempdir().unwrap();
    std::fs::write(tmp.path().join(ACTIVE_USER_STATE_FILE), "").unwrap();
    assert!(read_active_user_id(tmp.path()).is_none());
}

#[test]
fn read_active_user_returns_id_when_present() {
    let tmp = tempfile::tempdir().unwrap();
    write_active_user_id(tmp.path(), "user-789").unwrap();
    assert_eq!(
        read_active_user_id(tmp.path()),
        Some("user-789".to_string())
    );
}

#[test]
fn write_and_clear_active_user_roundtrip() {
    let tmp = tempfile::tempdir().unwrap();

    write_active_user_id(tmp.path(), "u-abc").unwrap();
    assert_eq!(read_active_user_id(tmp.path()), Some("u-abc".to_string()));

    clear_active_user(tmp.path()).unwrap();
    assert!(read_active_user_id(tmp.path()).is_none());
}

#[test]
fn user_openhuman_dir_builds_correct_path() {
    let root = PathBuf::from("/home/test/.openhuman");
    let dir = user_openhuman_dir(&root, "user-123");
    assert_eq!(dir, PathBuf::from("/home/test/.openhuman/users/user-123"));
}

#[tokio::test]
// Races on `OPENHUMAN_WORKSPACE` env var with other tests holding
// `TEST_ENV_LOCK` — passes in isolation, intermittently fails in parallel.
// Runs reliably with `--ignored --test-threads=1`. See PR #1524.
#[ignore = "flaky in parallel cargo test; OPENHUMAN_WORKSPACE env-var race — see PR #1524"]
async fn resolve_dirs_uses_active_user_when_present() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();
    let default_workspace = root.join("workspace");

    // No active user → falls back to the pre-login user directory so
    // memory/state/config are still encapsulated under users/.
    let (oh_dir, ws_dir, source) = resolve_runtime_config_dirs(root, &default_workspace)
        .await
        .unwrap();
    let expected_pre_login_dir = root.join("users").join(PRE_LOGIN_USER_ID);
    assert_eq!(oh_dir, expected_pre_login_dir);
    assert_eq!(ws_dir, expected_pre_login_dir.join("workspace"));
    assert_eq!(source, ConfigResolutionSource::DefaultConfigDir);

    // With active user → scopes to user dir.
    write_active_user_id(root, "u-test").unwrap();
    let (oh_dir, ws_dir, source) = resolve_runtime_config_dirs(root, &default_workspace)
        .await
        .unwrap();
    let expected_user_dir = root.join("users").join("u-test");
    assert_eq!(oh_dir, expected_user_dir);
    assert_eq!(ws_dir, expected_user_dir.join("workspace"));
    assert_eq!(source, ConfigResolutionSource::ActiveUser);
}

#[test]
fn pre_login_user_dir_is_under_users_tree() {
    let root = PathBuf::from("/home/test/.openhuman");
    let dir = pre_login_user_dir(&root);
    assert_eq!(
        dir,
        PathBuf::from("/home/test/.openhuman/users").join(PRE_LOGIN_USER_ID)
    );
}

#[test]
fn default_root_dir_name_uses_staging_suffix_for_staging_env() {
    let prior = std::env::var(crate::api::config::APP_ENV_VAR).ok();

    std::env::set_var(crate::api::config::APP_ENV_VAR, "staging");
    assert!(crate::api::config::is_staging_app_env(Some("staging")));
    assert_eq!(default_root_dir_name(), ".openhuman-staging");

    std::env::set_var(crate::api::config::APP_ENV_VAR, "production");
    assert_eq!(default_root_dir_name(), ".openhuman");

    match prior {
        Some(value) => std::env::set_var(crate::api::config::APP_ENV_VAR, value),
        None => std::env::remove_var(crate::api::config::APP_ENV_VAR),
    }
}

// ── apply_env_overrides ────────────────────────────────────────

use crate::openhuman::config::TEST_ENV_LOCK as ENV_LOCK;

fn clear_env(keys: &[&str]) {
    for key in keys {
        unsafe {
            std::env::remove_var(key);
        }
    }
}

#[test]
fn apply_env_overrides_picks_up_model() {
    let _g = env_lock();
    clear_env(&["OPENHUMAN_MODEL", "MODEL"]);
    unsafe {
        std::env::set_var("OPENHUMAN_MODEL", "gpt-5");
    }
    let mut cfg = Config::default();
    cfg.apply_env_overrides();
    assert_eq!(cfg.default_model.as_deref(), Some("gpt-5"));
    unsafe {
        std::env::remove_var("OPENHUMAN_MODEL");
    }
}

#[test]
fn apply_env_overrides_validates_temperature_range() {
    let _g = env_lock();
    clear_env(&["OPENHUMAN_TEMPERATURE"]);
    let mut cfg = Config::default();
    cfg.default_temperature = 0.5;
    unsafe {
        std::env::set_var("OPENHUMAN_TEMPERATURE", "1.2");
    }
    cfg.apply_env_overrides();
    assert!((cfg.default_temperature - 1.2).abs() < f64::EPSILON);

    // Out of range — should be ignored.
    unsafe {
        std::env::set_var("OPENHUMAN_TEMPERATURE", "5");
    }
    cfg.apply_env_overrides();
    assert!((cfg.default_temperature - 1.2).abs() < f64::EPSILON);

    // Garbage value — ignored.
    unsafe {
        std::env::set_var("OPENHUMAN_TEMPERATURE", "not-a-number");
    }
    cfg.apply_env_overrides();
    assert!((cfg.default_temperature - 1.2).abs() < f64::EPSILON);
    unsafe {
        std::env::remove_var("OPENHUMAN_TEMPERATURE");
    }
}

#[test]
fn apply_env_overrides_reasoning_enabled_parses_truthy_falsy() {
    let _g = env_lock();
    clear_env(&["OPENHUMAN_REASONING_ENABLED", "REASONING_ENABLED"]);
    let mut cfg = Config::default();
    cfg.runtime.reasoning_enabled = None;

    unsafe {
        std::env::set_var("OPENHUMAN_REASONING_ENABLED", "yes");
    }
    cfg.apply_env_overrides();
    assert_eq!(cfg.runtime.reasoning_enabled, Some(true));

    unsafe {
        std::env::set_var("OPENHUMAN_REASONING_ENABLED", "off");
    }
    cfg.apply_env_overrides();
    assert_eq!(cfg.runtime.reasoning_enabled, Some(false));

    // Unknown value — leaves field unchanged.
    unsafe {
        std::env::set_var("OPENHUMAN_REASONING_ENABLED", "maybe");
    }
    cfg.apply_env_overrides();
    assert_eq!(cfg.runtime.reasoning_enabled, Some(false));
    unsafe {
        std::env::remove_var("OPENHUMAN_REASONING_ENABLED");
    }
}

#[test]
fn apply_env_overrides_shell_hide_window_parses_truthy_falsy() {
    let _g = env_lock();
    clear_env(&["OPENHUMAN_SHELL_HIDE_WINDOW", "SHELL_HIDE_WINDOW"]);
    let mut cfg = Config::default();
    assert!(!cfg.shell.hide_window, "default should be off");

    unsafe {
        std::env::set_var("OPENHUMAN_SHELL_HIDE_WINDOW", "on");
    }
    cfg.apply_env_overrides();
    assert!(cfg.shell.hide_window);

    unsafe {
        std::env::set_var("OPENHUMAN_SHELL_HIDE_WINDOW", "false");
    }
    cfg.apply_env_overrides();
    assert!(!cfg.shell.hide_window);

    // The unprefixed alias `SHELL_HIDE_WINDOW` is honored too.
    unsafe {
        std::env::remove_var("OPENHUMAN_SHELL_HIDE_WINDOW");
        std::env::set_var("SHELL_HIDE_WINDOW", "on");
    }
    cfg.apply_env_overrides();
    assert!(cfg.shell.hide_window, "alias should set hide_window");

    // The namespaced var takes precedence over the alias when both are set.
    unsafe {
        std::env::set_var("OPENHUMAN_SHELL_HIDE_WINDOW", "off");
        std::env::set_var("SHELL_HIDE_WINDOW", "on");
    }
    cfg.apply_env_overrides();
    assert!(
        !cfg.shell.hide_window,
        "OPENHUMAN_-prefixed var should win over the alias"
    );

    // Unknown value leaves the field unchanged.
    cfg.shell.hide_window = true;
    unsafe {
        std::env::set_var("OPENHUMAN_SHELL_HIDE_WINDOW", "maybe");
        std::env::remove_var("SHELL_HIDE_WINDOW");
    }
    cfg.apply_env_overrides();
    assert!(cfg.shell.hide_window);
    unsafe {
        std::env::remove_var("OPENHUMAN_SHELL_HIDE_WINDOW");
    }
}

#[test]
fn apply_env_overrides_web_search_limits_only() {
    let _g = env_lock();
    clear_env(&[
        "OPENHUMAN_WEB_SEARCH_MAX_RESULTS",
        "WEB_SEARCH_MAX_RESULTS",
        "OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS",
        "WEB_SEARCH_TIMEOUT_SECS",
    ]);
    let mut cfg = Config::default();
    unsafe {
        std::env::set_var("OPENHUMAN_WEB_SEARCH_MAX_RESULTS", "5");
        std::env::set_var("OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS", "20");
    }
    cfg.apply_env_overrides();
    assert_eq!(cfg.web_search.max_results, 5);
    assert_eq!(cfg.web_search.timeout_secs, 20);
    clear_env(&[
        "OPENHUMAN_WEB_SEARCH_MAX_RESULTS",
        "OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS",
    ]);
}

#[test]
fn apply_env_overrides_web_search_max_results_and_timeout_clamped() {
    let _g = env_lock();
    clear_env(&[
        "OPENHUMAN_WEB_SEARCH_MAX_RESULTS",
        "WEB_SEARCH_MAX_RESULTS",
        "OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS",
        "WEB_SEARCH_TIMEOUT_SECS",
    ]);
    let mut cfg = Config::default();
    cfg.web_search.max_results = 3;
    cfg.web_search.timeout_secs = 10;

    // Valid values apply.
    unsafe {
        std::env::set_var("OPENHUMAN_WEB_SEARCH_MAX_RESULTS", "5");
        std::env::set_var("OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS", "20");
    }
    cfg.apply_env_overrides();
    assert_eq!(cfg.web_search.max_results, 5);
    assert_eq!(cfg.web_search.timeout_secs, 20);

    // Out-of-range (>10 for max_results, 0 for timeout) — ignored.
    unsafe {
        std::env::set_var("OPENHUMAN_WEB_SEARCH_MAX_RESULTS", "999");
        std::env::set_var("OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS", "0");
    }
    cfg.apply_env_overrides();
    assert_eq!(
        cfg.web_search.max_results, 5,
        "out-of-range must be ignored"
    );
    assert_eq!(cfg.web_search.timeout_secs, 20);
    clear_env(&[
        "OPENHUMAN_WEB_SEARCH_MAX_RESULTS",
        "OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS",
    ]);
}

#[test]
fn apply_env_overrides_searxng_config() {
    let _g = env_lock();
    clear_env(&[
        "OPENHUMAN_SEARXNG_ENABLED",
        "SEARXNG_ENABLED",
        "OPENHUMAN_SEARXNG_BASE_URL",
        "SEARXNG_BASE_URL",
        "OPENHUMAN_SEARXNG_MAX_RESULTS",
        "SEARXNG_MAX_RESULTS",
        "OPENHUMAN_SEARXNG_DEFAULT_LANGUAGE",
        "SEARXNG_DEFAULT_LANGUAGE",
        "OPENHUMAN_SEARXNG_TIMEOUT_SECS",
        "OPENHUMAN_SEARXNG_TIMEOUT_SECONDS",
        "SEARXNG_TIMEOUT_SECS",
        "SEARXNG_TIMEOUT_SECONDS",
    ]);

    let mut cfg = Config::default();
    unsafe {
        std::env::set_var("OPENHUMAN_SEARXNG_ENABLED", "yes");
        std::env::set_var("OPENHUMAN_SEARXNG_BASE_URL", "http://127.0.0.1:8081");
        std::env::set_var("OPENHUMAN_SEARXNG_MAX_RESULTS", "25");
        std::env::set_var("OPENHUMAN_SEARXNG_DEFAULT_LANGUAGE", "zh-CN");
        std::env::set_var("OPENHUMAN_SEARXNG_TIMEOUT_SECONDS", "12");
    }

    cfg.apply_env_overrides();

    assert!(cfg.searxng.enabled);
    assert_eq!(cfg.searxng.base_url, "http://127.0.0.1:8081");
    assert_eq!(cfg.searxng.max_results, 25);
    assert_eq!(cfg.searxng.default_language, "zh-CN");
    assert_eq!(cfg.searxng.timeout_secs, 12);
    clear_env(&[
        "OPENHUMAN_SEARXNG_ENABLED",
        "OPENHUMAN_SEARXNG_BASE_URL",
        "OPENHUMAN_SEARXNG_MAX_RESULTS",
        "OPENHUMAN_SEARXNG_DEFAULT_LANGUAGE",
        "OPENHUMAN_SEARXNG_TIMEOUT_SECONDS",
    ]);
}

#[test]
fn searxng_timeout_seconds_alias_deserializes() {
    let cfg: crate::openhuman::config::SearxngConfig =
        toml::from_str(r#"timeout_seconds = 7"#).expect("deserialize searxng config");
    assert_eq!(cfg.timeout_secs, 7);
}

#[test]
fn apply_env_overrides_picks_up_sentry_dsn() {
    let _g = env_lock();
    clear_env(&["OPENHUMAN_CORE_SENTRY_DSN", "OPENHUMAN_SENTRY_DSN"]);
    let mut cfg = Config::default();
    unsafe {
        std::env::set_var("OPENHUMAN_SENTRY_DSN", "https://token@sentry.io/1");
    }
    cfg.apply_env_overrides();
    assert_eq!(
        cfg.observability.sentry_dsn.as_deref(),
        Some("https://token@sentry.io/1")
    );
    clear_env(&["OPENHUMAN_CORE_SENTRY_DSN", "OPENHUMAN_SENTRY_DSN"]);
}

#[test]
fn apply_env_overrides_prefers_core_sentry_dsn_when_both_set() {
    let _g = env_lock();
    clear_env(&["OPENHUMAN_CORE_SENTRY_DSN", "OPENHUMAN_SENTRY_DSN"]);
    let mut cfg = Config::default();
    unsafe {
        std::env::set_var("OPENHUMAN_SENTRY_DSN", "https://legacy@sentry.io/1");
        std::env::set_var("OPENHUMAN_CORE_SENTRY_DSN", "https://new@sentry.io/2");
    }
    cfg.apply_env_overrides();
    assert_eq!(
        cfg.observability.sentry_dsn.as_deref(),
        Some("https://new@sentry.io/2"),
        "namespaced var must win over the legacy unprefixed one"
    );
    clear_env(&["OPENHUMAN_CORE_SENTRY_DSN", "OPENHUMAN_SENTRY_DSN"]);
}

#[test]
fn apply_env_overrides_picks_up_core_sentry_dsn_alone() {
    let _g = env_lock();
    clear_env(&["OPENHUMAN_CORE_SENTRY_DSN", "OPENHUMAN_SENTRY_DSN"]);
    let mut cfg = Config::default();
    unsafe {
        std::env::set_var("OPENHUMAN_CORE_SENTRY_DSN", "https://token@sentry.io/3");
    }
    cfg.apply_env_overrides();
    assert_eq!(
        cfg.observability.sentry_dsn.as_deref(),
        Some("https://token@sentry.io/3")
    );
    clear_env(&["OPENHUMAN_CORE_SENTRY_DSN", "OPENHUMAN_SENTRY_DSN"]);
}

// ── EnvLookup seam for resolve_runtime_config_dirs ─────────────

#[derive(Default)]
struct MapEnv(std::collections::HashMap<String, String>);

impl MapEnv {
    fn with(mut self, k: &str, v: &str) -> Self {
        self.0.insert(k.to_string(), v.to_string());
        self
    }
}

impl EnvLookup for MapEnv {
    fn get(&self, key: &str) -> Option<String> {
        self.0.get(key).cloned()
    }
}

#[tokio::test]
async fn env_workspace_override_wins_via_seam() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();
    // Active user would otherwise win — confirm env override takes precedence.
    write_active_user_id(root, "u-active").unwrap();

    let ws_root = tempfile::tempdir().unwrap();
    let ws_path = ws_root.path().join("my-workspace");
    let env = MapEnv::default().with("OPENHUMAN_WORKSPACE", ws_path.to_str().unwrap());

    let default_workspace = root.join("workspace");
    let (oh_dir, ws_dir, source) = resolve_runtime_config_dirs_with(root, &default_workspace, &env)
        .await
        .unwrap();

    let (expected_oh, expected_ws) = resolve_config_dir_for_workspace(&ws_path);
    assert_eq!(source, ConfigResolutionSource::EnvWorkspace);
    assert_eq!(oh_dir, expected_oh);
    assert_eq!(ws_dir, expected_ws);
}

#[tokio::test]
async fn empty_env_workspace_falls_through_to_active_user() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();
    write_active_user_id(root, "u-fallthrough").unwrap();
    let env = MapEnv::default().with("OPENHUMAN_WORKSPACE", "");

    let default_workspace = root.join("workspace");
    let (oh_dir, ws_dir, source) = resolve_runtime_config_dirs_with(root, &default_workspace, &env)
        .await
        .unwrap();

    let expected = root.join("users").join("u-fallthrough");
    assert_eq!(source, ConfigResolutionSource::ActiveUser);
    assert_eq!(oh_dir, expected);
    assert_eq!(ws_dir, expected.join("workspace"));
}

#[tokio::test]
async fn missing_env_workspace_uses_pre_login_default() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();
    let env = MapEnv::default(); // no OPENHUMAN_WORKSPACE, no active user

    let default_workspace = root.join("workspace");
    let (oh_dir, ws_dir, source) = resolve_runtime_config_dirs_with(root, &default_workspace, &env)
        .await
        .unwrap();

    let expected = root.join("users").join(PRE_LOGIN_USER_ID);
    assert_eq!(source, ConfigResolutionSource::DefaultConfigDir);
    assert_eq!(oh_dir, expected);
    assert_eq!(ws_dir, expected.join("workspace"));
}

// ── resolve_config_dir_for_workspace ───────────────────────────

#[test]
fn resolve_config_dir_for_workspace_returns_parent_and_workspace() {
    let ws = PathBuf::from("/home/test/.openhuman/workspace");
    let (config_dir, workspace_dir) = resolve_config_dir_for_workspace(&ws);
    // Config dir is the parent of workspace.
    assert!(
        config_dir.ends_with(".openhuman") || config_dir == PathBuf::from("/home/test/.openhuman")
    );
    assert!(workspace_dir.ends_with("workspace"));
}

// ── apply_env_overlay_with: EnvLookup seam ─────────────────────
//
// These tests exercise every env override branch via a `HashMapEnv`
// fixture so they neither mutate the process environment nor need
// to grab `TEST_ENV_LOCK`. They can all run in parallel.

use std::collections::HashMap;

/// In-memory [`EnvLookup`] used by the overlay tests. Case-sensitive
/// to mirror Unix `std::env::var` semantics.
#[derive(Default)]
struct HashMapEnv {
    entries: HashMap<String, String>,
}

impl HashMapEnv {
    fn new() -> Self {
        Self::default()
    }

    fn with(mut self, key: &str, value: &str) -> Self {
        self.entries.insert(key.to_string(), value.to_string());
        self
    }
}

impl EnvLookup for HashMapEnv {
    fn get(&self, key: &str) -> Option<String> {
        self.entries.get(key).cloned()
    }

    fn contains(&self, key: &str) -> bool {
        self.entries.contains_key(key)
    }
}

#[test]
fn env_overlay_model_only_honours_namespaced_var() {
    // Both set → OPENHUMAN_MODEL wins; bare MODEL is ignored even when
    // OPENHUMAN_MODEL is absent.
    let env = HashMapEnv::new()
        .with("OPENHUMAN_MODEL", "specific-v2")
        .with("MODEL", "alias-fallback");
    let mut cfg = Config::default();
    cfg.apply_env_overlay_with(&env);
    assert_eq!(cfg.default_model.as_deref(), Some("specific-v2"));

    // Only bare MODEL set → must NOT clobber default_model. Vendor
    // asset-tag env vars (e.g. Dell OptiPlex `MODEL=7080`) would otherwise
    // hijack the LLM model name and 400 every backend call
    // (Sentry OPENHUMAN-TAURI-J8).
    let env = HashMapEnv::new().with("MODEL", "7080");
    let mut cfg = Config::default();
    let original = cfg.default_model.clone();
    cfg.apply_env_overlay_with(&env);
    assert_eq!(
        cfg.default_model, original,
        "bare MODEL env var must not override default_model"
    );

    // Whitespace-only OPENHUMAN_MODEL must not clobber either. Some
    // shells/CI runners pass an unset-but-declared env var through as
    // `"   "`, which `is_empty()` alone wouldn't reject.
    let env = HashMapEnv::new().with("OPENHUMAN_MODEL", "   ");
    let mut cfg = Config::default();
    let original = cfg.default_model.clone();
    cfg.apply_env_overlay_with(&env);
    assert_eq!(
        cfg.default_model, original,
        "whitespace-only OPENHUMAN_MODEL must not clobber default_model"
    );
}

#[test]
fn env_overlay_model_ignores_empty() {
    let env = HashMapEnv::new().with("OPENHUMAN_MODEL", "");
    let mut cfg = Config::default();
    let original = cfg.default_model.clone();
    cfg.apply_env_overlay_with(&env);
    assert_eq!(cfg.default_model, original, "empty value must not clobber");
}

#[test]
fn env_overlay_temperature_accepts_valid_and_ignores_out_of_range_or_garbage() {
    let mut cfg = Config::default();
    cfg.default_temperature = 0.5;

    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_TEMPERATURE", "1.5"));
    assert!((cfg.default_temperature - 1.5).abs() < f64::EPSILON);

    // Negative (< 0.0) — ignored.
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_TEMPERATURE", "-0.1"));
    assert!((cfg.default_temperature - 1.5).abs() < f64::EPSILON);

    // Above cap (> 2.0) — ignored.
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_TEMPERATURE", "2.5"));
    assert!((cfg.default_temperature - 1.5).abs() < f64::EPSILON);

    // Garbage — ignored.
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_TEMPERATURE", "nope"));
    assert!((cfg.default_temperature - 1.5).abs() < f64::EPSILON);

    // Boundaries — inclusive on both ends.
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_TEMPERATURE", "0"));
    assert_eq!(cfg.default_temperature, 0.0);
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_TEMPERATURE", "2"));
    assert_eq!(cfg.default_temperature, 2.0);
}

#[test]
fn env_overlay_autonomy_max_actions_per_hour_accepts_valid_u32() {
    let mut cfg = Config::default();
    cfg.autonomy.max_actions_per_hour = 20;

    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_MAX_ACTIONS_PER_HOUR", "64"));
    assert_eq!(cfg.autonomy.max_actions_per_hour, 64);

    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_MAX_ACTIONS_PER_HOUR", "  "));
    assert_eq!(
        cfg.autonomy.max_actions_per_hour, 64,
        "blank env value must leave the configured limit unchanged"
    );

    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_MAX_ACTIONS_PER_HOUR", "NaN"));
    assert_eq!(
        cfg.autonomy.max_actions_per_hour, 64,
        "invalid env value must leave the configured limit unchanged"
    );
}

#[test]
fn env_overlay_memory_sync_interval_parses_and_honours_zero() {
    let mut cfg = Config::default();
    assert!(cfg.memory_sync_interval_secs.is_none());

    // A positive value is stored verbatim.
    cfg.apply_env_overlay_with(&HashMapEnv::new().with(MEMORY_SYNC_INTERVAL_SECS_ENV_VAR, "14400"));
    assert_eq!(cfg.memory_sync_interval_secs, Some(14_400));

    // `0` is honoured as the "Manual only" sentinel (unlike the per-provider
    // override which rejects it).
    cfg.apply_env_overlay_with(&HashMapEnv::new().with(MEMORY_SYNC_INTERVAL_SECS_ENV_VAR, "0"));
    assert_eq!(cfg.memory_sync_interval_secs, Some(0));

    // A non-numeric value is ignored, leaving the previous value intact.
    cfg.apply_env_overlay_with(&HashMapEnv::new().with(MEMORY_SYNC_INTERVAL_SECS_ENV_VAR, "nope"));
    assert_eq!(cfg.memory_sync_interval_secs, Some(0));

    // A blank value is ignored too.
    cfg.apply_env_overlay_with(&HashMapEnv::new().with(MEMORY_SYNC_INTERVAL_SECS_ENV_VAR, "  "));
    assert_eq!(cfg.memory_sync_interval_secs, Some(0));
}

#[test]
fn env_overlay_output_language_accepts_non_empty_value() {
    let mut cfg = Config::default();
    assert!(cfg.output_language.is_none());

    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_OUTPUT_LANGUAGE", "zh-CN"));
    assert_eq!(cfg.output_language.as_deref(), Some("zh-CN"));
    assert!(cfg
        .output_language_directive()
        .as_deref()
        .unwrap_or_default()
        .contains("Simplified Chinese"));

    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_OUTPUT_LANGUAGE", "   "));
    assert_eq!(
        cfg.output_language.as_deref(),
        Some("zh-CN"),
        "blank env value must not clear an explicit config value"
    );
}

#[test]
fn env_overlay_reasoning_enabled_recognises_truthy_falsy_and_ignores_garbage() {
    let mut cfg = Config::default();
    cfg.runtime.reasoning_enabled = None;

    for truthy in ["1", "true", "yes", "on", "TRUE", " On "] {
        cfg.runtime.reasoning_enabled = None;
        cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_REASONING_ENABLED", truthy));
        assert_eq!(
            cfg.runtime.reasoning_enabled,
            Some(true),
            "truthy value {truthy:?} should enable reasoning"
        );
    }

    for falsy in ["0", "false", "no", "off", "OFF"] {
        cfg.runtime.reasoning_enabled = Some(true);
        cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_REASONING_ENABLED", falsy));
        assert_eq!(
            cfg.runtime.reasoning_enabled,
            Some(false),
            "falsy value {falsy:?} should disable reasoning"
        );
    }

    // Garbage leaves the previous value unchanged.
    cfg.runtime.reasoning_enabled = Some(true);
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_REASONING_ENABLED", "maybe"));
    assert_eq!(cfg.runtime.reasoning_enabled, Some(true));

    // Alias works when the OPENHUMAN variant is absent.
    cfg.runtime.reasoning_enabled = None;
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("REASONING_ENABLED", "yes"));
    assert_eq!(cfg.runtime.reasoning_enabled, Some(true));
}

#[test]
fn env_overlay_web_search_limits_validated() {
    let mut cfg = Config::default();
    cfg.web_search.max_results = 3;
    cfg.web_search.timeout_secs = 10;

    // Valid values apply.
    cfg.apply_env_overlay_with(
        &HashMapEnv::new()
            .with("OPENHUMAN_WEB_SEARCH_MAX_RESULTS", "7")
            .with("OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS", "25"),
    );
    assert_eq!(cfg.web_search.max_results, 7);
    assert_eq!(cfg.web_search.timeout_secs, 25);

    // Out-of-range — ignored.
    cfg.apply_env_overlay_with(
        &HashMapEnv::new()
            .with("OPENHUMAN_WEB_SEARCH_MAX_RESULTS", "0")
            .with("OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS", "0"),
    );
    assert_eq!(cfg.web_search.max_results, 7);
    assert_eq!(cfg.web_search.timeout_secs, 25);

    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_WEB_SEARCH_MAX_RESULTS", "11"));
    assert_eq!(cfg.web_search.max_results, 7);

    // Bare aliases also accepted when the OPENHUMAN-prefixed variant is absent.
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("WEB_SEARCH_MAX_RESULTS", "4"));
    assert_eq!(cfg.web_search.max_results, 4);
}

#[test]
fn env_overlay_searxng_config_validated() {
    let mut cfg = Config::default();

    cfg.apply_env_overlay_with(
        &HashMapEnv::new()
            .with("OPENHUMAN_SEARXNG_ENABLED", "true")
            .with("OPENHUMAN_SEARXNG_BASE_URL", "http://127.0.0.1:8888")
            .with("OPENHUMAN_SEARXNG_MAX_RESULTS", "40")
            .with("OPENHUMAN_SEARXNG_DEFAULT_LANGUAGE", "fr")
            .with("OPENHUMAN_SEARXNG_TIMEOUT_SECS", "9"),
    );

    assert!(cfg.searxng.enabled);
    assert_eq!(cfg.searxng.base_url, "http://127.0.0.1:8888");
    assert_eq!(cfg.searxng.max_results, 40);
    assert_eq!(cfg.searxng.default_language, "fr");
    assert_eq!(cfg.searxng.timeout_secs, 9);

    cfg.apply_env_overlay_with(
        &HashMapEnv::new()
            .with("OPENHUMAN_SEARXNG_ENABLED", "no")
            .with("OPENHUMAN_SEARXNG_MAX_RESULTS", "0")
            .with("OPENHUMAN_SEARXNG_TIMEOUT_SECS", "0"),
    );

    assert!(!cfg.searxng.enabled);
    assert_eq!(cfg.searxng.max_results, 40);
    assert_eq!(cfg.searxng.timeout_secs, 9);

    cfg.apply_env_overlay_with(&HashMapEnv::new().with("SEARXNG_TIMEOUT_SECONDS", "11"));
    assert_eq!(cfg.searxng.timeout_secs, 11);
}

#[test]
fn env_overlay_proxy_url_enables_proxy_when_not_explicit() {
    let mut cfg = Config::default();
    assert!(!cfg.proxy.enabled);

    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_HTTP_PROXY", "http://proxy.local:3128"),
    );

    assert!(
        cfg.proxy.enabled,
        "setting a proxy URL without explicit enable should auto-enable"
    );
    assert_eq!(
        cfg.proxy.http_proxy.as_deref(),
        Some("http://proxy.local:3128")
    );
}

#[test]
fn env_overlay_explicit_proxy_enabled_overrides_auto_enable() {
    let mut cfg = Config::default();
    cfg.apply_env_overlay_with(
        &HashMapEnv::new()
            .with("OPENHUMAN_PROXY_ENABLED", "false")
            .with("OPENHUMAN_HTTP_PROXY", "http://proxy.local:3128"),
    );
    assert!(
        !cfg.proxy.enabled,
        "explicit OPENHUMAN_PROXY_ENABLED=false must win over URL-driven auto-enable"
    );
}

#[test]
fn env_overlay_proxy_scope_invalid_value_leaves_scope_unchanged() {
    let mut cfg = Config::default();
    let original_scope = cfg.proxy.scope;
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_PROXY_SCOPE", "bogus-scope"));
    assert_eq!(cfg.proxy.scope, original_scope);
}

#[test]
fn env_overlay_node_flags_respect_bool_parser() {
    let mut cfg = Config::default();
    let original_version = cfg.node.version.clone();

    cfg.apply_env_overlay_with(
        &HashMapEnv::new()
            .with("OPENHUMAN_NODE_ENABLED", "yes")
            .with("OPENHUMAN_NODE_PREFER_SYSTEM", "off")
            .with("OPENHUMAN_NODE_CACHE_DIR", "/tmp/oh-node"),
    );
    assert!(cfg.node.enabled);
    assert!(!cfg.node.prefer_system);
    assert_eq!(cfg.node.cache_dir, "/tmp/oh-node");
    assert_eq!(
        cfg.node.version, original_version,
        "untouched keys stay at defaults"
    );

    // Unrecognised bool — ignored, keeps previous true.
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_NODE_ENABLED", "perhaps"));
    assert!(cfg.node.enabled);

    // Blank version does NOT clobber.
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_NODE_VERSION", "   "));
    assert_eq!(cfg.node.version, original_version);
}

#[test]
fn env_overlay_runtime_python_flags_respect_bool_parser() {
    let mut cfg = Config::default();
    let original_version = cfg.runtime_python.minimum_version.clone();

    cfg.apply_env_overlay_with(
        &HashMapEnv::new()
            .with("OPENHUMAN_RUNTIME_PYTHON_ENABLED", "yes")
            .with("OPENHUMAN_RUNTIME_PYTHON_PREFER_SYSTEM", "off")
            .with("OPENHUMAN_RUNTIME_PYTHON_CACHE_DIR", "/tmp/oh-python")
            .with("OPENHUMAN_RUNTIME_PYTHON_MANAGED_RELEASE_TAG", "20260510")
            .with("OPENHUMAN_RUNTIME_PYTHON_PREFERRED_COMMAND", "python3.12"),
    );
    assert!(cfg.runtime_python.enabled);
    assert!(!cfg.runtime_python.prefer_system);
    assert_eq!(cfg.runtime_python.cache_dir, "/tmp/oh-python");
    assert_eq!(cfg.runtime_python.managed_release_tag, "20260510");
    assert_eq!(cfg.runtime_python.preferred_command, "python3.12");
    assert_eq!(
        cfg.runtime_python.minimum_version, original_version,
        "untouched keys stay at defaults"
    );

    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_RUNTIME_PYTHON_ENABLED", "perhaps"),
    );
    assert!(cfg.runtime_python.enabled);

    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_RUNTIME_PYTHON_MINIMUM_VERSION", "   "),
    );
    assert_eq!(cfg.runtime_python.minimum_version, original_version);

    cfg.runtime_python.cache_dir = "/tmp/seed".into();
    cfg.runtime_python.managed_release_tag = "20260510".into();
    cfg.runtime_python.preferred_command = "python3.12".into();
    cfg.apply_env_overlay_with(
        &HashMapEnv::new()
            .with("OPENHUMAN_RUNTIME_PYTHON_CACHE_DIR", "   ")
            .with("OPENHUMAN_RUNTIME_PYTHON_MANAGED_RELEASE_TAG", "   ")
            .with("OPENHUMAN_RUNTIME_PYTHON_PREFERRED_COMMAND", "   "),
    );
    assert_eq!(cfg.runtime_python.cache_dir, "");
    assert_eq!(cfg.runtime_python.managed_release_tag, "");
    assert_eq!(cfg.runtime_python.preferred_command, "");
}

#[test]
fn env_overlay_sentry_dsn_trims_and_ignores_blank() {
    let mut cfg = Config::default();
    cfg.observability.sentry_dsn = None;

    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_SENTRY_DSN", "  https://t@sentry.io/42  "),
    );
    assert_eq!(
        cfg.observability.sentry_dsn.as_deref(),
        Some("https://t@sentry.io/42")
    );

    // Blank value — ignored (previous DSN retained).
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_SENTRY_DSN", "   "));
    assert_eq!(
        cfg.observability.sentry_dsn.as_deref(),
        Some("https://t@sentry.io/42")
    );
}

#[test]
fn env_overlay_prefers_namespaced_core_sentry_dsn() {
    let mut cfg = Config::default();
    cfg.observability.sentry_dsn = None;

    cfg.apply_env_overlay_with(
        &HashMapEnv::new()
            .with("OPENHUMAN_SENTRY_DSN", "https://legacy@sentry.io/1")
            .with("OPENHUMAN_CORE_SENTRY_DSN", "https://new@sentry.io/2"),
    );
    assert_eq!(
        cfg.observability.sentry_dsn.as_deref(),
        Some("https://new@sentry.io/2"),
        "OPENHUMAN_CORE_SENTRY_DSN must win over OPENHUMAN_SENTRY_DSN"
    );
}

#[test]
fn env_overlay_namespaced_core_sentry_dsn_works_alone() {
    let mut cfg = Config::default();
    cfg.observability.sentry_dsn = None;

    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_CORE_SENTRY_DSN", "https://token@sentry.io/3"),
    );
    assert_eq!(
        cfg.observability.sentry_dsn.as_deref(),
        Some("https://token@sentry.io/3")
    );
}

#[test]
fn env_overlay_analytics_enabled_parses_truthy_falsy() {
    let mut cfg = Config::default();
    cfg.observability.analytics_enabled = false;
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_ANALYTICS_ENABLED", "1"));
    assert!(cfg.observability.analytics_enabled);

    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_ANALYTICS_ENABLED", "0"));
    assert!(!cfg.observability.analytics_enabled);
}

#[test]
fn env_overlay_learning_source_values_and_invalid_ignored() {
    let mut cfg = Config::default();
    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_LEARNING_REFLECTION_SOURCE", "local"),
    );
    assert_eq!(
        cfg.learning.reflection_source,
        crate::openhuman::config::ReflectionSource::Local
    );

    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_LEARNING_REFLECTION_SOURCE", "cloud"),
    );
    assert_eq!(
        cfg.learning.reflection_source,
        crate::openhuman::config::ReflectionSource::Cloud
    );

    // Unknown — ignored, retains cloud from previous step.
    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_LEARNING_REFLECTION_SOURCE", "bogus"),
    );
    assert_eq!(
        cfg.learning.reflection_source,
        crate::openhuman::config::ReflectionSource::Cloud
    );
}

#[test]
fn env_overlay_learning_numeric_values_parse() {
    let mut cfg = Config::default();
    cfg.apply_env_overlay_with(
        &HashMapEnv::new()
            .with("OPENHUMAN_LEARNING_MAX_REFLECTIONS_PER_SESSION", "8")
            .with("OPENHUMAN_LEARNING_MIN_TURN_COMPLEXITY", "2"),
    );
    assert_eq!(cfg.learning.max_reflections_per_session, 8);
    assert_eq!(cfg.learning.min_turn_complexity, 2);
}

#[test]
fn env_overlay_dictation_activation_mode_only_toggle_or_push() {
    let mut cfg = Config::default();

    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_DICTATION_ACTIVATION_MODE", "toggle"),
    );
    assert_eq!(
        cfg.dictation.activation_mode,
        crate::openhuman::config::DictationActivationMode::Toggle
    );

    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_DICTATION_ACTIVATION_MODE", "push"),
    );
    assert_eq!(
        cfg.dictation.activation_mode,
        crate::openhuman::config::DictationActivationMode::Push
    );

    // Unknown — retains previous value (Push).
    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_DICTATION_ACTIVATION_MODE", "wave"),
    );
    assert_eq!(
        cfg.dictation.activation_mode,
        crate::openhuman::config::DictationActivationMode::Push
    );
}

#[test]
fn env_overlay_context_tool_result_budget_env_suppresses_legacy_migration() {
    // If the env var is *present*, the `agent.tool_result_budget_bytes`
    // migration must NOT run — even when the explicit env value equals
    // the default. This protects users who explicitly set the env to
    // the default.
    let default_budget = crate::openhuman::context::DEFAULT_TOOL_RESULT_BUDGET_BYTES;
    let mut cfg = Config::default();
    cfg.context.tool_result_budget_bytes = default_budget;
    cfg.agent.tool_result_budget_bytes = 999_999;

    cfg.apply_env_overlay_with(&HashMapEnv::new().with(
        "OPENHUMAN_CONTEXT_TOOL_RESULT_BUDGET_BYTES",
        &default_budget.to_string(),
    ));
    assert_eq!(
        cfg.context.tool_result_budget_bytes, default_budget,
        "env presence must suppress the legacy agent→context copy"
    );
}

#[test]
fn env_overlay_compaction_default_on_and_kill_switch() {
    // Default is on.
    assert!(Config::default().context.compaction_enabled);

    // `OPENHUMAN_COMPACTION=0` disables it.
    let mut cfg = Config::default();
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_COMPACTION", "0"));
    assert!(!cfg.context.compaction_enabled);

    // Truthy re-enables; the namespaced alias works too.
    let mut cfg = Config::default();
    cfg.context.compaction_enabled = false;
    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_CONTEXT_COMPACTION_ENABLED", "on"),
    );
    assert!(cfg.context.compaction_enabled);

    // Garbage is ignored (leaves the prior value untouched).
    let mut cfg = Config::default();
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_COMPACTION", "maybe"));
    assert!(cfg.context.compaction_enabled);
}

#[test]
fn env_overlay_super_context_default_on_and_toggle() {
    // Default is on.
    assert!(Config::default().context.super_context_enabled);

    // `OPENHUMAN_SUPER_CONTEXT=0` opts out.
    let mut cfg = Config::default();
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_SUPER_CONTEXT", "0"));
    assert!(!cfg.context.super_context_enabled);

    // The namespaced alias works and `on` re-enables it.
    let mut cfg = Config::default();
    cfg.context.super_context_enabled = false;
    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_CONTEXT_SUPER_CONTEXT_ENABLED", "on"),
    );
    assert!(cfg.context.super_context_enabled);

    // Garbage is ignored (leaves the prior value untouched).
    let mut cfg = Config::default();
    cfg.context.super_context_enabled = false;
    cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_SUPER_CONTEXT", "maybe"));
    assert!(!cfg.context.super_context_enabled);
}

#[test]
fn env_overlay_context_tool_result_budget_legacy_migration_when_env_absent() {
    // Env absent, context at default, agent customised → agent value copies forward.
    let default_budget = crate::openhuman::context::DEFAULT_TOOL_RESULT_BUDGET_BYTES;
    let mut cfg = Config::default();
    cfg.context.tool_result_budget_bytes = default_budget;
    cfg.agent.tool_result_budget_bytes = 777_777;

    cfg.apply_env_overlay_with(&HashMapEnv::new());
    assert_eq!(cfg.context.tool_result_budget_bytes, 777_777);
}

#[test]
fn env_overlay_context_tool_result_budget_env_wins_over_legacy_migration() {
    // Env present with a non-default value, and agent also customised.
    // The env value must apply; the legacy agent→context copy must NOT
    // overwrite it.
    let mut cfg = Config::default();
    cfg.agent.tool_result_budget_bytes = 111_111;

    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_CONTEXT_TOOL_RESULT_BUDGET_BYTES", "222222"),
    );
    assert_eq!(
        cfg.context.tool_result_budget_bytes, 222_222,
        "env value wins; legacy migration suppressed"
    );
}

#[test]
fn env_overlay_auto_update_interval_parses_u32() {
    let mut cfg = Config::default();
    cfg.apply_env_overlay_with(
        &HashMapEnv::new()
            .with("OPENHUMAN_AUTO_UPDATE_ENABLED", "true")
            .with("OPENHUMAN_AUTO_UPDATE_INTERVAL_MINUTES", "60"),
    );
    assert!(cfg.update.enabled);
    assert_eq!(cfg.update.interval_minutes, 60);

    // Garbage numeric — ignored, previous value retained.
    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_AUTO_UPDATE_INTERVAL_MINUTES", "hello"),
    );
    assert_eq!(cfg.update.interval_minutes, 60);
}

#[test]
fn env_overlay_auto_update_restart_strategy_accepts_supported_values() {
    let mut cfg = Config::default();
    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY", "supervisor"),
    );
    assert_eq!(
        cfg.update.restart_strategy,
        crate::openhuman::config::UpdateRestartStrategy::Supervisor
    );

    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_AUTO_UPDATE_RESTART_STRATEGY", "self_replace"),
    );
    assert_eq!(
        cfg.update.restart_strategy,
        crate::openhuman::config::UpdateRestartStrategy::SelfReplace
    );
}

#[test]
fn env_overlay_auto_update_rpc_mutations_enabled_parses_bool() {
    let mut cfg = Config::default();
    cfg.apply_env_overlay_with(
        &HashMapEnv::new().with("OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED", "false"),
    );
    assert!(!cfg.update.rpc_mutations_enabled);
}

#[test]
fn env_overlay_empty_lookup_leaves_defaults_intact() {
    // The seam with no env entries should be a no-op on a fresh Config.
    let mut cfg = Config::default();
    let before = (
        cfg.default_model.clone(),
        cfg.default_temperature,
        cfg.runtime.reasoning_enabled,
        cfg.update.enabled,
        cfg.dictation.enabled,
    );
    cfg.apply_env_overlay_with(&HashMapEnv::new());
    let after = (
        cfg.default_model.clone(),
        cfg.default_temperature,
        cfg.runtime.reasoning_enabled,
        cfg.update.enabled,
        cfg.dictation.enabled,
    );
    assert_eq!(before, after);
}

#[test]
fn env_lookup_get_any_preserves_precedence() {
    let env = HashMapEnv::new()
        .with("KEY_A", "first-wins")
        .with("KEY_B", "second")
        .with("KEY_C", "third");
    // Ordered lookup: first hit wins.
    assert_eq!(env.get_any(&["KEY_A", "KEY_B"]), Some("first-wins".into()));
    // Missing first → falls through.
    assert_eq!(
        env.get_any(&["KEY_MISSING", "KEY_B"]),
        Some("second".into())
    );
    // All missing → None.
    assert_eq!(env.get_any(&["KEY_X", "KEY_Y"]), None);
}

// ── resolve_runtime_config_dirs_with ──────────────────────────────────────

#[tokio::test]
async fn resolve_runtime_config_dirs_with_env_workspace_override() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();
    let default_workspace = root.join("workspace");

    // Point OPENHUMAN_WORKSPACE at a custom path via HashMapEnv — no
    // process-env mutation needed.
    let custom_ws = tmp.path().join("custom_ws");
    let env = HashMapEnv::new().with("OPENHUMAN_WORKSPACE", custom_ws.to_str().unwrap());

    let (oh_dir, ws_dir, source) = resolve_runtime_config_dirs_with(root, &default_workspace, &env)
        .await
        .unwrap();

    assert_eq!(source, ConfigResolutionSource::EnvWorkspace);
    // resolve_config_dir_for_workspace: no config.toml and basename ≠
    // "workspace" → oh_dir == custom_ws, ws_dir == custom_ws/workspace.
    assert_eq!(oh_dir, custom_ws);
    assert_eq!(ws_dir, custom_ws.join("workspace"));
}

#[tokio::test]
async fn resolve_runtime_config_dirs_with_empty_env_falls_back_to_default() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();
    let default_workspace = root.join("workspace");

    // Empty env: no OPENHUMAN_WORKSPACE → falls through to the pre-login
    // user directory path (no active_user.toml, no workspace marker).
    let env = HashMapEnv::new();
    let (oh_dir, _ws_dir, source) =
        resolve_runtime_config_dirs_with(root, &default_workspace, &env)
            .await
            .unwrap();

    assert_eq!(source, ConfigResolutionSource::DefaultConfigDir);
    // Should be under the users/pre-login tree, not the bare root.
    assert!(
        oh_dir.starts_with(root.join("users")),
        "expected oh_dir under users/, got {oh_dir:?}"
    );
}

// ── parse_config_with_recovery ─────────────────────────────────

#[tokio::test]
async fn test_corrupt_config_no_backup_falls_back_to_defaults() {
    let tmp = tempfile::tempdir().unwrap();
    let config_path = tmp.path().join("config.toml");

    // Write invalid TOML — no .bak present.
    std::fs::write(&config_path, b"this is [not valid toml !!!").unwrap();

    let (result, was_corrupted) =
        parse_config_with_recovery(&config_path, "this is [not valid toml !!!").await;

    // Must return default config values.
    assert!(
        (result.default_temperature - 0.7).abs() < f64::EPSILON,
        "expected default temperature 0.7, got {}",
        result.default_temperature
    );
    assert!(was_corrupted, "parse failure must report corruption");
}

#[tokio::test]
async fn test_corrupt_config_valid_backup_recovers() {
    let tmp = tempfile::tempdir().unwrap();
    let config_path = tmp.path().join("config.toml");
    let backup_path = config_path.with_extension("toml.bak");

    // Write invalid primary TOML.
    std::fs::write(&config_path, b"not [ valid toml").unwrap();

    // Write a valid backup with a distinguishable field value.
    let bak_toml = "default_temperature = 1.5\n";
    std::fs::write(&backup_path, bak_toml).unwrap();

    let (result, was_corrupted) =
        parse_config_with_recovery(&config_path, "not [ valid toml").await;

    assert!(
        (result.default_temperature - 1.5).abs() < f64::EPSILON,
        "expected backup temperature 1.5, got {}",
        result.default_temperature
    );
    assert!(was_corrupted, "backup recovery must report corruption");
}

#[tokio::test]
async fn test_corrupt_config_corrupt_backup_falls_back_to_defaults() {
    let tmp = tempfile::tempdir().unwrap();
    let config_path = tmp.path().join("config.toml");
    let backup_path = config_path.with_extension("toml.bak");

    // Both files contain invalid TOML.
    std::fs::write(&config_path, b"invalid primary").unwrap();
    std::fs::write(&backup_path, b"invalid backup").unwrap();

    let (result, was_corrupted) = parse_config_with_recovery(&config_path, "invalid primary").await;

    assert!(
        (result.default_temperature - 0.7).abs() < f64::EPSILON,
        "expected default temperature 0.7 after double-corrupt, got {}",
        result.default_temperature
    );
    assert!(
        was_corrupted,
        "double-corrupt fallback must report corruption"
    );
}

#[test]
fn test_missing_default_temperature_uses_correct_default() {
    // TOML with no `default_temperature` field — serde should apply the
    // `default_temperature_value()` fn (0.7), not the bare Default (0.0).
    let toml_without_temperature = "api_url = \"https://example.com\"\n";
    let config: Config = toml::from_str(toml_without_temperature).unwrap();
    assert!(
        (config.default_temperature - 0.7).abs() < f64::EPSILON,
        "expected serde default 0.7 when field is absent, got {}",
        config.default_temperature
    );
}

#[tokio::test]
async fn test_save_preserves_backup_file() {
    let tmp = tempfile::tempdir().unwrap();
    let config_path = tmp.path().join("config.toml");
    let backup_path = tmp.path().join("config.toml.bak");

    let mut config = Config {
        config_path: config_path.clone(),
        workspace_dir: tmp.path().join("workspace"),
        action_dir: tmp.path().join("workspace"),
        ..Default::default()
    };

    // First save — creates config.toml (no prior file, so no .bak yet).
    config.save().await.unwrap();
    assert!(
        config_path.exists(),
        "config.toml must exist after first save"
    );

    // Second save — had_existing_config=true → .bak is written.
    config.save().await.unwrap();
    assert!(
        backup_path.exists(),
        "config.toml.bak must exist after second save"
    );
}

#[tokio::test]
async fn test_save_then_corrupt_then_recover() {
    let tmp = tempfile::tempdir().unwrap();
    let config_path = tmp.path().join("config.toml");

    let mut config = Config {
        config_path: config_path.clone(),
        workspace_dir: tmp.path().join("workspace"),
        action_dir: tmp.path().join("workspace"),
        default_temperature: 1.3,
        ..Default::default()
    };

    // First save writes config.toml.
    config.save().await.unwrap();
    // Second save copies to .bak and writes new primary.
    config.save().await.unwrap();

    // Verify .bak exists.
    let backup_path = config_path.with_extension("toml.bak");
    assert!(backup_path.exists(), ".bak must exist after save");

    // Now corrupt the primary.
    tokio::fs::write(&config_path, b"totally broken toml [[[")
        .await
        .unwrap();

    // Recovery should use .bak and return the saved temperature.
    let (recovered, was_corrupted) =
        parse_config_with_recovery(&config_path, "totally broken toml [[[").await;
    assert!(
        (recovered.default_temperature - 1.3).abs() < f64::EPSILON,
        "expected recovered temperature 1.3, got {}",
        recovered.default_temperature
    );
    assert!(was_corrupted, "recovery from .bak must report corruption");
}

#[test]
fn apply_env_overrides_commits_side_effects_to_runtime_proxy() {
    use crate::openhuman::config::schema::proxy::{runtime_proxy_config, set_runtime_proxy_config};

    // Hold the env lock so no other test races on proxy-related env vars.
    let _g = env_lock();
    clear_env(&[
        "OPENHUMAN_PROXY_ENABLED",
        "OPENHUMAN_HTTP_PROXY",
        "HTTP_PROXY",
        "OPENHUMAN_HTTPS_PROXY",
        "HTTPS_PROXY",
        "OPENHUMAN_ALL_PROXY",
        "ALL_PROXY",
    ]);

    // Snapshot the global runtime proxy config so we can restore it afterwards
    // and avoid leaking state into other tests.
    let previous_runtime = runtime_proxy_config();

    // Build a config with proxy fields set directly on the struct.
    // We cannot pre-configure via apply_env_overlay_with + a HashMapEnv and
    // then call apply_env_overrides(), because apply_env_overrides() internally
    // re-runs apply_env_overlay_with(&ProcessEnv) which reads the real process
    // environment — overwriting anything set via a HashMapEnv beforehand.
    // Setting fields directly ensures they survive the ProcessEnv overlay
    // (which only writes fields when the corresponding env var is present).
    let mut cfg = Config::default();
    cfg.proxy.http_proxy = Some("http://proxy.test:8080".to_string());
    cfg.proxy.enabled = true;

    // apply_env_overrides commits side effects: it calls set_runtime_proxy_config
    // with the current proxy config after the ProcessEnv overlay.
    cfg.apply_env_overrides();

    // `set_runtime_proxy_config` must have been called: the global should
    // reflect the proxy URL we set on cfg.proxy.
    let runtime = runtime_proxy_config();
    assert!(
        runtime.enabled,
        "runtime proxy must be enabled after apply_env_overrides"
    );
    assert_eq!(
        runtime.http_proxy.as_deref(),
        Some("http://proxy.test:8080"),
        "runtime proxy URL must match the value set on cfg.proxy"
    );

    // Restore the global runtime proxy state so this test doesn't bleed into
    // other tests that inspect runtime_proxy_config().
    set_runtime_proxy_config(previous_runtime);
}

// ── config recovery (load_or_init with corrupted config.toml) ───

/// Helper: write a file under a temp dir path.
async fn write_file(path: &std::path::Path, contents: &str) {
    tokio::fs::write(path, contents)
        .await
        .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
}

const CORRUPTED_TOML: &str = "{{{ bad table header\n";

fn env_lock() -> std::sync::MutexGuard<'static, ()> {
    ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}

async fn load_or_init_for_workspace(root: &std::path::Path) -> Config {
    let env = MapEnv::default().with("OPENHUMAN_WORKSPACE", root.to_str().unwrap());
    Config::load_or_init_with_env_lookup(root, &root.join("workspace"), &env)
        .await
        .unwrap()
}

#[tokio::test]
async fn load_or_init_recovers_from_backup_when_config_corrupted() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let config_path = root.join("config.toml");
    let backup_path = root.join("config.toml.bak");

    write_file(&config_path, CORRUPTED_TOML).await;
    write_file(
        &backup_path,
        r#"default_model = "gpt-recovery-test"
default_temperature = 0.7
"#,
    )
    .await;

    let config = load_or_init_for_workspace(root).await;

    assert_eq!(
        config.default_model.as_deref(),
        Some("gpt-recovery-test"),
        "must load values from backup"
    );

    // The recovered config must have been persisted to disk.
    let persisted = tokio::fs::read_to_string(&config_path).await.unwrap();
    assert!(
        persisted.contains("default_model"),
        "recovered config must be written back to config.toml: {persisted}"
    );

    // The .bak must still be intact (save() must NOT have overwritten it
    // with the corrupted primary).
    let bak_contents = tokio::fs::read_to_string(&backup_path).await.unwrap();
    assert!(
        bak_contents.contains("gpt-recovery-test"),
        "backup must not be overwritten by corrupted config during save: {bak_contents}"
    );
}

#[tokio::test]
async fn load_or_init_falls_back_to_defaults_when_backup_also_corrupted() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let config_path = root.join("config.toml");
    let backup_path = root.join("config.toml.bak");

    write_file(&config_path, CORRUPTED_TOML).await;
    write_file(&backup_path, CORRUPTED_TOML).await;

    let config = load_or_init_for_workspace(root).await;

    // Config::default() sets default_model = Some("reasoning-v1").
    assert_eq!(
        config.default_model.as_deref(),
        Some(crate::openhuman::config::schema::DEFAULT_MODEL),
        "must fall back to defaults when backup is also corrupted"
    );

    assert!(tokio::fs::try_exists(&config_path).await.unwrap());

    // The corrupted backup should not be deleted by the recovery flow.
    assert!(
        tokio::fs::try_exists(&backup_path).await.unwrap(),
        ".bak must not be deleted during recovery"
    );

    // The corrupted primary must have been renamed to .corrupted.
    let corrupted_path = root.join("config.toml.corrupted");
    assert!(
        tokio::fs::try_exists(&corrupted_path).await.unwrap(),
        "corrupted primary must be renamed to config.toml.corrupted"
    );
}

#[tokio::test]
async fn load_or_init_falls_back_to_defaults_when_no_backup() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let config_path = root.join("config.toml");
    write_file(&config_path, CORRUPTED_TOML).await;

    let config = load_or_init_for_workspace(root).await;

    assert_eq!(
        config.default_model.as_deref(),
        Some(crate::openhuman::config::schema::DEFAULT_MODEL),
        "must fall back to defaults when no backup exists"
    );

    assert!(tokio::fs::try_exists(&config_path).await.unwrap());

    // The corrupted primary must have been renamed to .corrupted.
    let corrupted_path = root.join("config.toml.corrupted");
    assert!(
        tokio::fs::try_exists(&corrupted_path).await.unwrap(),
        "corrupted primary must be renamed to config.toml.corrupted"
    );
}

#[tokio::test]
async fn load_or_init_does_not_trigger_recovery_on_valid_config() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    write_file(
        &root.join("config.toml"),
        r#"default_model = "gpt-valid"
default_temperature = 0.7
"#,
    )
    .await;

    let config = load_or_init_for_workspace(root).await;

    assert_eq!(
        config.default_model.as_deref(),
        Some("gpt-valid"),
        "valid config must load normally without recovery"
    );
}

#[tokio::test]
async fn load_or_init_reads_valid_config_through_retry_wrapper() {
    // OPENHUMAN-TAURI-9R regression: the config read is wrapped in
    // `retry_with_backoff_async`. Confirm the happy path is untouched —
    // a present, readable, valid config loads on the first attempt with
    // no behavior change from the wrapper.
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    write_file(
        &root.join("config.toml"),
        r#"default_model = "gpt-through-retry"
default_temperature = 0.5
"#,
    )
    .await;

    let config = load_or_init_for_workspace(root).await;

    assert_eq!(
        config.default_model.as_deref(),
        Some("gpt-through-retry"),
        "valid config must load on first attempt through the retry wrapper"
    );
}

#[cfg(unix)]
#[tokio::test]
async fn load_or_init_read_failure_embeds_path_in_error_context() {
    // OPENHUMAN-TAURI-9R (~8k events, Windows): the read at the
    // `config_path.exists()` branch raced `Config::save`'s atomic rename
    // and surfaced the opaque "Failed to read config file" with no path
    // or underlying cause. The fix retries transient Windows locking
    // errors AND embeds the config path in the context; #3962 additionally
    // surfaces the underlying io cause (`os error N`) through `{:#}`.
    //
    // Trigger a genuine non-transient read failure with a 0o000 (unreadable)
    // *regular* file — not a directory, which `impl_load` now rejects with a
    // distinct message before the read (see the directory guard / Codex P2).
    // `exists()` is true so we enter the read branch; `read_to_string` fails
    // with EACCES, which `is_transient_fs_error` does not retry. Skipped under
    // root, which ignores file permissions.
    use std::os::unix::fs::PermissionsExt;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();
    let config_path = root.join("config.toml");
    std::fs::write(&config_path, "default_temperature = 0.5\n").unwrap();
    std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o000)).unwrap();

    if std::fs::read_to_string(&config_path).is_ok() {
        return; // running as root — permissions are ignored, assertion is moot
    }

    let env = MapEnv::default().with("OPENHUMAN_WORKSPACE", root.to_str().unwrap());
    let err = Config::load_or_init_with_env_lookup(root, &root.join("workspace"), &env)
        .await
        .expect_err("reading an unreadable config.toml must fail");

    let msg = format!("{err:#}");
    assert!(
        msg.contains("Failed to read config file"),
        "error must carry the read-failure context: {msg}"
    );
    assert!(
        msg.contains("config.toml"),
        "error context must embed the config path so Sentry titles are triageable: {msg}"
    );
    assert!(
        msg.contains("os error"),
        "error must carry the underlying io cause via {{:#}} (#3962): {msg}"
    );
}

#[test]
fn redact_url_strips_basic_auth_and_query() {
    let out = redact_url_for_log(
        "https://user:token@api.example.com/v1/chat/completions?api_key=sk-x&debug=1",
    );
    assert!(!out.contains("token"), "got: {out}");
    assert!(!out.contains("sk-x"), "got: {out}");
    assert!(out.starts_with("https://api.example.com"), "got: {out}");
}

#[test]
fn redact_url_handles_plain_url() {
    let out = redact_url_for_log("https://api.openai.com/v1/chat/completions");
    assert_eq!(out, "https://api.openai.com/v1/chat/completions");
}

#[test]
fn redact_url_fallback_masks_userinfo_when_unparseable() {
    let out = redact_url_for_log("not-a-scheme://user:secret@host/path?token=1");
    assert!(!out.contains("secret"), "got: {out}");
    assert!(!out.contains("token=1"), "got: {out}");
}

#[test]
fn migrate_legacy_inference_url_moves_external_chat_completions() {
    let mut cfg = Config::default();
    cfg.api_url = Some("https://api.openai.com/v1/chat/completions".to_string());
    cfg.inference_url = None;
    migrate_legacy_inference_url(&mut cfg);
    assert_eq!(cfg.api_url, None);
    assert_eq!(
        cfg.inference_url.as_deref(),
        Some("https://api.openai.com/v1/chat/completions")
    );
}

#[test]
fn migrate_legacy_inference_url_clears_openhuman_backend_form() {
    let mut cfg = Config::default();
    cfg.api_url = Some("https://api.tinyhumans.ai/openai/v1/chat/completions".to_string());
    cfg.inference_url = None;
    migrate_legacy_inference_url(&mut cfg);
    // The OpenHuman host is the default backend — both fields end up None so
    // inference flows through the derived default `{backend}/openai/v1/...`.
    assert_eq!(cfg.api_url, None);
    assert_eq!(cfg.inference_url, None);
}

#[test]
fn migrate_legacy_inference_url_is_noop_when_inference_url_set() {
    let mut cfg = Config::default();
    cfg.api_url = Some("https://api.openai.com/v1/chat/completions".to_string());
    cfg.inference_url = Some("https://existing.example/v1/chat/completions".to_string());
    migrate_legacy_inference_url(&mut cfg);
    // Existing inference_url wins — api_url is left alone.
    assert_eq!(
        cfg.api_url.as_deref(),
        Some("https://api.openai.com/v1/chat/completions")
    );
    assert_eq!(
        cfg.inference_url.as_deref(),
        Some("https://existing.example/v1/chat/completions")
    );
}

#[test]
fn migrate_cloud_provider_slugs_routes_cloud_to_legacy_custom_when_primary_is_openhuman() {
    let mut cfg = Config::default();
    cfg.inference_url = Some("https://api.example.com/v1".into());
    cfg.primary_cloud = Some("p_oh".into());
    cfg.memory_provider = Some("cloud".into());
    cfg.reasoning_provider = Some("openhuman".into());
    cfg.cloud_providers = vec![
        crate::openhuman::config::schema::CloudProviderCreds {
            id: "p_oh".into(),
            slug: "openhuman".into(),
            label: "OpenHuman".into(),
            endpoint: "https://api.openhuman.ai/v1".into(),
            auth_style: crate::openhuman::config::schema::AuthStyle::OpenhumanJwt,
            ..Default::default()
        },
        crate::openhuman::config::schema::CloudProviderCreds {
            id: "p_custom".into(),
            slug: "custom".into(),
            label: "Custom".into(),
            endpoint: "https://api.example.com/v1/".into(),
            auth_style: crate::openhuman::config::schema::AuthStyle::Bearer,
            default_model: Some("gpt-4o-mini".into()),
            ..Default::default()
        },
    ];

    migrate_cloud_provider_slugs(&mut cfg);

    assert_eq!(cfg.memory_provider.as_deref(), Some("custom:"));
    assert_eq!(
        cfg.reasoning_provider.as_deref(),
        Some("openhuman"),
        "explicit OpenHuman routing must stay explicit"
    );
}

#[test]
fn migrate_cloud_provider_slugs_keeps_cloud_on_openhuman_without_legacy_custom() {
    let mut cfg = Config::default();
    cfg.primary_cloud = Some("p_oh".into());
    cfg.memory_provider = Some("cloud".into());
    cfg.cloud_providers = vec![crate::openhuman::config::schema::CloudProviderCreds {
        id: "p_oh".into(),
        slug: "openhuman".into(),
        label: "OpenHuman".into(),
        endpoint: "https://api.tinyhumans.ai/v1".into(),
        auth_style: crate::openhuman::config::schema::AuthStyle::OpenhumanJwt,
        ..Default::default()
    }];

    migrate_cloud_provider_slugs(&mut cfg);

    assert_eq!(cfg.memory_provider.as_deref(), Some("openhuman"));
}

#[test]
fn migrate_cloud_provider_slugs_does_not_pick_unmatched_custom_provider() {
    let mut cfg = Config::default();
    cfg.inference_url = Some("https://api.example.com/v1".into());
    cfg.primary_cloud = Some("p_oh".into());
    cfg.memory_provider = Some("cloud".into());
    cfg.cloud_providers = vec![
        crate::openhuman::config::schema::CloudProviderCreds {
            id: "p_oh".into(),
            slug: "openhuman".into(),
            label: "OpenHuman".into(),
            endpoint: "https://api.openhuman.ai/v1".into(),
            auth_style: crate::openhuman::config::schema::AuthStyle::OpenhumanJwt,
            ..Default::default()
        },
        crate::openhuman::config::schema::CloudProviderCreds {
            id: "p_other".into(),
            slug: "other".into(),
            label: "Other".into(),
            endpoint: "https://other.example.com/v1".into(),
            auth_style: crate::openhuman::config::schema::AuthStyle::Bearer,
            ..Default::default()
        },
    ];

    migrate_cloud_provider_slugs(&mut cfg);

    assert_eq!(cfg.memory_provider.as_deref(), Some("openhuman"));
}

/// Regression test for #1900: secrets are encrypted on save and decrypted on load.
///
/// Verifies that:
/// 1. Channel tokens are NOT stored in plaintext on disk
/// 2. The backup file (.bak) is encrypted even when overwriting a plaintext config
/// 3. Loading the config back decrypts secrets correctly
#[tokio::test]
async fn config_secrets_encrypted_on_save_decrypted_on_load() {
    let tmp = tempfile::tempdir().unwrap();
    let config_path = tmp.path().join("config.toml");
    let known_secret = "my-telegram-bot-token-abc123";

    // ── Phase 1: Simulate a pre-upgrade plaintext config on disk ──────
    // Write a raw TOML file containing the secret in plaintext, just like
    // a user who upgraded from a build before encryption was wired in.
    // save() requires the workspace dir to exist, so create it first.
    let workspace_dir = tmp.path().join("workspace");
    std::fs::create_dir_all(&workspace_dir).unwrap();

    let plaintext_toml = format!(
        r#"[channels_config.telegram]
bot_token = "{known_secret}"
allowed_users = ["@admin"]
"#
    );
    std::fs::write(&config_path, plaintext_toml.as_bytes()).unwrap();

    // Build a Config pointing at the existing plaintext file.
    // We set a fresh secret to force a changed value — the save path
    // will encrypt this new value and write it to disk.
    let mut cfg = Config {
        config_path: config_path.clone(),
        workspace_dir,
        ..Default::default()
    };
    cfg.channels_config.telegram = Some(TelegramConfig {
        bot_token: known_secret.to_string(),
        chat_id: None,
        allowed_users: vec!["@admin".to_string()],
        stream_mode: StreamMode::Off,
        draft_update_interval_ms: 1000,
        silent_streaming: true,
        mention_only: false,
    });

    // ── Phase 2: Save (encrypts + creates backup from old file) ──────
    cfg.save().await.unwrap();

    // The primary config must NOT contain the plaintext secret.
    let raw_contents = std::fs::read_to_string(&config_path).expect("config.toml should exist");
    assert!(
        !raw_contents.contains(known_secret),
        "SECURITY BUG: secret '{known_secret}' found in plaintext in config.toml!"
    );

    // The backup file is created by copying the old on-disk file BEFORE
    // the atomic replace. Our fix ensures the backup comes from the
    // encrypted bytes, NOT the plaintext original.
    let backup_path = config_path.with_extension("toml.bak");
    assert!(
        backup_path.exists(),
        "config.toml.bak should exist after overwriting an existing config"
    );
    let backup_contents = std::fs::read_to_string(&backup_path).unwrap();
    assert!(
        !backup_contents.contains(known_secret),
        "SECURITY BUG: secret found in plaintext in config.toml.bak!\n\
         Backup contents:\n{backup_contents}"
    );

    // ── Phase 3: Reload — secrets must decrypt back correctly ────────
    let reloaded = load_or_init_for_workspace(tmp.path()).await;
    let reloaded_token = reloaded
        .channels_config
        .telegram
        .as_ref()
        .map(|t| t.bot_token.as_str());
    assert_eq!(
        reloaded_token,
        Some(known_secret),
        "decrypt path broken: reloaded bot_token '{reloaded_token:?}' \
         does not match original '{known_secret}'"
    );
}

/// Regression for keyring-loss scenario: if a channel token was encrypted with
/// a key that is no longer accessible (e.g. keyring reset, machine migration),
/// config load must NOT fail hard. The field should be cleared and a warning
/// logged, so the rest of the app continues to work.
#[tokio::test]
async fn config_load_succeeds_when_decryption_key_inaccessible() {
    let tmp = tempfile::tempdir().unwrap();
    let config_path = tmp.path().join("config.toml");
    let workspace_dir = tmp.path().join("workspace");
    std::fs::create_dir_all(&workspace_dir).unwrap();

    // Write a config whose discord.bot_token is encrypted with a key from a
    // *different* workspace so the current SecretStore (keyed to `tmp`) cannot
    // decrypt it. The `enc2:` prefix makes `is_encrypted()` return true.
    // The hex blob is garbage — intentionally undecryptable.
    let stale_ciphertext =
        "enc2:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
    let toml_content = format!(
        r#"[secrets]
encrypt = true

[channels_config.discord]
bot_token = "{stale_ciphertext}"
"#
    );
    std::fs::write(&config_path, toml_content.as_bytes()).unwrap();

    // Config load must succeed even though the token cannot be decrypted.
    let reloaded = load_or_init_for_workspace(tmp.path()).await;

    // Discord config should be cleared (None bot_token → channel won't start)
    // rather than crashing the entire config load.
    let discord_token = reloaded
        .channels_config
        .discord
        .as_ref()
        .map(|d| d.bot_token.as_str());
    assert!(
        discord_token.map_or(true, |t| t.is_empty()),
        "Expected discord.bot_token to be cleared after decryption failure, got: {discord_token:?}"
    );
}

/// Backwards-compatibility regression for #1900: a pre-upgrade `config.toml`
/// that contains plaintext secrets (written by a build from before encryption
/// was wired in) must continue to load with `secrets.encrypt = true`. The
/// load path should hand the raw plaintext to channel code rather than
/// erroring or returning a ciphertext placeholder. The next `save()` is what
/// migrates the values to `enc2:` on disk.
#[tokio::test]
async fn plaintext_legacy_config_still_loads_with_encryption_enabled() {
    let tmp = tempfile::tempdir().unwrap();
    let config_path = tmp.path().join("config.toml");
    let known_secret = "legacy-plaintext-bot-token-xyz789";

    let plaintext_toml = format!(
        r#"[secrets]
encrypt = true

[channels_config.telegram]
bot_token = "{known_secret}"
allowed_users = ["@admin"]
"#
    );
    std::fs::write(&config_path, plaintext_toml.as_bytes()).unwrap();

    let reloaded = load_or_init_for_workspace(tmp.path()).await;
    let reloaded_token = reloaded
        .channels_config
        .telegram
        .as_ref()
        .map(|t| t.bot_token.as_str());
    assert_eq!(
        reloaded_token,
        Some(known_secret),
        "backwards-compat broken: legacy plaintext bot_token did not load as cleartext \
         (got {reloaded_token:?})"
    );
}

// ── resolve_action_dir precedence (env > override > default), issue #3240 ──────

#[test]
fn resolve_action_dir_env_beats_override_and_default() {
    let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    unsafe {
        std::env::set_var(ACTION_DIR_ENV_VAR, "/tmp/env-action-dir");
    }
    let over = Some(PathBuf::from("/tmp/override-action-dir"));
    assert_eq!(
        resolve_action_dir(&over),
        PathBuf::from("/tmp/env-action-dir"),
        "env var must win over a persisted override"
    );
    unsafe {
        std::env::remove_var(ACTION_DIR_ENV_VAR);
    }
}

#[test]
fn resolve_action_dir_override_beats_default_when_no_env() {
    let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    unsafe {
        std::env::remove_var(ACTION_DIR_ENV_VAR);
    }
    let over = Some(PathBuf::from("/tmp/override-action-dir"));
    assert_eq!(
        resolve_action_dir(&over),
        PathBuf::from("/tmp/override-action-dir"),
        "override must be used when no env var is set"
    );
}

#[test]
fn resolve_action_dir_falls_back_to_default_when_none() {
    let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    unsafe {
        std::env::remove_var(ACTION_DIR_ENV_VAR);
    }
    assert_eq!(
        resolve_action_dir(&None),
        default_projects_dir(),
        "no env + no override must fall back to the default projects dir"
    );
}

#[test]
fn resolve_action_dir_blank_env_does_not_pin() {
    let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    unsafe {
        std::env::set_var(ACTION_DIR_ENV_VAR, "   ");
    }
    let over = Some(PathBuf::from("/tmp/override-action-dir"));
    assert_eq!(
        resolve_action_dir(&over),
        PathBuf::from("/tmp/override-action-dir"),
        "blank env var must be ignored so the override still applies"
    );
    unsafe {
        std::env::remove_var(ACTION_DIR_ENV_VAR);
    }
}

#[test]
fn resolve_action_dir_rejects_relative_override() {
    let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    unsafe {
        std::env::remove_var(ACTION_DIR_ENV_VAR);
    }
    let over = Some(PathBuf::from("relative/projects"));
    assert_eq!(
        resolve_action_dir(&over),
        default_projects_dir(),
        "relative override must be ignored, falling back to default"
    );
}

#[test]
fn resolve_action_dir_rejects_empty_override() {
    let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
    unsafe {
        std::env::remove_var(ACTION_DIR_ENV_VAR);
    }
    let over = Some(PathBuf::from(""));
    assert_eq!(
        resolve_action_dir(&over),
        default_projects_dir(),
        "empty override must be ignored, falling back to default"
    );
}
