use crate::Result;
use crate::config::miserc;
use crate::env_diff::{EnvDiff, EnvDiffOperation, EnvDiffPatches, EnvMap};
use crate::file::replace_path;
use crate::shell::ShellType;
use crate::{cli::args::ToolArg, file::display_path};
use eyre::Context;
use indexmap::IndexSet;
use itertools::Itertools;
use log::LevelFilter;
pub use std::env::*;
use std::process;
use std::sync::LazyLock as Lazy;
use std::sync::RwLock;
use std::{
    collections::{HashMap, HashSet},
    ffi::OsStr,
    sync::Mutex,
};
use std::{path::Path, string::ToString};
use std::{path::PathBuf, sync::atomic::AtomicBool};

pub static ARGS: RwLock<Vec<String>> = RwLock::new(vec![]);
pub static TOOL_ARGS: RwLock<Vec<ToolArg>> = RwLock::new(vec![]);
pub const MISE_INSTALL_VERSION_ENV_VAR: &str = "MISE_INSTALL_VERSION";
pub const MISE_TOOL_VERSION_ENV_VAR: &str = "MISE_TOOL_VERSION";
pub const NON_TOOL_VERSION_ENV_VARS: &[&str] =
    &[MISE_INSTALL_VERSION_ENV_VAR, MISE_TOOL_VERSION_ENV_VAR];
#[cfg(unix)]
pub static SHELL: Lazy<String> = Lazy::new(|| var("SHELL").unwrap_or_else(|_| "sh".into()));
#[cfg(windows)]
pub static SHELL: Lazy<String> = Lazy::new(|| var("COMSPEC").unwrap_or_else(|_| "cmd.exe".into()));
pub static MISE_SHELL: Lazy<Option<ShellType>> =
    Lazy::new(|| detect_shell(var("MISE_SHELL").ok(), var("SHELL").ok(), &SHELL));

/// Which shell mise should speak, from the environment.
///
/// `SHELL` is consulted here but deliberately *not* through [`SHELL`], which on Windows reads
/// `COMSPEC`. Git Bash, MSYS2 and Cygwin all set `SHELL` to a real shell there, and
/// [`ShellType::from_str`] already understands the form they use, but nothing ever looked. Reading
/// it through [`SHELL`] instead would be wrong: [`SHELL_COMMAND_FLAG`] is `/c` on Windows and
/// `mise exec -c` and `mise en` pair the two, so `bash.exe /c …` is what that would run.
///
/// Split out from the `Lazy` because that is process-wide and reads the real environment, so the
/// precedence below cannot be exercised from a test any other way.
fn detect_shell(
    mise_shell: Option<String>,
    shell_var: Option<String>,
    fallback: &str,
) -> Option<ShellType> {
    // What `mise activate` exports, so it decides on its own. Set but unparseable is an answer,
    // not a reason to start guessing.
    if let Some(s) = mise_shell {
        return s.parse().ok();
    }
    // On unix `SHELL` *is* the fallback, so consulting it separately would be the same lookup
    // twice. On Windows it is the one the fallback cannot reach.
    if cfg!(windows)
        && let Some(st) = shell_var.and_then(|s| s.parse().ok())
    {
        return Some(st);
    }
    fallback.parse().ok()
}
#[cfg(unix)]
pub static SHELL_COMMAND_FLAG: &str = "-c";
#[cfg(windows)]
pub static SHELL_COMMAND_FLAG: &str = "/c";

// paths and directories
#[cfg(test)]
pub static HOME: Lazy<PathBuf> =
    Lazy::new(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test"));
#[cfg(not(test))]
pub static HOME: Lazy<PathBuf> = Lazy::new(|| {
    homedir::my_home()
        .ok()
        .flatten()
        .unwrap_or_else(|| PathBuf::from("/"))
});

pub static EDITOR: Lazy<String> =
    Lazy::new(|| var("VISUAL").unwrap_or_else(|_| var("EDITOR").unwrap_or_else(|_| "nano".into())));

#[cfg(macos)]
pub static XDG_CACHE_HOME: Lazy<PathBuf> =
    Lazy::new(|| var_path("XDG_CACHE_HOME").unwrap_or_else(|| HOME.join("Library/Caches")));
#[cfg(windows)]
pub static XDG_CACHE_HOME: Lazy<PathBuf> = Lazy::new(|| {
    var_path("XDG_CACHE_HOME")
        .or_else(|| var_path("TEMP"))
        .unwrap_or_else(temp_dir)
});
#[cfg(all(not(windows), not(macos)))]
pub static XDG_CACHE_HOME: Lazy<PathBuf> =
    Lazy::new(|| var_path("XDG_CACHE_HOME").unwrap_or_else(|| HOME.join(".cache")));
pub static XDG_CONFIG_HOME: Lazy<PathBuf> =
    Lazy::new(|| var_path("XDG_CONFIG_HOME").unwrap_or_else(|| HOME.join(".config")));
#[cfg(unix)]
pub static XDG_DATA_HOME: Lazy<PathBuf> =
    Lazy::new(|| var_path("XDG_DATA_HOME").unwrap_or_else(|| HOME.join(".local").join("share")));
#[cfg(windows)]
pub static XDG_DATA_HOME: Lazy<PathBuf> = Lazy::new(|| {
    var_path("XDG_DATA_HOME")
        .or(var_path("LOCALAPPDATA"))
        .unwrap_or_else(|| HOME.join("AppData").join("Local"))
});
pub static XDG_STATE_HOME: Lazy<PathBuf> =
    Lazy::new(|| var_path("XDG_STATE_HOME").unwrap_or_else(|| HOME.join(".local").join("state")));

/// `%LOCALAPPDATA%`. What the `adrg/xdg` Go package resolves `XDG_CONFIG_HOME` to on Windows,
/// so it is where CLIs built on it — `glab` among them — keep their config.
///
/// Roaming `%APPDATA%` deliberately has no counterpart here. Its only consumer is the `gh`
/// lookup, which has to reproduce go-gh's literal `os.Getenv("AppData")` test — including
/// falling through to `~/.config/gh` when the variable is unset — so a synthesized default
/// would make mise look somewhere gh never would.
///
/// An empty `%LOCALAPPDATA%` falls back rather than resolving to an empty path — see
/// [`var_path`] — which is also what `adrg/xdg` does (`dir != "" && filepath.IsAbs(dir)`).
#[cfg(windows)]
pub static LOCAL_APPDATA: Lazy<PathBuf> =
    Lazy::new(|| var_path("LOCALAPPDATA").unwrap_or_else(|| HOME.join("AppData").join("Local")));

/// control display of "friendly" errors - defaults to release mode behavior unless overridden
pub static MISE_FRIENDLY_ERROR: Lazy<bool> = Lazy::new(|| {
    if var_is_true("MISE_FRIENDLY_ERROR") {
        true
    } else if var_is_false("MISE_FRIENDLY_ERROR") {
        false
    } else {
        // default behavior: friendly in release mode unless debug logging
        !cfg!(debug_assertions) && log::max_level() < log::LevelFilter::Debug
    }
});
pub static MISE_TOOL_STUB: Lazy<bool> =
    Lazy::new(|| ARGS.read().unwrap().get(1).map(|s| s.as_str()) == Some("tool-stub"));
pub static MISE_NO_CONFIG: Lazy<bool> = Lazy::new(|| var_is_true("MISE_NO_CONFIG"));
pub static MISE_NO_ENV: Lazy<bool> = Lazy::new(|| var_is_true("MISE_NO_ENV"));
pub static MISE_NO_HOOKS: Lazy<bool> = Lazy::new(|| var_is_true("MISE_NO_HOOKS"));
pub static MISE_PROGRESS_TRACE: Lazy<bool> = Lazy::new(|| var_is_true("MISE_PROGRESS_TRACE"));
pub static MISE_CACHE_DIR: Lazy<PathBuf> =
    Lazy::new(|| var_path("MISE_CACHE_DIR").unwrap_or_else(|| XDG_CACHE_HOME.join("mise")));
pub static MISE_CONFIG_DIR: Lazy<PathBuf> =
    Lazy::new(|| var_path("MISE_CONFIG_DIR").unwrap_or_else(|| XDG_CONFIG_HOME.join("mise")));
/// The default config directory location (XDG_CONFIG_HOME/mise), used to filter out
/// configs from this location when MISE_CONFIG_DIR is set to a different path
pub static MISE_DEFAULT_CONFIG_DIR: Lazy<PathBuf> = Lazy::new(|| XDG_CONFIG_HOME.join("mise"));
/// True if MISE_CONFIG_DIR was explicitly set to a non-default location
pub static MISE_CONFIG_DIR_OVERRIDDEN: Lazy<bool> = Lazy::new(|| {
    var_path("MISE_CONFIG_DIR").is_some() && *MISE_CONFIG_DIR != *MISE_DEFAULT_CONFIG_DIR
});
pub static MISE_DATA_DIR: Lazy<PathBuf> =
    Lazy::new(|| var_path("MISE_DATA_DIR").unwrap_or_else(|| XDG_DATA_HOME.join("mise")));
pub static MISE_STATE_DIR: Lazy<PathBuf> =
    Lazy::new(|| var_path("MISE_STATE_DIR").unwrap_or_else(|| XDG_STATE_HOME.join("mise")));
pub static MISE_TMP_DIR: Lazy<PathBuf> =
    Lazy::new(|| var_path("MISE_TMP_DIR").unwrap_or_else(|| temp_dir().join("mise")));
pub static MISE_SYSTEM_CONFIG_DIR: Lazy<PathBuf> = Lazy::new(|| {
    var_path("MISE_SYSTEM_CONFIG_DIR")
        .or_else(|| var_path("MISE_SYSTEM_DIR"))
        .unwrap_or_else(|| PathBuf::from("/etc/mise"))
});

// data subdirs
pub static MISE_INSTALLS_DIR: Lazy<PathBuf> =
    Lazy::new(|| var_path("MISE_INSTALLS_DIR").unwrap_or_else(|| MISE_DATA_DIR.join("installs")));
pub static MISE_DOWNLOADS_DIR: Lazy<PathBuf> =
    Lazy::new(|| var_path("MISE_DOWNLOADS_DIR").unwrap_or_else(|| MISE_DATA_DIR.join("downloads")));
pub static MISE_PLUGINS_DIR: Lazy<PathBuf> =
    Lazy::new(|| var_path("MISE_PLUGINS_DIR").unwrap_or_else(|| MISE_DATA_DIR.join("plugins")));
pub static MISE_SHIMS_DIR: Lazy<PathBuf> =
    Lazy::new(|| var_path("MISE_SHIMS_DIR").unwrap_or_else(|| MISE_DATA_DIR.join("shims")));
/// System-level data directory (like MISE_DATA_DIR but for system-wide tools).
pub static MISE_SYSTEM_DATA_DIR: Lazy<PathBuf> = Lazy::new(|| {
    var_path("MISE_SYSTEM_DATA_DIR").unwrap_or_else(|| PathBuf::from("/usr/local/share/mise"))
});
/// System-level installs directory, derived from MISE_SYSTEM_DATA_DIR.
pub static MISE_SYSTEM_INSTALLS_DIR: Lazy<PathBuf> =
    Lazy::new(|| MISE_SYSTEM_DATA_DIR.join("installs"));

/// Extra shared install directories parsed from the environment variable.
/// This is the early/fallback source; prefer `shared_install_dirs()` which also
/// reads from Settings (config files) when available.
static MISE_SHARED_INSTALL_DIRS_ENV: Lazy<Vec<PathBuf>> = Lazy::new(|| {
    var_os("MISE_SHARED_INSTALL_DIRS")
        .map(|v| {
            std::env::split_paths(&v)
                .filter(|p| !p.as_os_str().is_empty())
                .map(replace_path)
                .collect()
        })
        .unwrap_or_default()
});

/// Returns the list of shared install directories to search.
/// Includes the system installs dir (`MISE_SYSTEM_DATA_DIR/installs`) plus any
/// user-configured dirs from Settings (config files) or the environment variable.
/// The user's primary install dir is NOT included here — it is checked separately.
pub fn shared_install_dirs() -> Vec<PathBuf> {
    use crate::config::Settings;
    let user_dirs = if let std::result::Result::Ok(settings) = Settings::try_get()
        && let Some(ref dirs) = settings.shared_install_dirs
        && !dirs.is_empty()
    {
        dirs.clone()
    } else {
        MISE_SHARED_INSTALL_DIRS_ENV.clone()
    };
    let system = &*MISE_SYSTEM_INSTALLS_DIR;
    // System dir first (if it exists and isn't the user's own install dir),
    // then user-configured dirs.
    let mut result = Vec::new();
    if system.is_dir() && *system != *MISE_INSTALLS_DIR {
        result.push(system.clone());
    }
    result.extend(user_dirs);
    result
}

/// Early-boot variant used by install_state::init_tools() before Settings is loaded.
pub fn shared_install_dirs_early() -> Vec<PathBuf> {
    let system = &*MISE_SYSTEM_INSTALLS_DIR;
    let mut result = Vec::new();
    if system.is_dir() && *system != *MISE_INSTALLS_DIR {
        result.push(system.clone());
    }
    result.extend(MISE_SHARED_INSTALL_DIRS_ENV.iter().cloned());
    result
}

/// Categorize an install path as system, shared, or local.
pub fn install_path_category(path: &Path) -> InstallPathCategory {
    if path.starts_with(&*MISE_SYSTEM_INSTALLS_DIR) {
        InstallPathCategory::System
    } else if shared_install_dirs().iter().any(|d| path.starts_with(d)) {
        InstallPathCategory::Shared
    } else {
        InstallPathCategory::Local
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallPathCategory {
    /// Primary user install dir
    Local,
    /// System-level (/usr/local/share/mise/installs)
    System,
    /// User-configured shared dir
    Shared,
}

/// Look up a tool version in shared install directories.
/// `tool_dir_name` should be the kebab-cased directory name (e.g. from `ba.installs_path`).
/// Returns the first shared path where `<shared_dir>/<tool_dir_name>/<pathname>` exists,
/// or `primary_path` if not found in any shared directory.
pub fn find_in_shared_installs(
    primary_path: PathBuf,
    tool_dir_name: &str,
    pathname: &str,
) -> PathBuf {
    if !primary_path.exists() {
        for shared_dir in shared_install_dirs() {
            let shared_path = shared_dir.join(tool_dir_name).join(pathname);
            if shared_path.exists() {
                return shared_path;
            }
        }
    }
    primary_path
}

pub static MISE_DEFAULT_TOOL_VERSIONS_FILENAME: Lazy<String> = Lazy::new(|| {
    var("MISE_DEFAULT_TOOL_VERSIONS_FILENAME")
        .ok()
        .or(MISE_OVERRIDE_TOOL_VERSIONS_FILENAMES
            .as_ref()
            .and_then(|v| v.first().cloned()))
        .or(var("MISE_DEFAULT_TOOL_VERSIONS_FILENAME").ok())
        .unwrap_or_else(|| ".tool-versions".into())
});
pub static MISE_DEFAULT_CONFIG_FILENAME: Lazy<String> = Lazy::new(|| {
    var("MISE_DEFAULT_CONFIG_FILENAME")
        .ok()
        .or(MISE_OVERRIDE_CONFIG_FILENAMES.first().cloned())
        .unwrap_or_else(|| "mise.toml".into())
});
pub static MISE_OVERRIDE_TOOL_VERSIONS_FILENAMES: Lazy<Option<IndexSet<String>>> =
    Lazy::new(|| match var("MISE_OVERRIDE_TOOL_VERSIONS_FILENAMES") {
        Ok(v) if v == "none" => Some([].into()),
        Ok(v) => Some(split_colon_list(&v)),
        Err(_) => {
            miserc::get_override_tool_versions_filenames().map(|v| v.iter().cloned().collect())
        }
    });
pub static MISE_OVERRIDE_CONFIG_FILENAMES: Lazy<IndexSet<String>> =
    Lazy::new(|| match var("MISE_OVERRIDE_CONFIG_FILENAMES") {
        Ok(v) => split_colon_list(&v),
        Err(_) => miserc::get_override_config_filenames()
            .map(|v| v.iter().cloned().collect())
            .unwrap_or_default(),
    });
pub static MISE_ENV: Lazy<Vec<String>> = Lazy::new(|| environment(&ARGS.read().unwrap()));

/// The tri-state auto_env setting: MISE_AUTO_ENV env var > .miserc.toml > unset
pub fn auto_env_setting() -> Option<bool> {
    if var_is_true("MISE_AUTO_ENV") {
        Some(true)
    } else if var_is_false("MISE_AUTO_ENV") {
        Some(false)
    } else {
        miserc::get_auto_env()
    }
}

/// Default for auto_env when the setting is unset: off until mise 2027.6.0
pub(crate) fn auto_env_default_for_version(v: &versions::Versioning) -> bool {
    *v >= versions::Versioning::new("2027.6.0").unwrap()
}

/// Platform-derived environment names, regardless of whether auto_env is enabled.
/// Ordered least to most specific: os family ("unix"), os, "{os}-{arch}".
/// On Windows the family equals the os so it dedupes to ["windows", "windows-{arch}"].
pub fn platform_env_names() -> Vec<String> {
    let mut names: Vec<String> = vec![];
    for name in [
        consts::FAMILY.to_string(),
        crate::cli::version::OS.to_string(),
        format!(
            "{}-{}",
            *crate::cli::version::OS,
            *crate::cli::version::ARCH
        ),
    ] {
        if !names.contains(&name) {
            names.push(name);
        }
    }
    names
}

/// Platform environments active for config file discovery and lockfile selection.
/// Empty unless auto_env is enabled. Names already in MISE_ENV are excluded so
/// explicit environments keep their user-specified (higher) precedence.
/// These are deliberately not part of MISE_ENV: they do not affect the
/// `{{ mise_env }}` template variable or MISE_ENV propagation to subprocesses.
pub static AUTO_ENV_NAMES: Lazy<Vec<String>> = Lazy::new(|| {
    let enabled =
        auto_env_setting().unwrap_or_else(|| auto_env_default_for_version(&crate::cli::version::V));
    if !enabled {
        return vec![];
    }
    platform_env_names()
        .into_iter()
        .filter(|name| !MISE_ENV.contains(name))
        .collect()
});

/// Auto platform envs followed by explicit MISE_ENV entries, for "later wins"
/// consumers like config filename enumeration.
pub static MISE_ENV_WITH_AUTO: Lazy<Vec<String>> = Lazy::new(|| {
    AUTO_ENV_NAMES
        .iter()
        .chain(MISE_ENV.iter())
        .cloned()
        .collect()
});

pub static MISE_GLOBAL_CONFIG_FILE: Lazy<Option<PathBuf>> =
    Lazy::new(|| var_path("MISE_GLOBAL_CONFIG_FILE").or_else(|| var_path("MISE_CONFIG_FILE")));
pub static MISE_GLOBAL_CONFIG_ROOT: Lazy<PathBuf> =
    Lazy::new(|| var_path("MISE_GLOBAL_CONFIG_ROOT").unwrap_or_else(|| HOME.to_path_buf()));
pub static MISE_SYSTEM_CONFIG_FILE: Lazy<Option<PathBuf>> =
    Lazy::new(|| var_path("MISE_SYSTEM_CONFIG_FILE"));
pub static MISE_IGNORED_CONFIG_PATHS: Lazy<Vec<PathBuf>> = Lazy::new(|| {
    var_os("MISE_IGNORED_CONFIG_PATHS")
        .map(|v| {
            split_paths(&v)
                .filter(|p| !p.as_os_str().is_empty())
                .map(replace_path)
                .collect()
        })
        .or_else(|| {
            miserc::get_ignored_config_paths()
                .map(|paths| paths.iter().cloned().map(replace_path).collect())
        })
        .unwrap_or_default()
});
pub static MISE_CEILING_PATHS: Lazy<HashSet<PathBuf>> = Lazy::new(|| {
    var_os("MISE_CEILING_PATHS")
        .map(|v| {
            split_paths(&v)
                .filter(|p| !p.as_os_str().is_empty())
                .map(replace_path)
                .collect()
        })
        .or_else(|| {
            miserc::get_ceiling_paths()
                .map(|paths| paths.iter().cloned().map(replace_path).collect())
        })
        .unwrap_or_default()
});
pub static MISE_USE_TOML: Lazy<bool> = Lazy::new(|| !var_is_false("MISE_USE_TOML"));
pub static MISE_LIST_ALL_VERSIONS: Lazy<bool> = Lazy::new(|| var_is_true("MISE_LIST_ALL_VERSIONS"));
pub static ARGV0: Lazy<String> = Lazy::new(|| ARGS.read().unwrap()[0].to_string());
pub static MISE_BIN_NAME: Lazy<&str> = Lazy::new(|| filename(&ARGV0));
pub static MISE_LOG_FILE: Lazy<Option<PathBuf>> = Lazy::new(|| var_path("MISE_LOG_FILE"));
pub static MISE_LOG_FILE_LEVEL: Lazy<Option<LevelFilter>> = Lazy::new(log_file_level);
fn find_in_tree(base: &Path, rels: &[&[&str]]) -> Option<PathBuf> {
    for rel in rels {
        let mut p = base.to_path_buf();
        for part in *rel {
            p = p.join(part);
        }
        if p.exists() {
            return Some(p);
        }
    }
    None
}

fn mise_install_base() -> Option<PathBuf> {
    std::fs::canonicalize(&*MISE_BIN)
        .ok()
        .and_then(|p| p.parent().map(|p| p.to_path_buf()))
        .and_then(|p| p.parent().map(|p| p.to_path_buf()))
}

pub static MISE_SELF_UPDATE_INSTRUCTIONS: Lazy<Option<PathBuf>> = Lazy::new(|| {
    if let Some(p) = var_path("MISE_SELF_UPDATE_INSTRUCTIONS") {
        return Some(p);
    }
    let base = mise_install_base()?;
    // search lib/, lib/mise/, lib64/mise/
    find_in_tree(
        &base,
        &[
            &["lib", "mise-self-update-instructions.toml"],
            &["lib", "mise", "mise-self-update-instructions.toml"],
            &["lib64", "mise", "mise-self-update-instructions.toml"],
        ],
    )
});
#[cfg(feature = "self_update")]
pub static MISE_SELF_UPDATE_AVAILABLE: Lazy<Option<bool>> = Lazy::new(|| {
    if var_is_true("MISE_SELF_UPDATE_AVAILABLE") {
        Some(true)
    } else if var_is_false("MISE_SELF_UPDATE_AVAILABLE") {
        Some(false)
    } else {
        None
    }
});
#[cfg(feature = "self_update")]
pub static MISE_SELF_UPDATE_DISABLED_PATH: Lazy<Option<PathBuf>> = Lazy::new(|| {
    let base = mise_install_base()?;
    find_in_tree(
        &base,
        &[
            &["lib", ".disable-self-update"],
            &["lib", "mise", ".disable-self-update"],
            &["lib64", "mise", ".disable-self-update"],
        ],
    )
});
pub static MISE_LOG_HTTP: Lazy<bool> = Lazy::new(|| var_is_true("MISE_LOG_HTTP"));
pub static MISE_LOG_VERBOSE_DEPS: Lazy<bool> = Lazy::new(|| var_is_true("MISE_LOG_VERBOSE_DEPS"));

pub static __USAGE: Lazy<Option<String>> = Lazy::new(|| var("__USAGE").ok());

// true if running inside a shim
pub static __MISE_SHIM: Lazy<bool> = Lazy::new(|| var_is_true("__MISE_SHIM"));

/// Absolute path of the shim that delegated to mise. Unlike `MISE_SHIMS_DIR`,
/// this remains reliable when a parent process preserves PATH but filters out
/// mise's directory configuration variables.
pub const MISE_SHIM_PATH_ENV: &str = "__MISE_SHIM_PATH";
pub static MISE_SHIM_PATH: Lazy<RwLock<Option<PathBuf>>> =
    Lazy::new(|| RwLock::new(var_path(MISE_SHIM_PATH_ENV)));

// true if the current process is running as a shim (not direct mise invocation)
pub static IS_RUNNING_AS_SHIM: Lazy<bool> = Lazy::new(|| {
    // When running tests, always treat as direct mise invocation
    // to avoid interfering with test expectations
    if cfg!(test) {
        return false;
    }

    // Check if running as tool stub
    if *MISE_TOOL_STUB {
        return true;
    }

    let bin_name = *MISE_BIN_NAME;
    !is_mise_binary(bin_name)
});

/// Returns true if the given binary name refers to mise itself (not a shim).
/// Handles "mise", "mise.exe", "mise.bat", "mise.cmd", "mise-doctor", etc.
///
/// The comparison ignores case on Windows only. Its filesystem does too, so `MISE.EXE` starts the
/// same file as `mise.exe` and reaches `argv[0]` with whatever casing the caller wrote — and a
/// case-sensitive test then sent mise through [`crate::shims::handle_shim`] against itself. On unix
/// they are two different files, so a shim genuinely named `MISE` has to stay a shim.
pub fn is_mise_binary(bin_name: &str) -> bool {
    let is_mise = |s: &str| {
        if cfg!(windows) {
            s.eq_ignore_ascii_case("mise")
        } else {
            s == "mise"
        }
    };
    // Equivalent to matching "mise" plus the "mise." and "mise-" prefixes, with the case rule
    // applied in one place rather than three.
    is_mise(bin_name)
        || bin_name
            .split_once(['.', '-'])
            .is_some_and(|(stem, _)| is_mise(stem))
}

/// The suffixes `self-replace` gives the copies it makes of the running executable on Windows.
/// `get_temp_executable_name` builds `.{exe stem}.{32 random}{suffix}`.
///
/// This and the predicates below are compiled off Windows too, like
/// [`crate::path::windows_path_list_to_unix`], so they stay unit-tested everywhere rather than only
/// on the platform that runs them. Their callers are all `#[cfg(windows)]`, hence the allow.
#[cfg_attr(not(windows), allow(dead_code))]
pub const SELF_REPLACE_SUFFIXES: [&str; 2] = [".__selfdelete__.exe", ".__relocated__.exe"];

/// Whether `bin_name` is a copy of *this* executable that `self-replace` made while updating.
///
/// It is mise under a generated name, not a shim — but [`is_mise_binary`] cannot tell, because the
/// name **begins with a `.`**, so `split_once(['.', '-'])` hands back an empty stem and `is_mise("")`
/// is false. mise then walks into `handle_shim` and reports the name as a broken shim, advising the
/// user to reinstall a tool that was never uninstalled.
///
/// Takes the stem rather than reading `current_exe` so it stays pure, and requires it so that a
/// *different* application's orphan — `.othertool.….__selfdelete__.exe` — is not claimed as ours.
///
/// The random segment is checked too, because the caller that acts on this **deletes** the file:
/// anything short of the generated shape is somebody else's and stays where it is.
#[cfg_attr(not(windows), allow(dead_code))]
pub fn is_self_replace_helper(bin_name: &str, exe_stem: &str) -> bool {
    let prefix = format!(".{exe_stem}.");
    // Case-insensitively on Windows, and only for the stem. Measured: `current_exe()` hands back
    // whatever casing the caller used to start the process — `CASEPROBE.EXE` gives a stem of
    // `CASEPROBE` — so an update launched as `MISE.EXE` writes `.MISE.….__selfdelete__.exe`, and
    // the next one launched as `mise.exe` would fail to recognise its own orphan and leave it
    // there for good. The suffix and the random segment are generated by the crate and are always
    // lowercase, so only the stem can vary. Same rule, same reason, as [`is_mise_binary`].
    let matches = bin_name.get(..prefix.len()).is_some_and(|head| {
        if cfg!(windows) {
            head.eq_ignore_ascii_case(&prefix)
        } else {
            head == prefix
        }
    });
    if !matches {
        return false;
    }
    let rest = &bin_name[prefix.len()..];
    SELF_REPLACE_SUFFIXES.iter().any(|suffix| {
        rest.strip_suffix(suffix)
            .is_some_and(is_self_replace_random_segment)
    })
}

/// `self-replace` fills this many characters from `fastrand`'s `lowercase()`:
/// `for _ in 0..32 { file_name.push(rng.lowercase()) }` in `get_temp_executable_name`.
#[cfg_attr(not(windows), allow(dead_code))]
pub const SELF_REPLACE_RANDOM_LEN: usize = 32;

/// Exactly the segment that generator produces — nothing shorter, longer, or outside `a-z`.
///
/// Compares bytes rather than chars on purpose: ASCII bytes cannot appear inside a multi-byte
/// character, so this cannot mis-read a name that is not ASCII to begin with.
#[cfg_attr(not(windows), allow(dead_code))]
fn is_self_replace_random_segment(s: &str) -> bool {
    s.len() == SELF_REPLACE_RANDOM_LEN && s.bytes().all(|b| b.is_ascii_lowercase())
}

/// Whether *this* process is one of those copies, read straight from the OS.
///
/// Deliberately does not go through `ARGS`/`MISE_BIN_NAME`: this answers a question `main` asks
/// before the runtime, logging or config exist, in the same shape as
/// [`crate::cache::session::is_rustc_shim`].
///
/// The stem is not checked, unlike [`is_self_replace_helper`], and it cannot be: the original stem
/// is *inside* the generated name, so a process running under one has nothing left to compare it
/// against. That costs nothing here — a running binary named `.x.….__selfdelete__.exe` was copied
/// from whatever spawned it, and the process asking is mise. Everything around the stem is still
/// required: a leading `.`, then some stem, then the generated random segment and the suffix.
#[cfg(windows)]
pub fn invoked_as_self_replace_helper() -> bool {
    let Some(invoked) = std::env::args_os().next() else {
        return false;
    };
    let Some(name) = Path::new(&invoked).file_name().and_then(|n| n.to_str()) else {
        return false;
    };
    name.starts_with('.')
        && SELF_REPLACE_SUFFIXES.iter().any(|suffix| {
            name.strip_suffix(suffix).is_some_and(|head| {
                head.rsplit_once('.').is_some_and(|(stem, random)| {
                    !stem.is_empty() && is_self_replace_random_segment(random)
                })
            })
        })
}

/// Explicit terminal-width override: `MISE_TERM_WIDTH` takes precedence, then the
/// conventional `COLUMNS`. Lets tables/lists render sanely in CI where terminal
/// size detection returns 0. Honored exactly (no 80 floor) so a narrow width can
/// be forced on purpose. See discussion #4109.
///
/// Gated to `None` under `#[cfg(test)]` (like `TERM_WIDTH`) so unit tests that
/// build a table don't pick up a stray `COLUMNS`/`MISE_TERM_WIDTH` from the env.
#[cfg(test)]
pub static TERM_WIDTH_OVERRIDE: Lazy<Option<usize>> = Lazy::new(|| None);

#[cfg(not(test))]
pub static TERM_WIDTH_OVERRIDE: Lazy<Option<usize>> = Lazy::new(|| {
    for key in ["MISE_TERM_WIDTH", "COLUMNS"] {
        if let Some(w) = var(key)
            .ok()
            .and_then(|v| v.trim().parse::<usize>().ok())
            .filter(|w| *w > 0)
        {
            // COLUMNS is maintained by the shell and can leak in unintentionally,
            // so leave a breadcrumb when it (rather than MISE_TERM_WIDTH) is used.
            if key == "COLUMNS" {
                debug!(
                    "overriding terminal width with COLUMNS={w}; set MISE_TERM_WIDTH to control this explicitly"
                );
            }
            return Some(w);
        }
    }
    None
});

#[cfg(test)]
pub static TERM_WIDTH: Lazy<usize> = Lazy::new(|| 80);

#[cfg(not(test))]
pub static TERM_WIDTH: Lazy<usize> = Lazy::new(|| {
    if let Some(w) = *TERM_WIDTH_OVERRIDE {
        return w;
    }
    terminal_size::terminal_size()
        .map(|(w, _)| w.0 as usize)
        .unwrap_or(80)
        .max(80)
});

/// true if inside a script like bin/exec-env or bin/install
/// used to prevent infinite loops
pub static MISE_BIN: Lazy<PathBuf> = Lazy::new(|| {
    var_path("__MISE_BIN")
        .or_else(|| current_exe().ok())
        .unwrap_or_else(|| "mise".into())
});
pub static MISE_TIMINGS: Lazy<u8> = Lazy::new(|| var_u8("MISE_TIMINGS"));
pub static MISE_PID: Lazy<String> = Lazy::new(|| process::id().to_string());
pub static MISE_JOBS: Lazy<Option<usize>> =
    Lazy::new(|| var("MISE_JOBS").ok().and_then(|v| v.parse::<usize>().ok()));
pub static __MISE_SCRIPT: Lazy<bool> = Lazy::new(|| var_is_true("__MISE_SCRIPT"));
pub static __MISE_DIFF: Lazy<EnvDiff> = Lazy::new(get_env_diff);
pub static __MISE_ORIG_PATH: Lazy<Option<String>> = Lazy::new(|| var("__MISE_ORIG_PATH").ok());
pub static __MISE_ZSH_PRECMD_RUN: Lazy<bool> = Lazy::new(|| !var_is_false("__MISE_ZSH_PRECMD_RUN"));
pub static LINUX_DISTRO: Lazy<Option<String>> = Lazy::new(linux_distro);
pub static PREFER_OFFLINE: Lazy<AtomicBool> =
    Lazy::new(|| prefer_offline(&ARGS.read().unwrap()).into());
/// Commands whose explicit purpose is to enumerate remote versions/tags. Under
/// `prefer_offline`, remote-version lookups are otherwise capped to a single
/// ~3s attempt with no retries so fast/interactive commands (shims, activation)
/// never stall. These commands opt out of that cap so they honor the full
/// configured `fetch_remote_versions_timeout` even when `prefer_offline` is set
/// (https://github.com/jdx/mise/discussions/11185).
pub static REMOTE_FETCH_COMMAND: Lazy<AtomicBool> =
    Lazy::new(|| remote_fetch_command(&ARGS.read().unwrap()).into());
pub static OFFLINE: Lazy<bool> = Lazy::new(|| offline(&ARGS.read().unwrap()));
pub static WARN_ON_MISSING_REQUIRED_ENV: Lazy<bool> =
    Lazy::new(|| warn_on_missing_required_env(&ARGS.read().unwrap()));
/// essentially, this is whether we show spinners or build output on runtime install
pub static PRISTINE_ENV: Lazy<EnvMap> =
    Lazy::new(|| get_pristine_env(&__MISE_DIFF, vars_safe().collect()));
pub static PATH_KEY: Lazy<String> =
    Lazy::new(|| path_key_from_env(vars_os().filter_map(|(k, _)| k.into_string().ok())));

#[cfg(unix)]
fn path_key_from_env(_keys: impl IntoIterator<Item = String>) -> String {
    "PATH".into()
}

#[cfg(windows)]
fn path_key_from_env(keys: impl IntoIterator<Item = String>) -> String {
    keys.into_iter()
        .find(|k| k.eq_ignore_ascii_case("PATH"))
        .unwrap_or("PATH".into())
}
pub static PATH: Lazy<Vec<PathBuf>> = Lazy::new(|| match PRISTINE_ENV.get(&*PATH_KEY) {
    Some(path) => split_paths(path).collect(),
    None => vec![],
});
pub static PATH_NON_PRISTINE: Lazy<Vec<PathBuf>> = Lazy::new(|| match var(&*PATH_KEY) {
    Ok(ref path) => split_paths(path).collect(),
    Err(_) => vec![],
});
pub static DIRENV_DIFF: Lazy<Option<String>> = Lazy::new(|| var("DIRENV_DIFF").ok());

/// GitHub token resolved from environment variables ONLY
/// (`MISE_GITHUB_TOKEN`, `GITHUB_API_TOKEN`, `GITHUB_TOKEN`).
///
/// Intended for subprocess env-var plumbing — passing a token to child processes such as
/// `cargo install` or `ruby-build` that read it themselves.
///
/// **Do not use for mise's own HTTP or sigstore calls.** Use
/// [`crate::github::resolve_token_for_api_url`] (which walks env vars,
/// `credential_command`, `github_tokens.toml`, gh CLI, and git credentials) or the
/// [`crate::github::sigstore`] wrapper (which calls it internally). Passing this static
/// to attestation verification is the original cause of the lock-time rate-limit bug.
pub static GITHUB_TOKEN: Lazy<Option<String>> =
    Lazy::new(|| get_token(&["MISE_GITHUB_TOKEN", "GITHUB_API_TOKEN", "GITHUB_TOKEN"]));
pub static MISE_GITHUB_ENTERPRISE_TOKEN: Lazy<Option<String>> =
    Lazy::new(|| get_token(&["MISE_GITHUB_ENTERPRISE_TOKEN"]));
pub static GITLAB_TOKEN: Lazy<Option<String>> =
    Lazy::new(|| get_token(&["MISE_GITLAB_TOKEN", "GITLAB_TOKEN"]));
pub static MISE_GITLAB_ENTERPRISE_TOKEN: Lazy<Option<String>> =
    Lazy::new(|| get_token(&["MISE_GITLAB_ENTERPRISE_TOKEN"]));
pub static MISE_FORGEJO_ENTERPRISE_TOKEN: Lazy<Option<String>> =
    Lazy::new(|| get_token(&["MISE_FORGEJO_ENTERPRISE_TOKEN"]));

pub static TEST_TRANCHE: Lazy<usize> = Lazy::new(|| var_u8("TEST_TRANCHE") as usize);
pub static TEST_TRANCHE_COUNT: Lazy<usize> = Lazy::new(|| var_u8("TEST_TRANCHE_COUNT") as usize);

pub static CLICOLOR_FORCE: Lazy<Option<bool>> =
    Lazy::new(|| var("CLICOLOR_FORCE").ok().map(|v| v != "0"));

pub static CLICOLOR: Lazy<Option<bool>> = Lazy::new(|| {
    if *CLICOLOR_FORCE == Some(true) {
        Some(true)
    } else if *NO_COLOR || var_is_false("MISE_COLOR") {
        Some(false)
    } else if let Ok(v) = var("CLICOLOR") {
        Some(v != "0")
    } else {
        None
    }
});

/// Disable color output - https://no-color.org/
pub static NO_COLOR: Lazy<bool> = Lazy::new(|| var("NO_COLOR").is_ok_and(|v| !v.is_empty()));

/// Force progress bars even in non-TTY (for debugging)
pub static MISE_FORCE_PROGRESS: Lazy<bool> = Lazy::new(|| var_is_true("MISE_FORCE_PROGRESS"));

// python
pub static PYENV_ROOT: Lazy<PathBuf> =
    Lazy::new(|| var_path("PYENV_ROOT").unwrap_or_else(|| HOME.join(".pyenv")));
pub static UV_PYTHON_INSTALL_DIR: Lazy<PathBuf> = Lazy::new(|| {
    var_path("UV_PYTHON_INSTALL_DIR").unwrap_or_else(|| XDG_DATA_HOME.join("uv").join("python"))
});

#[cfg(unix)]
pub const PATH_ENV_SEP: char = ':';
#[cfg(windows)]
pub const PATH_ENV_SEP: char = ';';

fn get_env_diff() -> EnvDiff {
    let env = vars_safe().collect::<HashMap<_, _>>();
    match env.get("__MISE_DIFF") {
        Some(raw) => EnvDiff::deserialize(raw).unwrap_or_else(|err| {
            warn!("Failed to deserialize __MISE_DIFF: {:#}", err);
            EnvDiff::default()
        }),
        None => EnvDiff::default(),
    }
}

fn var_u8(key: &str) -> u8 {
    var(key)
        .ok()
        .and_then(|v| v.parse::<u8>().ok())
        .unwrap_or_default()
}

pub(crate) fn var_is_true(key: &str) -> bool {
    match var(key) {
        Ok(v) => {
            let v = v.to_lowercase();
            v == "y" || v == "yes" || v == "true" || v == "1" || v == "on"
        }
        Err(_) => false,
    }
}

fn var_is_false(key: &str) -> bool {
    match var(key) {
        Ok(v) => {
            let v = v.to_lowercase();
            v == "n" || v == "no" || v == "false" || v == "0" || v == "off"
        }
        Err(_) => false,
    }
}

pub fn in_home_dir() -> bool {
    current_dir().is_ok_and(|d| d == *HOME)
}

/// The value of `key` as a path, or `None` when it is unset **or empty**.
///
/// An empty value is not a directory. Without this it would yield an empty `PathBuf`, and every
/// caller joins onto the result — producing a *relative* path that gets resolved against the
/// current working directory. `XDG_CONFIG_HOME=` would make `MISE_CONFIG_DIR` the relative
/// `mise`, and a forge CLI lookup read `gh/hosts.yml` out of whatever directory mise happened to
/// be run from. Treating empty as unset is also what the tools mise mirrors here do: go-gh
/// (`os.Getenv(x) != ""`) and `adrg/xdg` (`dir != "" && filepath.IsAbs(dir)`) both fall through.
pub fn var_path(key: &str) -> Option<PathBuf> {
    var_os(key)
        .map(PathBuf::from)
        .map(replace_path)
        .filter(|p| !p.as_os_str().is_empty())
}

/// this returns the environment as if __MISE_DIFF was reversed.
/// putting the shell back into a state before hook-env was run
fn get_pristine_env(mise_diff: &EnvDiff, orig_env: EnvMap) -> EnvMap {
    let patches = mise_diff.reverse().to_patches();
    let mut env = apply_patches(&orig_env, &patches);

    // get the current path as a vector
    let path = match env.get(&*PATH_KEY) {
        Some(path) => split_paths(path).collect(),
        None => vec![],
    };
    // get the paths that were removed by mise as a hashset
    let mut to_remove = mise_diff.path.iter().collect::<HashSet<_>>();

    // remove those paths that were added by mise, but only once (the first time)
    let path = path
        .into_iter()
        .filter(|p| !to_remove.remove(p))
        .collect_vec();

    // put the pristine PATH back into the environment
    env.insert(
        PATH_KEY.to_string(),
        join_paths(path).unwrap().to_string_lossy().to_string(),
    );
    env
}

fn apply_patches(env: &EnvMap, patches: &EnvDiffPatches) -> EnvMap {
    let mut new_env = env.clone();
    for patch in patches {
        match patch {
            EnvDiffOperation::Add(k, v) | EnvDiffOperation::Change(k, v) => {
                new_env.insert(k.into(), v.into());
            }
            EnvDiffOperation::Remove(k) => {
                new_env.remove(k);
            }
        }
    }

    new_env
}

fn offline(args: &[String]) -> bool {
    if var_is_true("MISE_OFFLINE") {
        return true;
    }

    args.iter()
        .take_while(|a| *a != "--")
        .any(|a| a == "--offline")
}

/// returns true if new runtime versions should not be fetched
fn prefer_offline(args: &[String]) -> bool {
    // First check if MISE_PREFER_OFFLINE is set
    if var_is_true("MISE_PREFER_OFFLINE") {
        return true;
    }

    let settings_args_end = first_non_global_arg_idx(args).unwrap_or(args.len());
    if args[..settings_args_end]
        .iter()
        .any(|arg| arg == "--prefer-offline")
    {
        return true;
    }

    prefer_offline_command(args)
}

/// Commands that should not fetch remote versions.
const PREFER_OFFLINE_COMMANDS: &[&str] = &[
    "activate", "current", "direnv", "env", "exec", "hook-env", "ls", "where", "which", "x",
];

/// Commands whose whole purpose is to enumerate remote versions. See
/// [`REMOTE_FETCH_COMMAND`].
const REMOTE_FETCH_COMMANDS: &[&str] = &[
    "lock",
    "ls-remote",
    "list-all",
    "list-remote",
    "outdated",
    "upgrade",
    "up",
];

fn first_non_global_arg_idx(args: &[String]) -> Option<usize> {
    // Uses the cached global-flag list rather than building a fresh clap tree.
    // This runs from `Lazy` statics during startup, so on essentially every
    // invocation; building the tree here cost ~6.3M instructions per run.
    crate::cli::first_non_global_arg_idx_cached(args)
}

/// Whether the subcommand at `command_idx` is one of `names`.
fn is_command(args: &[String], command_idx: Option<usize>, names: &[&str]) -> bool {
    command_idx
        .and_then(|idx| args.get(idx))
        .map(|a| names.contains(&a.as_str()))
        .unwrap_or_default()
}

fn prefer_offline_command(args: &[String]) -> bool {
    is_command(
        args,
        first_non_global_arg_idx(args),
        PREFER_OFFLINE_COMMANDS,
    )
}

/// See [`REMOTE_FETCH_COMMAND`].
fn remote_fetch_command(args: &[String]) -> bool {
    is_command(args, first_non_global_arg_idx(args), REMOTE_FETCH_COMMANDS)
}

/// returns true if missing required env vars should produce warnings instead of errors
fn warn_on_missing_required_env(args: &[String]) -> bool {
    // Check if we're running in a command that should warn instead of error
    args.iter()
        .take_while(|a| *a != "--")
        .filter(|a| !a.starts_with('-'))
        .nth(1)
        .map(|a| {
            [
                "hook-env", // Shell activation should not break the shell
            ]
            .contains(&a.as_str())
        })
        .unwrap_or_default()
}

fn environment(args: &[String]) -> Vec<String> {
    let arg_defs = HashSet::from(["--profile", "-P", "--env", "-E"]);

    // Get environment value from args or env vars
    // Precedence: CLI args > env vars > .miserc.toml
    let from_args = if *IS_RUNNING_AS_SHIM {
        // When running as shim, ignore command line args and use env vars only
        vec![]
    } else {
        // Subcommands where positional args accept hyphen values, so -E after the
        // first positional would be a task arg, not a global flag.
        let run_subcommands: HashSet<&str> = HashSet::from(["run", "r"]);
        // Try to get from command line args first
        // Handles `--env production`, `--env=production`, `-E production`, `-E=production`,
        // and `-Eproduction`.
        let mut values = Vec::new();
        let mut it = args.iter().take_while(|a| a.as_str() != "--");
        let mut in_run_subcommand = false;
        while let Some(arg) = it.next() {
            if arg.starts_with('-') {
                if arg_defs.contains(arg.as_str()) {
                    // Case: `-E production` or `--env production`
                    if let Some(next) = it.next() {
                        values.push(next.to_string());
                    }
                } else if let Some((prefix, rest)) = arg.split_at_checked(2)
                    && !rest.starts_with('=')
                    && arg_defs.contains(prefix)
                {
                    // Case: `-Eproduction`
                    values.push(rest.to_string());
                } else if let Some((flag, value)) = arg.split_once('=') {
                    // Case: `-E=production` or `--env=production`
                    if arg_defs.contains(flag) {
                        values.push(value.to_string());
                    }
                }
            } else {
                // After `run`/`r`, the first positional is the task name — everything
                // after that belongs to the task, so stop scanning for env flags.
                if in_run_subcommand {
                    break;
                }
                if run_subcommands.contains(arg.as_str()) {
                    in_run_subcommand = true;
                }
            }
        }
        values
            .into_iter()
            .flat_map(|s| {
                s.split(',')
                    .filter(|s| !s.is_empty())
                    .map(String::from)
                    .collect::<Vec<_>>()
            })
            .collect()
    };
    if !from_args.is_empty() {
        return from_args;
    }
    var("MISE_ENV")
        .ok()
        .or_else(|| var("MISE_PROFILE").ok())
        .or_else(|| var("MISE_ENVIRONMENT").ok())
        .map(|s| {
            s.split(',')
                .filter(|s| !s.is_empty())
                .map(String::from)
                .collect()
        })
        .or_else(|| miserc::get_env().cloned())
        .unwrap_or_default()
}

fn log_file_level() -> Option<LevelFilter> {
    let log_level = var("MISE_LOG_FILE_LEVEL").unwrap_or_default();
    log_level.parse::<LevelFilter>().ok()
}

fn linux_distro() -> Option<String> {
    crate::platform::linux_os_release().map(|release| release.id.clone())
}

/// Split a colon-separated string into a set, filtering empty segments.
/// Empty segments arise from empty strings, leading/trailing colons, or
/// consecutive colons — all of which should be ignored rather than
/// injected as empty paths into config discovery.
fn split_colon_list(value: &str) -> IndexSet<String> {
    value
        .split(':')
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string())
        .collect()
}

/// The basename of `path` by the host's path grammar, which on Windows means either separator.
///
/// `argv[0]` does not always arrive with `MAIN_SEPARATOR_STR`. libuv hands a Windows process a
/// forward-slash path, which is how Neovim's `jobstart` spawns mise, and splitting on the platform
/// separator left the whole path in [`MISE_BIN_NAME`]. [`is_mise_binary`] then said no and mise ran
/// itself as a shim named after its own path (discussion #11423).
///
/// Deferring to [`Path`] rather than splitting on both separators unconditionally is deliberate:
/// `\` is an ordinary filename character on unix, and splitting there would resolve a shim to a
/// different tool than the one invoked.
fn filename(path: &str) -> &str {
    Path::new(path)
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or(path)
}

fn get_token(keys: &[&str]) -> Option<String> {
    keys.iter()
        .find_map(|key| var(key).ok())
        .filter(|v| !v.trim().is_empty())
}

pub fn is_activated() -> bool {
    var("__MISE_DIFF").is_ok()
}

pub fn set_var<K: AsRef<OsStr>, V: AsRef<OsStr>>(key: K, value: V) {
    static MUTEX: Mutex<()> = Mutex::new(());
    let _mutex = MUTEX.lock().unwrap();
    unsafe {
        std::env::set_var(key, value);
    }
}

pub fn remove_var<K: AsRef<OsStr>>(key: K) {
    static MUTEX: Mutex<()> = Mutex::new(());
    let _mutex = MUTEX.lock().unwrap();
    unsafe {
        std::env::remove_var(key);
    }
}

/// Remove the env cache encryption key to force fresh env computation
pub fn reset_env_cache_key() {
    remove_var("__MISE_ENV_CACHE_KEY");
}

/// Safe wrapper around std::env::vars() that handles invalid UTF-8 gracefully.
/// This function uses vars_os() and converts OsString to String, skipping any
/// environment variables that contain invalid UTF-8 sequences.
pub fn vars_safe() -> impl Iterator<Item = (String, String)> {
    vars_os().filter_map(|(k, v)| {
        let k_str = k.to_str()?;
        let v_str = v.to_str()?;
        Some((k_str.to_string(), v_str.to_string()))
    })
}

/// Safe wrapper around std::env::args() that handles invalid UTF-8 gracefully.
/// std::env::args() panics if any argument contains invalid UTF-8; this uses
/// args_os() and lossily converts each argument (invalid sequences become U+FFFD).
/// Unlike vars_safe() the conversion is lossy rather than skipping, so argument
/// positions are preserved and a malformed argv yields a normal "unknown command"
/// error instead of crashing.
pub fn args_safe() -> Vec<String> {
    args_os().map(|a| a.to_string_lossy().to_string()).collect()
}

pub fn set_current_dir<P: AsRef<Path>>(path: P) -> Result<()> {
    let path = path.as_ref();
    trace!("cd {}", display_path(path));
    std::env::set_current_dir(path)
        .wrap_err_with(|| format!("failed to set current directory to {}", display_path(path)))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use pretty_assertions::assert_eq;

    use crate::config::Config;

    use super::*;

    #[tokio::test]
    async fn test_apply_patches() {
        let _config = Config::get().await.unwrap();
        let mut env = EnvMap::new();
        env.insert("foo".into(), "bar".into());
        env.insert("baz".into(), "qux".into());
        let patches = vec![
            EnvDiffOperation::Add("foo".into(), "bar".into()),
            EnvDiffOperation::Change("baz".into(), "qux".into()),
            EnvDiffOperation::Remove("quux".into()),
        ];
        let new_env = apply_patches(&env, &patches);
        assert_eq!(new_env.len(), 2);
        assert_eq!(new_env.get("foo").unwrap(), "bar");
        assert_eq!(new_env.get("baz").unwrap(), "qux");
    }

    #[tokio::test]
    async fn test_var_path() {
        let _config = Config::get().await.unwrap();
        set_var("MISE_TEST_PATH", "/foo/bar");
        assert_eq!(
            var_path("MISE_TEST_PATH").unwrap(),
            PathBuf::from("/foo/bar")
        );
        remove_var("MISE_TEST_PATH");
    }

    /// An empty value is not a directory. Callers all join onto the result, so returning
    /// `Some("")` would hand them a relative path resolved against the cwd — e.g. an empty
    /// `XDG_CONFIG_HOME` turning `MISE_CONFIG_DIR` into the relative `mise`.
    #[tokio::test]
    async fn test_var_path_treats_empty_as_unset() {
        let _config = Config::get().await.unwrap();
        set_var("MISE_TEST_EMPTY_PATH", "");
        assert_eq!(var_path("MISE_TEST_EMPTY_PATH"), None);
        remove_var("MISE_TEST_EMPTY_PATH");
    }

    /// vars_safe() must skip pairs whose key or value is not valid UTF-8 rather
    /// than panicking the way std::env::vars() does (#5370).
    #[cfg(unix)]
    #[test]
    fn test_vars_safe_skips_invalid_utf8() {
        use std::ffi::OsString;
        use std::os::unix::ffi::OsStringExt;

        // 0xff can never appear in valid UTF-8.
        let bad_value_key = "MISE_TEST_VARS_SAFE_BAD_VALUE";
        let bad_key = OsString::from_vec(b"MISE_TEST_VARS_SAFE_BAD_KEY_\xff".to_vec());
        let good_key = "MISE_TEST_VARS_SAFE_GOOD";

        // the guard restores the previous environment on drop, so even a failing
        // assertion below cannot leak a non-UTF-8 var into the test process
        let mut guard = crate::test::EnvVarGuard::new();
        guard
            .set(bad_value_key, OsString::from_vec(vec![0xff]))
            .set(&bad_key, "ok")
            .set(good_key, "1");

        // Both malformed vars really are in the process environment...
        assert!(vars_os().any(|(k, _)| k == bad_value_key));
        assert!(vars_os().any(|(k, _)| k == bad_key));
        // ...and vars_safe() returns without panicking.
        let safe: Vec<(String, String)> = vars_safe().collect();

        assert!(!safe.iter().any(|(k, _)| k == bad_value_key));
        assert!(
            !safe
                .iter()
                .any(|(k, _)| k.starts_with("MISE_TEST_VARS_SAFE_BAD_KEY_"))
        );
        // Valid neighbours are still returned.
        assert!(safe.iter().any(|(k, v)| k == good_key && v == "1"));
    }

    #[test]
    fn test_auto_env_default_for_version() {
        let v = |s: &str| versions::Versioning::new(s).unwrap();
        assert!(!auto_env_default_for_version(&v("2026.6.2")));
        assert!(!auto_env_default_for_version(&v("2026.12.0")));
        assert!(!auto_env_default_for_version(&v("2027.5.9")));
        assert!(auto_env_default_for_version(&v("2027.6.0")));
        assert!(auto_env_default_for_version(&v("2028.1.0")));
    }

    #[test]
    fn test_remote_fetch_command_skips_global_option_values() {
        let args = |args: &[&str]| {
            args.iter()
                .map(|arg| (*arg).to_string())
                .collect::<Vec<_>>()
        };

        assert!(remote_fetch_command(&args(&[
            "mise", "--cd", "/tmp", "lock"
        ])));
        assert!(remote_fetch_command(&args(&[
            "mise",
            "--profile",
            "development",
            "ls-remote",
        ])));
        assert!(remote_fetch_command(&args(&[
            "mise",
            "--cd=/tmp",
            "--profile=development",
            "outdated",
        ])));
    }

    #[test]
    fn test_prefer_offline_command_skips_global_option_values() {
        let args = |args: &[&str]| {
            args.iter()
                .map(|arg| (*arg).to_string())
                .collect::<Vec<_>>()
        };

        assert!(prefer_offline_command(&args(&[
            "mise", "--cd", "/tmp", "activate"
        ])));
        assert!(prefer_offline_command(&args(&[
            "mise",
            "--profile",
            "development",
            "hook-env",
        ])));
        assert!(prefer_offline_command(&args(&["mise", "-C/tmp", "env",])));
        assert!(!prefer_offline_command(&args(&[
            "mise", "--cd", "/tmp", "lock"
        ])));
        assert!(prefer_offline(&args(&[
            "mise",
            "--cd",
            "/tmp",
            "--prefer-offline",
            "lock",
        ])));
    }

    #[cfg(unix)]
    #[test]
    fn test_platform_env_names_unix() {
        let names = platform_env_names();
        assert_eq!(names.len(), 3);
        assert_eq!(names[0], "unix");
        assert_eq!(names[1], *crate::cli::version::OS);
        assert_eq!(
            names[2],
            format!(
                "{}-{}",
                *crate::cli::version::OS,
                *crate::cli::version::ARCH
            )
        );
    }

    #[cfg(windows)]
    #[test]
    fn test_platform_env_names_windows() {
        // os family == os on windows, so the list dedupes to two entries
        let names = platform_env_names();
        assert_eq!(
            names,
            vec![
                "windows".to_string(),
                format!("windows-{}", *crate::cli::version::ARCH)
            ]
        );
    }

    #[cfg(unix)]
    #[test]
    fn test_path_key_from_env_uses_uppercase_path_on_unix() {
        assert_eq!(
            path_key_from_env(vec!["path".into(), "HOME".into()]),
            "PATH"
        );
        assert_eq!(
            path_key_from_env(vec!["Path".into(), "HOME".into()]),
            "PATH"
        );
    }

    #[cfg(windows)]
    #[test]
    fn test_path_key_from_env_preserves_windows_path_casing() {
        assert_eq!(
            path_key_from_env(vec!["Path".into(), "TEMP".into()]),
            "Path"
        );
        assert_eq!(
            path_key_from_env(vec!["TEMP".into(), "PATH".into()]),
            "PATH"
        );
        assert_eq!(path_key_from_env(vec!["TEMP".into()]), "PATH");
    }

    #[test]
    fn test_split_colon_list() {
        let cases: Vec<(&str, Vec<&str>)> = vec![
            ("", vec![]),    // empty string — was causing panic
            (":", vec![]),   // colon only
            (":::", vec![]), // multiple colons
            ("mise.toml", vec!["mise.toml"]),
            ("a:b", vec!["a", "b"]),
            (":a:b:", vec!["a", "b"]), // leading/trailing colons
            ("a::b", vec!["a", "b"]),  // consecutive colons
        ];
        for (input, expected) in cases {
            let result = split_colon_list(input);
            let expected: IndexSet<String> = expected.into_iter().map(|s| s.to_string()).collect();
            assert_eq!(result, expected, "input: {input:?}");
        }
    }

    #[test]
    fn test_token_overwrite() {
        // Clean up any existing environment variables that might interfere
        remove_var("MISE_GITHUB_TOKEN");
        remove_var("GITHUB_TOKEN");
        remove_var("GITHUB_API_TOKEN");

        set_var("MISE_GITHUB_TOKEN", "");
        set_var("GITHUB_TOKEN", "invalid_token");
        assert_eq!(
            get_token(&["MISE_GITHUB_TOKEN", "GITHUB_TOKEN"]),
            None,
            "Empty token should overwrite other tokens"
        );
        assert_eq!(
            get_token(&["GITHUB_API_TOKEN", "GITHUB_TOKEN"]),
            Some("invalid_token".into()),
            "Unset token should not overwrite other tokens"
        );
        remove_var("MISE_GITHUB_TOKEN");
        remove_var("GITHUB_TOKEN");
        remove_var("GITHUB_API_TOKEN");
    }

    #[test]
    fn test_filename_takes_the_basename() {
        // The reported case: libuv gives a Windows process a forward-slash argv[0], and splitting
        // on MAIN_SEPARATOR_STR left the whole path behind. `/` separates on every platform, so
        // the case the fix is for is pinned everywhere.
        assert_eq!(filename("C:/Users/alice/.cargo/bin/mise.EXE"), "mise.EXE");
        // A unix path was equally unhandled on Windows, since the separator there is `\`.
        assert_eq!(filename("/usr/local/bin/mise"), "mise");
        // A trailing separator used to yield an empty name.
        assert_eq!(filename("/usr/local/bin/mise/"), "mise");
        // Bare names pass through, which is the common case.
        assert_eq!(filename("mise"), "mise");
        assert_eq!(filename("mise.exe"), "mise.exe");
    }

    /// `\` separates path components only on Windows, so the two tests below are a deliberate
    /// platform split rather than duplicated coverage.
    ///
    /// This half is the regression guard: the spelling that always worked has to keep working.
    #[cfg(windows)]
    #[test]
    fn test_filename_splits_on_backslash_on_windows() {
        assert_eq!(filename(r"C:\Users\alice\.cargo\bin\mise.EXE"), "mise.EXE");
        assert_eq!(filename(r"C:\tools\mise\"), "mise");
    }

    /// The other half. On unix `\` is an ordinary filename character, so splitting on it would
    /// resolve a shim to a different tool than the one invoked. `filename` defers to the host
    /// path grammar to avoid that, and this pins the choice.
    #[cfg(unix)]
    #[test]
    fn test_filename_keeps_backslashes_on_unix() {
        assert_eq!(
            filename(r"C:\Users\alice\.cargo\bin\mise.EXE"),
            r"C:\Users\alice\.cargo\bin\mise.EXE"
        );
        assert_eq!(filename(r"/opt/odd/weird\name"), r"weird\name");
    }

    #[test]
    fn test_a_full_path_argv0_is_recognised_as_mise_itself() {
        // What the fix is actually for: `filename` is only interesting because its result feeds
        // `is_mise_binary`, and a false there sends mise into shim mode against its own path.
        for argv0 in [
            "C:/Users/alice/.cargo/bin/mise.EXE",
            "/usr/local/bin/mise",
            "mise",
        ] {
            assert!(
                is_mise_binary(filename(argv0)),
                "argv[0] {argv0:?} should be recognised as mise, not a shim"
            );
        }
        // The backslash spelling only resolves where `\` is a separator; see the pair of
        // `filename` tests above.
        #[cfg(windows)]
        assert!(
            is_mise_binary(filename(r"C:\Users\alice\.cargo\bin\mise.exe")),
            "a backslash argv[0] should be recognised as mise on Windows"
        );
        // The control: a real shim invocation must still be treated as one, or this "fix" would
        // be mise refusing to act as a shim at all.
        for argv0 in ["/home/alice/.local/share/mise/shims/node", "node.exe"] {
            assert!(
                !is_mise_binary(filename(argv0)),
                "argv[0] {argv0:?} should still be a shim"
            );
        }
    }

    #[test]
    fn test_is_mise_binary() {
        // The spellings mise is actually invoked under, on every platform.
        assert!(is_mise_binary("mise"));
        assert!(is_mise_binary("mise.exe"));
        assert!(is_mise_binary("mise.cmd"));
        assert!(is_mise_binary("mise-doctor"));
        // The controls. Real shim names must stay shims, or this stops being a test of anything:
        // a version that always returned true would pass every assertion above.
        assert!(!is_mise_binary("node"));
        assert!(!is_mise_binary("node.exe"));
        assert!(!is_mise_binary("misex"));
        assert!(!is_mise_binary("misex.exe"));
    }

    /// Windows resolves `MISE.EXE` to the same file as `mise.exe` and hands the process `argv[0]`
    /// with the casing the caller wrote, so the test has to ignore case there. See the pair below.
    #[cfg(windows)]
    #[test]
    fn test_is_mise_binary_ignores_case_on_windows() {
        assert!(is_mise_binary("MISE.EXE"));
        assert!(is_mise_binary("Mise.exe"));
        assert!(is_mise_binary("MISE"));
        assert!(is_mise_binary("MISE-DOCTOR"));
        // Case-insensitivity must not widen what counts as mise.
        assert!(!is_mise_binary("NODE.EXE"));
        assert!(!is_mise_binary("MISEX.EXE"));
    }

    /// The other half. On unix `MISE` is a different file from `mise`, so a shim by that name has
    /// to keep being treated as a shim; ignoring case here would make mise run itself instead.
    #[cfg(unix)]
    #[test]
    fn test_is_mise_binary_is_case_sensitive_on_unix() {
        assert!(!is_mise_binary("MISE"));
        assert!(!is_mise_binary("Mise"));
        assert!(!is_mise_binary("MISE-DOCTOR"));
    }

    fn detect(mise_shell: Option<&str>, shell_var: Option<&str>, fallback: &str) -> String {
        detect_shell(
            mise_shell.map(str::to_string),
            shell_var.map(str::to_string),
            fallback,
        )
        .map(|st| st.to_string())
        .unwrap_or_else(|| "(none)".to_string())
    }

    /// `mise activate` exports `MISE_SHELL`, so it decides on its own — including deciding that
    /// the answer is nothing. Falling back after an unparseable value would start guessing at a
    /// shell the session has already named.
    #[test]
    fn mise_shell_wins_and_an_unparseable_one_is_still_the_answer() {
        assert_eq!(detect(Some("zsh"), Some("/bin/bash"), "/bin/bash"), "zsh");
        assert_eq!(
            detect(Some("nonsense"), Some("/bin/bash"), "/bin/bash"),
            "(none)"
        );
    }

    /// The fix: Git Bash, MSYS2 and Cygwin set `SHELL` on Windows, where the fallback reads
    /// `COMSPEC` and so can never see it.
    #[cfg(windows)]
    #[test]
    fn shell_is_consulted_on_windows() {
        for shell_var in [
            r"C:\Program Files\Git\bin\bash.exe",
            "/bin/bash.exe",
            r"C:\msys64\usr\bin\zsh.exe",
        ] {
            let expected = match shell_var.contains("zsh") {
                true => "zsh",
                false => "bash",
            };
            assert_eq!(
                detect(None, Some(shell_var), r"C:\WINDOWS\system32\cmd.exe"),
                expected,
                "{shell_var}"
            );
        }
        // Unchanged where there is nothing to find: cmd.exe is not a shell mise generates for.
        assert_eq!(detect(None, None, r"C:\WINDOWS\system32\cmd.exe"), "(none)");
    }

    /// The control. On unix `SHELL` *is* the fallback, so the extra lookup must not exist —
    /// otherwise this test would pass for the wrong reason on every platform.
    #[cfg(unix)]
    #[test]
    fn the_fallback_is_the_only_second_source_on_unix() {
        // A `SHELL` that disagrees with the fallback is ignored: unix passes the same value as
        // both, so anything else would mean the Windows branch had leaked.
        assert_eq!(detect(None, Some("/bin/zsh"), "/bin/bash"), "bash");
        assert_eq!(detect(None, None, "/bin/zsh"), "zsh");
        assert_eq!(detect(None, None, "sh"), "bash");
    }

    /// The names `self-replace` generates, which mise used to report as broken shims. Measured with
    /// `TEMP` at 199 and 201 characters, where the copy's own init hook stops recognising itself.
    #[test]
    fn a_self_replace_copy_of_this_binary_is_recognised() {
        let rand = "qzcdgqhxhqwhzdwyqchsmdxcqouxxche";
        assert!(is_self_replace_helper(
            &format!(".mise.{rand}.__selfdelete__.exe"),
            "mise"
        ));
        assert!(is_self_replace_helper(
            &format!(".mise.{rand}.__relocated__.exe"),
            "mise"
        ));
        // The stem follows the binary, so a renamed mise is still recognised.
        assert!(is_self_replace_helper(
            &format!(".mise-dev.{rand}.__selfdelete__.exe"),
            "mise-dev"
        ));
    }

    /// The controls. Two of them are the reason the stem is a parameter at all.
    #[test]
    fn ordinary_names_and_other_applications_are_not() {
        let rand = "qzcdgqhxhqwhzdwyqchsmdxcqouxxche";
        // mise itself, and a genuine shim.
        assert!(!is_self_replace_helper("mise.exe", "mise"));
        assert!(!is_self_replace_helper("node.exe", "mise"));
        // Another application's orphan, sitting in the same TEMP. Matching the suffix alone would
        // claim it — and the sweep would delete it.
        assert!(!is_self_replace_helper(
            &format!(".node.{rand}.__selfdelete__.exe"),
            "mise"
        ));
        // Ours by name, but not one of these copies.
        assert!(!is_self_replace_helper(".mise.something.exe", "mise"));
    }

    /// The segment between the stem and the suffix has to be the one `self-replace` generates —
    /// 32 characters from `fastrand`'s `lowercase()`. The caller acting on a `true` here **deletes
    /// the file**, so a name that merely looks similar must not qualify.
    #[test]
    fn a_name_that_only_resembles_a_generated_one_is_left_alone() {
        let ok = "qzcdgqhxhqwhzdwyqchsmdxcqouxxche";
        assert_eq!(ok.len(), SELF_REPLACE_RANDOM_LEN, "premise");
        assert!(is_self_replace_helper(
            &format!(".mise.{ok}.__selfdelete__.exe"),
            "mise"
        ));

        // No segment at all.
        assert!(!is_self_replace_helper(".mise..__selfdelete__.exe", "mise"));
        assert!(!is_self_replace_helper(".mise.__selfdelete__.exe", "mise"));
        // Too short, and too long.
        assert!(!is_self_replace_helper(
            ".mise.abc.__selfdelete__.exe",
            "mise"
        ));
        assert!(!is_self_replace_helper(
            &format!(".mise.{ok}x.__selfdelete__.exe"),
            "mise"
        ));
        // Right length, wrong alphabet — a digit and an uppercase letter.
        let digits = format!("{}1", &ok[..SELF_REPLACE_RANDOM_LEN - 1]);
        assert!(!is_self_replace_helper(
            &format!(".mise.{digits}.__selfdelete__.exe"),
            "mise"
        ));
        let upper = format!("{}A", &ok[..SELF_REPLACE_RANDOM_LEN - 1]);
        assert!(!is_self_replace_helper(
            &format!(".mise.{upper}.__selfdelete__.exe"),
            "mise"
        ));
    }

    /// The stem carries whatever casing the caller used to start mise — measured: `current_exe()`
    /// returns `CASEPROBE` for a `caseprobe.exe` started as `CASEPROBE.EXE`. So an update launched
    /// one way writes an orphan the next one, launched the other way, has to still recognise.
    #[cfg(windows)]
    #[test]
    fn an_orphan_from_a_differently_cased_launch_is_still_ours() {
        let rand = "qzcdgqhxhqwhzdwyqchsmdxcqouxxche";
        for stem in ["mise", "MISE", "Mise"] {
            for written in ["mise", "MISE", "Mise"] {
                assert!(
                    is_self_replace_helper(&format!(".{written}.{rand}.__selfdelete__.exe"), stem),
                    "stem {stem} should claim an orphan written as {written}"
                );
            }
        }
        // Still not another application's, however it is cased.
        assert!(!is_self_replace_helper(
            &format!(".NODE.{rand}.__selfdelete__.exe"),
            "mise"
        ));
    }

    /// The control for the rule above: unix filesystems are case-sensitive, so `MISE` and `mise`
    /// are different files and an orphan of one is not an orphan of the other.
    #[cfg(unix)]
    #[test]
    fn casing_still_separates_names_on_unix() {
        let rand = "qzcdgqhxhqwhzdwyqchsmdxcqouxxche";
        assert!(is_self_replace_helper(
            &format!(".mise.{rand}.__selfdelete__.exe"),
            "mise"
        ));
        assert!(!is_self_replace_helper(
            &format!(".MISE.{rand}.__selfdelete__.exe"),
            "mise"
        ));
    }
}
