//! Configuration schema for the microsandbox library.
//!
//! [`LocalConfig`] is the persisted schema for `~/.microsandbox/config.json`.
//! It is owned by [`LocalBackend`](crate::backend::LocalBackend); accessors
//! live on the backend, not on a process-wide static. See D6.7 Layer 2a in
//! `planning/microsandbox/design/api/local-cloud-backend.md`.
//!
//! Layer 1 process-wide knobs ([`set_sdk_msb_path`],
//! [`set_sdk_libkrunfw_path`]) stay in this module — they are documented as
//! process-singleton-by-physics (one dylib per process address space,
//! one resolved `msb` binary).

use std::{
    collections::HashMap,
    num::NonZero,
    path::{Path, PathBuf},
    sync::OnceLock,
};

use docker_credential::{CredentialRetrievalError, DockerCredential};
use microsandbox_image::RegistryAuth;
use microsandbox_runtime::logging::LogLevel;
use serde::{Deserialize, Serialize};

use crate::{MicrosandboxError, MicrosandboxResult};

//--------------------------------------------------------------------------------------------------
// Constants
//--------------------------------------------------------------------------------------------------

/// Default number of vCPUs per sandbox.
pub(crate) const DEFAULT_CPUS: u8 = 1;

/// Default guest memory in MiB.
pub(crate) const DEFAULT_MEMORY_MIB: u32 = 512;

/// Default database max connections.
pub(crate) const DEFAULT_MAX_CONNECTIONS: u32 = 5;

/// Default database connection acquisition timeout in seconds.
pub(crate) const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 30;

/// Default sandbox metrics sampling interval in milliseconds.
pub const DEFAULT_METRICS_SAMPLE_INTERVAL_MS: u64 = 1000;

/// Default value for `metrics_sample_interval_ms` fields.
pub fn default_metrics_sample_interval() -> Option<NonZero<u64>> {
    NonZero::new(DEFAULT_METRICS_SAMPLE_INTERVAL_MS)
}

/// Serde adapter mapping the `metrics_sample_interval_ms` wire format `u64` to `Option<NonZero<u64>>` (`0` ↔ `None`).
pub(crate) mod metrics_interval_serde {
    use serde::{Deserialize, Deserializer, Serialize, Serializer};
    use std::num::NonZero;

    pub fn serialize<S: Serializer>(v: &Option<NonZero<u64>>, s: S) -> Result<S::Ok, S::Error> {
        v.map(|n| n.get()).unwrap_or(0).serialize(s)
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<NonZero<u64>>, D::Error> {
        Ok(NonZero::new(u64::deserialize(d)?))
    }
}

/// Service name for microsandbox-managed registry credentials in the OS keyring.
#[cfg(all(
    feature = "keyring",
    any(target_os = "linux", target_os = "macos", target_os = "windows")
))]
const REGISTRY_KEYRING_SERVICE: &str = "dev.microsandbox.registry";

//--------------------------------------------------------------------------------------------------
// Statics: Layer 1 (process-level)
//--------------------------------------------------------------------------------------------------

/// SDK-provided path to the bundled `msb` binary. Set via [`set_sdk_msb_path`]
/// by FFI bindings that ship a binary inside their language package and need
/// an in-process channel that doesn't fight user env. Tier 2 of the
/// resolution ladder (below `MSB_PATH` env, above config + filesystem
/// fallbacks).
static SDK_MSB_PATH: OnceLock<PathBuf> = OnceLock::new();

/// SDK-provided path to the bundled `libkrunfw` dylib. Set via
/// [`set_sdk_libkrunfw_path`]. Tier 2 of the libkrunfw resolution ladder.
static SDK_LIBKRUNFW_PATH: OnceLock<PathBuf> = OnceLock::new();

//--------------------------------------------------------------------------------------------------
// Types
//--------------------------------------------------------------------------------------------------

/// Configuration owned by a [`LocalBackend`](crate::backend::LocalBackend).
///
/// Built from `~/.microsandbox/config.json` by default, or programmatically
/// via [`LocalBackend::builder`](crate::backend::LocalBackend::builder).
/// Bound to one backend instance — not a process-wide singleton.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct LocalConfig {
    /// Root directory for all microsandbox data.
    pub home: Option<PathBuf>,

    /// Default runtime log level for SDK-spawned sandbox processes.
    ///
    /// `None` means sandbox runtime processes are silent unless overridden
    /// per-sandbox.
    pub log_level: Option<LogLevel>,

    /// Database configuration.
    pub database: DatabaseConfig,

    /// Path overrides.
    pub paths: PathsConfig,

    /// Default values for sandbox configuration.
    pub sandbox_defaults: SandboxDefaults,

    /// Registry authentication configuration.
    pub registries: RegistriesConfig,

    /// Live metrics registry configuration.
    pub metrics: MetricsConfig,
}

/// Live metrics registry configuration.
///
/// Controls the host-side shared-memory registry that backs
/// `Sandbox::metrics()` and `all_sandbox_metrics()`. The capacity here
/// determines how many concurrent sandboxes can have a live metrics slot.
///
/// The capacity is locked when the registry is first created for a given
/// `MSB_HOME`; processes that subsequently supply a different value are
/// rejected at open time. To change the capacity, stop all sandboxes for
/// the same home and `shm_unlink` the registry segment before the next
/// host process boots.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct MetricsConfig {
    /// Number of slots reserved in the metrics shared-memory segment.
    /// A value of `0` (the default) falls back to the built-in default at
    /// read time via [`LocalConfig::metrics_registry_capacity`]. The
    /// derived `Default` therefore avoids pinning serialized configs to a
    /// particular release's default capacity.
    pub capacity: u32,
}

/// Database configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DatabaseConfig {
    /// Database connection URL. `None` uses the default SQLite path.
    pub url: Option<String>,

    /// Maximum connection pool size.
    pub max_connections: u32,

    /// Timeout when acquiring a database connection from the pool.
    pub connect_timeout_secs: u64,

    /// SQLite `busy_timeout` PRAGMA: seconds SQLite waits on a contended
    /// lock before surfacing `SQLITE_BUSY` to the retry layer.
    pub busy_timeout_secs: u64,
}

/// Path overrides for runtime binaries and data directories.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct PathsConfig {
    /// Path to `msb` binary.
    ///
    /// Resolution: `MSB_PATH` env → SDK runtime path → this →
    /// workspace-local (debug only) → `~/.microsandbox/bin/msb` → PATH lookup.
    pub msb: Option<PathBuf>,

    /// Path to `libkrunfw.{so,dylib}`.
    pub libkrunfw: Option<PathBuf>,

    /// Cache directory.
    pub cache: Option<PathBuf>,

    /// Per-sandbox state directory.
    pub sandboxes: Option<PathBuf>,

    /// Named volumes directory.
    pub volumes: Option<PathBuf>,

    /// Snapshot artifacts directory.
    pub snapshots: Option<PathBuf>,

    /// Logs directory.
    pub logs: Option<PathBuf>,

    /// Secrets directory.
    pub secrets: Option<PathBuf>,
}

/// Default values applied to sandboxes when not overridden per-sandbox.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SandboxDefaults {
    /// Default vCPU count.
    pub cpus: u8,

    /// Default guest memory in MiB.
    pub memory_mib: u32,

    /// Default OCI rootfs settings.
    pub oci: OciSandboxDefaults,

    /// Default shell for interactive sessions and scripts.
    pub shell: String,

    /// Default working directory inside the sandbox.
    pub workdir: Option<String>,

    /// Default metrics sampling interval in milliseconds; `0` disables sampling globally.
    #[serde(
        default = "default_metrics_sample_interval",
        with = "metrics_interval_serde"
    )]
    pub metrics_sample_interval_ms: Option<NonZero<u64>>,

    /// Force-disable metrics sampling regardless of `metrics_sample_interval_ms`.
    #[serde(default)]
    pub disable_metrics_sample: bool,
}

/// Default values applied to OCI-rooted sandboxes.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct OciSandboxDefaults {
    /// Default writable overlay upper size in MiB.
    ///
    /// `None` uses microsandbox's built-in formatter default.
    pub upper_size_mib: Option<u32>,
}

/// Registry configuration.
///
/// Example:
/// ```json
/// {
///   "registries": {
///     "ca_certs": "/path/to/corporate-ca.pem",
///     "hosts": {
///       "localhost:5050": { "insecure": true },
///       "ghcr.io": {
///         "auth": { "username": "user", "store": "keyring" }
///       }
///     }
///   }
/// }
/// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct RegistriesConfig {
    /// Path to a PEM file containing additional CA root certificates to trust.
    ///
    /// Applies globally to all registry connections.
    pub ca_certs: Option<PathBuf>,

    /// Per-registry settings keyed by hostname.
    #[serde(default)]
    pub hosts: HashMap<String, RegistryEntry>,
}

/// Configuration for a single OCI registry.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct RegistryEntry {
    /// Authentication credentials.
    #[serde(default)]
    pub auth: Option<RegistryAuthEntry>,

    /// Access this registry over plain HTTP instead of HTTPS.
    #[serde(default, skip_serializing_if = "is_false")]
    pub insecure: bool,
}

/// Authentication credentials for a registry entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryAuthEntry {
    /// Registry username.
    pub username: String,

    /// Credential source metadata for interactive local auth.
    pub store: Option<RegistryCredentialStore>,

    /// Environment variable containing the password/token.
    pub password_env: Option<String>,

    /// Secret name — password is read from `{home}/secrets/registries/<secret_name>`.
    pub secret_name: Option<String>,
}

/// Credential source metadata for registry auth entries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RegistryCredentialStore {
    /// Credential is stored in the OS keyring.
    Keyring,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct KeyringRegistryCredential {
    username: String,
    password: String,
}

//--------------------------------------------------------------------------------------------------
// Methods
//--------------------------------------------------------------------------------------------------

impl LocalConfig {
    /// Get the resolved home directory.
    pub fn home(&self) -> PathBuf {
        self.home.clone().unwrap_or_else(resolve_default_home)
    }

    /// Resolve the `sandboxes` directory.
    pub fn sandboxes_dir(&self) -> PathBuf {
        self.paths
            .sandboxes
            .clone()
            .unwrap_or_else(|| self.home().join(microsandbox_utils::SANDBOXES_SUBDIR))
    }

    /// Resolve the `volumes` directory.
    pub fn volumes_dir(&self) -> PathBuf {
        self.paths
            .volumes
            .clone()
            .unwrap_or_else(|| self.home().join(microsandbox_utils::VOLUMES_SUBDIR))
    }

    /// Resolve the `snapshots` directory.
    pub fn snapshots_dir(&self) -> PathBuf {
        self.paths
            .snapshots
            .clone()
            .unwrap_or_else(|| self.home().join(microsandbox_utils::SNAPSHOTS_SUBDIR))
    }

    /// Resolve the `logs` directory.
    pub fn logs_dir(&self) -> PathBuf {
        self.paths
            .logs
            .clone()
            .unwrap_or_else(|| self.home().join(microsandbox_utils::LOGS_SUBDIR))
    }

    /// Resolve the `cache` directory.
    pub fn cache_dir(&self) -> PathBuf {
        self.paths
            .cache
            .clone()
            .unwrap_or_else(|| self.home().join(microsandbox_utils::CACHE_SUBDIR))
    }

    /// Resolve the `secrets` directory.
    pub fn secrets_dir(&self) -> PathBuf {
        self.paths
            .secrets
            .clone()
            .unwrap_or_else(|| self.home().join(microsandbox_utils::SECRETS_SUBDIR))
    }

    /// Resolve the `ssh` directory used for host-side SSH state.
    pub fn ssh_dir(&self) -> PathBuf {
        self.home().join(microsandbox_utils::SSH_SUBDIR)
    }

    /// Resolve the `run` directory used for ephemeral runtime artifacts.
    pub fn run_dir(&self) -> PathBuf {
        self.home().join(microsandbox_utils::RUN_SUBDIR)
    }

    /// Resolve the optional diagnostic file under `run/metrics` that records
    /// the derived shared-memory registry name and capacity.
    pub fn metrics_registry_name_path(&self) -> PathBuf {
        self.run_dir()
            .join(microsandbox_utils::METRICS_RUN_SUBDIR)
            .join(microsandbox_utils::metrics_registry_name_filename(
                microsandbox_metrics::REGISTRY_ABI_VERSION,
            ))
    }

    /// Deterministic POSIX shared-memory object name for the live metrics
    /// registry. Hashes the resolved home directory so concurrent
    /// `MSB_HOME`-isolated environments do not collide.
    pub fn metrics_registry_shm_name(&self) -> String {
        microsandbox_utils::metrics_registry_shm_name(
            &self.home(),
            microsandbox_metrics::REGISTRY_ABI_VERSION,
        )
    }

    /// Resolved capacity for the live metrics registry. Falls back to the
    /// built-in default when `metrics.capacity` is zero or unset.
    pub fn metrics_registry_capacity(&self) -> u32 {
        if self.metrics.capacity == 0 {
            microsandbox_metrics::default_capacity()
        } else {
            self.metrics.capacity
        }
    }

    /// Resolve registry transport for a given hostname from the global config.
    /// Load additional CA root certificates from the global `registries.ca_certs` path.
    ///
    /// Returns an empty vec if no path is configured.
    pub async fn resolve_ca_certs(&self) -> MicrosandboxResult<Vec<Vec<u8>>> {
        match &self.registries.ca_certs {
            Some(path) => {
                let data = tokio::fs::read(path).await.map_err(|e| {
                    MicrosandboxError::InvalidConfig(format!(
                        "failed to read CA certs from `{}`: {e}",
                        path.display()
                    ))
                })?;
                Ok(vec![data])
            }
            None => Ok(Vec::new()),
        }
    }

    /// Return all registry hostnames configured as insecure (plain HTTP).
    pub fn insecure_registries(&self) -> Vec<String> {
        self.registries
            .hosts
            .iter()
            .filter(|(_, entry)| entry.insecure)
            .map(|(hostname, _)| hostname.clone())
            .collect()
    }

    /// Resolve registry authentication for a given hostname.
    ///
    /// Resolution order:
    /// 1. OS keyring (interactive CLI login, when the `keyring` feature is enabled)
    /// 2. `registries.<hostname>.auth` in this config
    /// 3. Docker credential store/config
    /// 4. Anonymous
    ///
    /// Returns `Anonymous` if no entry matches.
    pub fn resolve_registry_auth(&self, hostname: &str) -> MicrosandboxResult<RegistryAuth> {
        #[cfg(feature = "keyring")]
        {
            match lookup_registry_keyring_auth(hostname) {
                Ok(Some(auth)) => return Ok(auth),
                Ok(None) => {}
                Err(error) => {
                    tracing::debug!(registry = hostname, error = %error, "failed to resolve registry auth from OS keyring");
                }
            }
        }

        if let Some(auth) = self.resolve_configured_registry_auth(hostname)? {
            return Ok(auth);
        }

        if let Some(auth) = resolve_docker_registry_auth(hostname) {
            return Ok(auth);
        }

        Ok(RegistryAuth::Anonymous)
    }

    fn resolve_configured_registry_auth(
        &self,
        hostname: &str,
    ) -> MicrosandboxResult<Option<RegistryAuth>> {
        let entry = match self
            .registries
            .hosts
            .get(hostname)
            .and_then(|e| e.auth.as_ref())
        {
            Some(entry) => entry,
            None => return Ok(None),
        };

        let source_count = usize::from(entry.store.is_some())
            + usize::from(entry.password_env.is_some())
            + usize::from(entry.secret_name.is_some());

        if source_count == 0 {
            return Err(MicrosandboxError::InvalidConfig(format!(
                "registry auth for {hostname}: entry has no credential source"
            )));
        }

        if source_count > 1 {
            return Err(MicrosandboxError::InvalidConfig(format!(
                "registry auth for {hostname}: entry defines multiple credential sources"
            )));
        }

        if entry.store == Some(RegistryCredentialStore::Keyring) {
            return match lookup_registry_keyring_auth(hostname) {
                Ok(Some(auth)) => Ok(Some(auth)),
                Ok(None) => Err(MicrosandboxError::InvalidConfig(format!(
                    "registry auth for {hostname}: OS keyring entry is missing"
                ))),
                Err(error) => Err(MicrosandboxError::InvalidConfig(format!(
                    "registry auth for {hostname}: failed to read OS keyring entry: {error}"
                ))),
            };
        }

        let password = if let Some(ref env_var) = entry.password_env {
            std::env::var(env_var).map_err(|_| {
                MicrosandboxError::InvalidConfig(format!(
                    "registry auth for {hostname}: environment variable `{env_var}` is not set"
                ))
            })?
        } else if let Some(ref secret_name) = entry.secret_name {
            let secret_path = self.secrets_dir().join("registries").join(secret_name);
            std::fs::read_to_string(&secret_path)
                .map_err(|e| {
                    MicrosandboxError::InvalidConfig(format!(
                        "registry auth for {hostname}: failed to read secret `{}`: {e}",
                        secret_path.display()
                    ))
                })?
                .trim()
                .to_string()
        } else {
            return Err(MicrosandboxError::InvalidConfig(format!(
                "registry auth for {hostname}: entry has no usable credential source"
            )));
        };

        Ok(Some(RegistryAuth::Basic {
            username: entry.username.clone(),
            password,
        }))
    }
}

//--------------------------------------------------------------------------------------------------
// Trait Implementations
//--------------------------------------------------------------------------------------------------

impl Default for DatabaseConfig {
    fn default() -> Self {
        Self {
            url: None,
            max_connections: DEFAULT_MAX_CONNECTIONS,
            connect_timeout_secs: DEFAULT_CONNECT_TIMEOUT_SECS,
            busy_timeout_secs: microsandbox_db::pool::DEFAULT_BUSY_TIMEOUT_SECS,
        }
    }
}

impl Default for SandboxDefaults {
    fn default() -> Self {
        Self {
            cpus: DEFAULT_CPUS,
            memory_mib: DEFAULT_MEMORY_MIB,
            oci: OciSandboxDefaults::default(),
            shell: "/bin/sh".into(),
            workdir: None,
            metrics_sample_interval_ms: default_metrics_sample_interval(),
            disable_metrics_sample: false,
        }
    }
}

//--------------------------------------------------------------------------------------------------
// Functions
//--------------------------------------------------------------------------------------------------

fn is_false(v: &bool) -> bool {
    !v
}

fn resolve_docker_registry_auth(hostname: &str) -> Option<RegistryAuth> {
    resolve_registry_auth_with_lookup(hostname, docker_credential::get_credential)
}

fn lookup_registry_keyring_auth(hostname: &str) -> Result<Option<RegistryAuth>, String> {
    let payload = match load_keyring_registry_credential(hostname)? {
        Some(payload) => payload,
        None => return Ok(None),
    };

    Ok(Some(RegistryAuth::Basic {
        username: payload.username,
        password: payload.password,
    }))
}

fn resolve_registry_auth_with_lookup<F>(hostname: &str, mut lookup: F) -> Option<RegistryAuth>
where
    F: FnMut(&str) -> Result<DockerCredential, CredentialRetrievalError>,
{
    for server in docker_credential_servers(hostname) {
        match lookup(&server) {
            Ok(DockerCredential::UsernamePassword(username, password)) => {
                tracing::debug!(registry = hostname, server = %server, "resolved registry auth from Docker credentials");
                return Some(RegistryAuth::Basic { username, password });
            }
            Ok(DockerCredential::IdentityToken(_)) => {
                tracing::debug!(registry = hostname, server = %server, "ignoring Docker identity token for registry auth");
            }
            Err(CredentialRetrievalError::NoCredentialConfigured)
            | Err(CredentialRetrievalError::ConfigNotFound)
            | Err(CredentialRetrievalError::ConfigReadError) => {}
            Err(error) => {
                tracing::debug!(registry = hostname, server = %server, ?error, "failed to resolve Docker registry credentials");
            }
        }
    }

    None
}

fn docker_credential_servers(hostname: &str) -> Vec<String> {
    let mut servers = vec![hostname.to_string(), format!("https://{hostname}")];

    if matches!(
        hostname,
        "docker.io" | "index.docker.io" | "registry-1.docker.io"
    ) {
        servers.extend([
            "index.docker.io".to_string(),
            "https://index.docker.io".to_string(),
            "https://index.docker.io/v1/".to_string(),
            "registry-1.docker.io".to_string(),
            "https://registry-1.docker.io".to_string(),
        ]);
    }

    dedupe_strings(&mut servers);
    servers
}

/// Resolve the path to the persisted local config file.
pub fn config_path() -> PathBuf {
    // Honour MSB_CONFIG_PATH if set — same env var the SDK config loader
    // checks. The LocalConfig and the SdkConfig live in the same JSON
    // document, so both layers must agree on the path.
    if let Ok(p) = std::env::var("MSB_CONFIG_PATH") {
        return PathBuf::from(p);
    }
    resolve_default_home().join(microsandbox_utils::CONFIG_FILENAME)
}

/// Load the persisted config file or return the default config if it does not exist.
pub fn load_persisted_config_or_default() -> MicrosandboxResult<LocalConfig> {
    let path = config_path();
    if !path.exists() {
        return Ok(LocalConfig::default());
    }

    read_config_from(&path)
}

/// Persist the provided local config to disk as pretty JSON.
pub fn save_persisted_config(config: &LocalConfig) -> MicrosandboxResult<()> {
    let path = config_path();
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| {
            MicrosandboxError::Custom(format!(
                "failed to create config directory `{}`: {e}",
                parent.display()
            ))
        })?;
    }

    let content = serde_json::to_string_pretty(config)
        .map_err(|e| MicrosandboxError::Custom(format!("failed to serialize config: {e}")))?;

    std::fs::write(&path, format!("{content}\n")).map_err(|e| {
        MicrosandboxError::Custom(format!("failed to write config `{}`: {e}", path.display()))
    })?;
    Ok(())
}

/// Store registry credentials in the OS keyring for interactive local use.
pub fn set_registry_keyring_auth(
    hostname: &str,
    username: &str,
    password: &str,
) -> MicrosandboxResult<()> {
    store_registry_keyring_auth(hostname, username, password).map_err(MicrosandboxError::Custom)
}

/// Load registry credentials from the OS keyring, if present.
pub fn get_registry_keyring_auth(hostname: &str) -> MicrosandboxResult<Option<RegistryAuth>> {
    lookup_registry_keyring_auth(hostname).map_err(MicrosandboxError::Custom)
}

/// Delete registry credentials from the OS keyring if they exist.
pub fn delete_registry_keyring_auth(hostname: &str) -> MicrosandboxResult<()> {
    remove_registry_keyring_auth(hostname).map_err(MicrosandboxError::Custom)
}

/// Set the `msb` binary path resolved by an SDK package.
///
/// This is an internal SDK bridge for runtimes where mutating `process.env`
/// does not update the native process environment. User-provided `MSB_PATH`
/// still wins over this value. Set-once: subsequent calls are ignored.
pub fn set_sdk_msb_path(path: impl Into<PathBuf>) {
    let _ = SDK_MSB_PATH.set(path.into());
}

/// Resolve the path to the `msb` binary against the supplied [`LocalConfig`].
///
/// Resolution order:
/// 1. `MSB_PATH` environment variable
/// 2. SDK-provided runtime path
/// 3. `config.paths.msb`
/// 4. workspace-local `build/msb` or `target/debug/msb` (debug builds only)
/// 5. `~/.microsandbox/bin/msb`
/// 6. `which::which("msb")`
pub fn resolve_msb_path(config: &LocalConfig) -> MicrosandboxResult<PathBuf> {
    let env_msb = std::env::var("MSB_PATH").ok();
    let sdk_msb = SDK_MSB_PATH.get().cloned();
    let config_msb = config.paths.msb.clone();

    let debug_probe = || -> Option<PathBuf> {
        // Only probe workspace-local dev builds in debug builds to prevent
        // binary hijacking from untrusted parent directories in production.
        #[cfg(debug_assertions)]
        {
            let mut local_candidates = Vec::new();
            if let Ok(current_dir) = std::env::current_dir() {
                local_candidates.extend(dev_msb_candidates_from(&current_dir));
            }
            if let Ok(current_exe) = std::env::current_exe()
                && let Some(exe_dir) = current_exe.parent()
            {
                local_candidates.extend(dev_msb_candidates_from(exe_dir));
            }
            dedupe_paths(&mut local_candidates);
            local_candidates.into_iter().find(|path| path.is_file())
        }
        #[cfg(not(debug_assertions))]
        {
            None
        }
    };

    let home_probe = || -> Option<PathBuf> {
        let home_bin = config
            .home()
            .join(microsandbox_utils::BIN_SUBDIR)
            .join(microsandbox_utils::MSB_BINARY);
        home_bin.is_file().then_some(home_bin)
    };

    let which_probe = || -> Option<PathBuf> { which::which(microsandbox_utils::MSB_BINARY).ok() };

    resolve_msb_path_from(
        env_msb.as_deref(),
        sdk_msb.as_deref(),
        config_msb.as_deref(),
        &debug_probe,
        &home_probe,
        &which_probe,
    )
}

/// Pure precedence ladder for `resolve_msb_path`. Probe closures encapsulate
/// the filesystem-touching tiers so unit tests can supply fakes.
fn resolve_msb_path_from(
    env_msb: Option<&str>,
    sdk_msb: Option<&Path>,
    config_msb: Option<&Path>,
    debug_probe: &dyn Fn() -> Option<PathBuf>,
    home_probe: &dyn Fn() -> Option<PathBuf>,
    which_probe: &dyn Fn() -> Option<PathBuf>,
) -> MicrosandboxResult<PathBuf> {
    if let Some(path) = env_msb {
        tracing::debug!(path = %path, source = "MSB_PATH env", "resolved msb binary");
        return Ok(PathBuf::from(path));
    }
    if let Some(path) = sdk_msb {
        tracing::debug!(path = %path.display(), source = "SDK runtime path", "resolved msb binary");
        return Ok(path.to_path_buf());
    }
    if let Some(path) = config_msb {
        tracing::debug!(path = %path.display(), source = "config.paths.msb", "resolved msb binary");
        return Ok(path.to_path_buf());
    }
    if let Some(path) = debug_probe() {
        tracing::debug!(path = %path.display(), source = "workspace-local msb", "resolved msb binary");
        return Ok(path);
    }
    if let Some(path) = home_probe() {
        tracing::debug!(path = %path.display(), source = "~/.microsandbox/bin/msb", "resolved msb binary");
        return Ok(path);
    }
    if let Some(path) = which_probe() {
        tracing::debug!(path = %path.display(), source = "PATH lookup", "resolved msb binary");
        return Ok(path);
    }
    Err(MicrosandboxError::Custom(
        "msb binary not found. Run `cargo clean -p microsandbox && cargo build` to reinstall, \
         or set MSB_PATH to the binary location"
            .into(),
    ))
}

/// Set the `libkrunfw` path resolved by an SDK package (e.g. one that ships a
/// bundled libkrunfw dylib inside its language-package wheel/npm-package).
///
/// Set-once: subsequent calls are ignored. Sits at tier 2 of
/// [`resolve_libkrunfw_path`] — below user env (`MSB_LIBKRUNFW_PATH`) so a user
/// override always wins, above the config + filesystem fallbacks.
///
/// Mirrors [`set_sdk_msb_path`]; both share the same precedence shape.
pub fn set_sdk_libkrunfw_path(path: impl Into<PathBuf>) {
    let _ = SDK_LIBKRUNFW_PATH.set(path.into());
}

/// Resolve the path to `libkrunfw` against the supplied [`LocalConfig`].
///
/// Resolution order (highest first):
/// 1. `MSB_LIBKRUNFW_PATH` environment variable (user-facing override).
/// 2. SDK-provided runtime path (set via [`set_sdk_libkrunfw_path`], used by
///    FFI bindings that ship a bundled dylib).
/// 3. `config.paths.libkrunfw`.
/// 4. A sibling of the resolved `msb` binary (for `build/msb`).
/// 5. `../lib/` next to the resolved `msb` binary (for installed layouts).
/// 6. `{home}/lib/libkrunfw.{so,dylib}`.
pub fn resolve_libkrunfw_path(config: &LocalConfig) -> MicrosandboxResult<PathBuf> {
    if let Ok(env_path) = std::env::var("MSB_LIBKRUNFW_PATH") {
        let path = PathBuf::from(env_path);
        if path.is_file() {
            tracing::debug!(path = %path.display(), source = "MSB_LIBKRUNFW_PATH env", "resolved libkrunfw");
            return Ok(path);
        }
        return Err(MicrosandboxError::LibkrunfwNotFound(format!(
            "MSB_LIBKRUNFW_PATH points to non-file: {}",
            path.display()
        )));
    }
    if let Some(sdk_path) = SDK_LIBKRUNFW_PATH.get() {
        if sdk_path.is_file() {
            tracing::debug!(path = %sdk_path.display(), source = "SDK runtime path", "resolved libkrunfw");
            return Ok(sdk_path.clone());
        }
        // SDK path set but missing — fall through to config + fallbacks rather than error.
        tracing::warn!(path = %sdk_path.display(), "SDK_LIBKRUNFW_PATH points to non-file; falling through to config + filesystem fallbacks");
    }
    if let Some(path) = &config.paths.libkrunfw {
        if path.is_file() {
            return Ok(path.clone());
        }
        return Err(MicrosandboxError::LibkrunfwNotFound(format!(
            "configured path does not exist: {}",
            path.display()
        )));
    }

    let os = if cfg!(target_os = "macos") {
        "macos"
    } else {
        "linux"
    };
    let filename = microsandbox_utils::libkrunfw_filename(os);
    let home_fallback = config
        .home()
        .join(microsandbox_utils::LIB_SUBDIR)
        .join(&filename);

    let mut candidates = Vec::new();
    if let Ok(msb_path) = resolve_msb_path(config) {
        candidates.extend(libkrunfw_candidates_from_msb(&msb_path, &filename));
    }
    candidates.push(home_fallback);

    if let Some(path) = candidates.iter().find(|path| path.is_file()) {
        tracing::debug!(path = %path.display(), "resolved libkrunfw path");
        return Ok(path.clone());
    }

    let searched = candidates
        .iter()
        .map(|path| path.display().to_string())
        .collect::<Vec<_>>()
        .join(", ");
    Err(MicrosandboxError::LibkrunfwNotFound(format!(
        "searched: {searched}"
    )))
}

fn libkrunfw_candidates_from_msb(msb_path: &Path, filename: &str) -> Vec<PathBuf> {
    let mut candidates = Vec::new();

    if let Some(msb_dir) = msb_path.parent() {
        candidates.push(msb_dir.join(filename));

        if let Some(parent) = msb_dir.parent() {
            candidates.push(parent.join(microsandbox_utils::LIB_SUBDIR).join(filename));
        }
    }

    let mut deduped = Vec::new();
    for path in candidates {
        if !deduped.iter().any(|existing| existing == &path) {
            deduped.push(path);
        }
    }

    deduped
}

#[cfg(debug_assertions)]
fn dev_msb_candidates_from(start: &Path) -> Vec<PathBuf> {
    let mut candidates = Vec::new();

    for ancestor in start.ancestors() {
        if !ancestor.join("Cargo.toml").is_file() {
            continue;
        }

        candidates.push(ancestor.join("build").join(microsandbox_utils::MSB_BINARY));
    }

    dedupe_paths(&mut candidates);
    candidates
}

#[cfg(debug_assertions)]
fn dedupe_paths(paths: &mut Vec<PathBuf>) {
    let mut deduped = Vec::new();
    for path in paths.drain(..) {
        if !deduped.iter().any(|existing| existing == &path) {
            deduped.push(path);
        }
    }
    *paths = deduped;
}

fn dedupe_strings(values: &mut Vec<String>) {
    let mut deduped = Vec::new();
    for value in values.drain(..) {
        if !deduped.iter().any(|existing| existing == &value) {
            deduped.push(value);
        }
    }
    *values = deduped;
}

fn read_config_from(path: &Path) -> MicrosandboxResult<LocalConfig> {
    let content = std::fs::read_to_string(path).map_err(|e| {
        MicrosandboxError::Custom(format!("failed to read config `{}`: {e}", path.display()))
    })?;

    serde_json::from_str(&content).map_err(|e| {
        MicrosandboxError::InvalidConfig(format!(
            "failed to parse config `{}`: {e}",
            path.display()
        ))
    })
}

/// Resolve the default home directory (`~/.microsandbox`, or `$MSB_HOME` if set).
fn resolve_default_home() -> PathBuf {
    microsandbox_utils::resolve_home()
}

#[cfg(all(
    feature = "keyring",
    any(target_os = "linux", target_os = "macos", target_os = "windows")
))]
fn store_registry_keyring_auth(
    hostname: &str,
    username: &str,
    password: &str,
) -> Result<(), String> {
    let entry = keyring::Entry::new(REGISTRY_KEYRING_SERVICE, hostname)
        .map_err(|e| format!("failed to open OS credential store entry for `{hostname}`: {e}"))?;

    let payload = serde_json::to_vec(&KeyringRegistryCredential {
        username: username.to_string(),
        password: password.to_string(),
    })
    .map_err(|e| format!("failed to serialize keyring credential for `{hostname}`: {e}"))?;

    entry
        .set_secret(&payload)
        .map_err(|e| format!("failed to store OS credential for `{hostname}`: {e}"))
}

#[cfg(not(all(
    feature = "keyring",
    any(target_os = "linux", target_os = "macos", target_os = "windows")
)))]
fn store_registry_keyring_auth(
    hostname: &str,
    _username: &str,
    _password: &str,
) -> Result<(), String> {
    Err(keyring_unavailable_message(hostname))
}

#[cfg(all(
    feature = "keyring",
    any(target_os = "linux", target_os = "macos", target_os = "windows")
))]
fn load_keyring_registry_credential(
    hostname: &str,
) -> Result<Option<KeyringRegistryCredential>, String> {
    let entry = keyring::Entry::new(REGISTRY_KEYRING_SERVICE, hostname)
        .map_err(|e| format!("failed to open OS credential store entry for `{hostname}`: {e}"))?;

    let payload = match entry.get_secret() {
        Ok(payload) => payload,
        Err(keyring::Error::NoEntry) => return Ok(None),
        Err(error) => {
            return Err(format!(
                "failed to read OS credential for `{hostname}`: {error}"
            ));
        }
    };

    serde_json::from_slice(&payload)
        .map(Some)
        .map_err(|e| format!("failed to decode OS credential for `{hostname}`: {e}"))
}

#[cfg(not(all(
    feature = "keyring",
    any(target_os = "linux", target_os = "macos", target_os = "windows")
)))]
fn load_keyring_registry_credential(
    hostname: &str,
) -> Result<Option<KeyringRegistryCredential>, String> {
    Err(keyring_unavailable_message(hostname))
}

#[cfg(all(
    feature = "keyring",
    any(target_os = "linux", target_os = "macos", target_os = "windows")
))]
fn remove_registry_keyring_auth(hostname: &str) -> Result<(), String> {
    let entry = keyring::Entry::new(REGISTRY_KEYRING_SERVICE, hostname)
        .map_err(|e| format!("failed to open OS credential store entry for `{hostname}`: {e}"))?;

    match entry.delete_credential() {
        Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
        Err(error) => Err(format!(
            "failed to delete OS credential for `{hostname}`: {error}"
        )),
    }
}

#[cfg(not(all(
    feature = "keyring",
    any(target_os = "linux", target_os = "macos", target_os = "windows")
)))]
fn remove_registry_keyring_auth(hostname: &str) -> Result<(), String> {
    Err(keyring_unavailable_message(hostname))
}

#[cfg(not(all(
    feature = "keyring",
    any(target_os = "linux", target_os = "macos", target_os = "windows")
)))]
fn keyring_unavailable_message(hostname: &str) -> String {
    #[cfg(not(feature = "keyring"))]
    {
        format!(
            "secure OS credential storage is disabled; enable the `keyring` feature to use it for `{hostname}`"
        )
    }

    #[cfg(all(
        feature = "keyring",
        not(any(target_os = "linux", target_os = "macos", target_os = "windows"))
    ))]
    format!("secure OS credential storage is not supported on this platform for `{hostname}`")
}

//--------------------------------------------------------------------------------------------------
// Tests
//--------------------------------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    use std::collections::VecDeque;

    #[test]
    fn test_default_config() {
        let cfg = LocalConfig::default();
        assert_eq!(cfg.sandbox_defaults.cpus, 1);
        assert_eq!(cfg.sandbox_defaults.memory_mib, 512);
        assert_eq!(cfg.sandbox_defaults.oci.upper_size_mib, None);
        assert_eq!(cfg.sandbox_defaults.shell, "/bin/sh");
        assert_eq!(
            cfg.sandbox_defaults.metrics_sample_interval_ms,
            NonZero::new(DEFAULT_METRICS_SAMPLE_INTERVAL_MS)
        );
        assert_eq!(cfg.log_level, None);
        assert_eq!(cfg.database.max_connections, 5);
        assert_eq!(cfg.database.connect_timeout_secs, 30);
        assert_eq!(cfg.database.busy_timeout_secs, 5);
    }

    #[test]
    fn test_deserialize_empty_json() {
        let cfg: LocalConfig = serde_json::from_str("{}").unwrap();
        assert_eq!(cfg.sandbox_defaults.cpus, 1);
        assert!(cfg.home.is_none());
    }

    #[test]
    fn test_deserialize_partial_json() {
        let json = r#"{"sandbox_defaults": {"cpus": 4}}"#;
        let cfg: LocalConfig = serde_json::from_str(json).unwrap();
        assert_eq!(cfg.sandbox_defaults.cpus, 4);
        assert_eq!(cfg.sandbox_defaults.memory_mib, 512);
    }

    #[test]
    fn test_deserialize_metrics_interval_missing_uses_default() {
        let json = r#"{"sandbox_defaults": {}}"#;
        let cfg: LocalConfig = serde_json::from_str(json).unwrap();
        assert_eq!(
            cfg.sandbox_defaults.metrics_sample_interval_ms,
            NonZero::new(DEFAULT_METRICS_SAMPLE_INTERVAL_MS)
        );
    }

    #[test]
    fn test_deserialize_metrics_interval_zero_disables() {
        let json = r#"{"sandbox_defaults": {"metrics_sample_interval_ms": 0}}"#;
        let cfg: LocalConfig = serde_json::from_str(json).unwrap();
        assert!(cfg.sandbox_defaults.metrics_sample_interval_ms.is_none());
    }

    #[test]
    fn test_deserialize_metrics_interval_positive() {
        let json = r#"{"sandbox_defaults": {"metrics_sample_interval_ms": 2500}}"#;
        let cfg: LocalConfig = serde_json::from_str(json).unwrap();
        assert_eq!(
            cfg.sandbox_defaults.metrics_sample_interval_ms,
            NonZero::new(2500)
        );
    }

    #[test]
    fn test_serialize_metrics_interval_disabled_round_trips() {
        let mut cfg = LocalConfig::default();
        cfg.sandbox_defaults.metrics_sample_interval_ms = None;
        let json = serde_json::to_string(&cfg).unwrap();
        assert!(
            json.contains("\"metrics_sample_interval_ms\":0"),
            "expected `0` serialization, got: {json}"
        );
        let round: LocalConfig = serde_json::from_str(&json).unwrap();
        assert!(round.sandbox_defaults.metrics_sample_interval_ms.is_none());
    }

    #[test]
    fn test_metrics_capacity_default_uses_crate_default() {
        let cfg = LocalConfig::default();
        assert_eq!(
            cfg.metrics_registry_capacity(),
            microsandbox_metrics::default_capacity()
        );
    }

    #[test]
    fn test_metrics_capacity_zero_falls_back_to_default() {
        let json = r#"{"metrics": {"capacity": 0}}"#;
        let cfg: LocalConfig = serde_json::from_str(json).unwrap();
        assert_eq!(cfg.metrics.capacity, 0);
        assert_eq!(
            cfg.metrics_registry_capacity(),
            microsandbox_metrics::default_capacity()
        );
    }

    #[test]
    fn test_metrics_capacity_explicit_value_overrides_default() {
        let json = r#"{"metrics": {"capacity": 2048}}"#;
        let cfg: LocalConfig = serde_json::from_str(json).unwrap();
        assert_eq!(cfg.metrics.capacity, 2048);
        assert_eq!(cfg.metrics_registry_capacity(), 2048);
    }

    #[test]
    fn test_deserialize_disable_metrics_sample_default_false() {
        let cfg: LocalConfig = serde_json::from_str("{}").unwrap();
        assert!(!cfg.sandbox_defaults.disable_metrics_sample);
    }

    #[test]
    fn test_deserialize_disable_metrics_sample_true() {
        let json = r#"{"sandbox_defaults": {"disable_metrics_sample": true}}"#;
        let cfg: LocalConfig = serde_json::from_str(json).unwrap();
        assert!(cfg.sandbox_defaults.disable_metrics_sample);
    }

    #[test]
    fn test_deserialize_log_level() {
        let json = r#"{"log_level":"debug"}"#;
        let cfg: LocalConfig = serde_json::from_str(json).unwrap();
        assert_eq!(cfg.log_level, Some(LogLevel::Debug));
    }

    #[test]
    fn test_deserialize_database_config() {
        let json = r#"{
            "database": {
                "max_connections": 9,
                "connect_timeout_secs": 7,
                "busy_timeout_secs": 12
            }
        }"#;
        let cfg: LocalConfig = serde_json::from_str(json).unwrap();
        assert_eq!(cfg.database.max_connections, 9);
        assert_eq!(cfg.database.connect_timeout_secs, 7);
        assert_eq!(cfg.database.busy_timeout_secs, 12);
    }

    #[test]
    fn test_home_resolution() {
        let cfg = LocalConfig {
            home: Some(PathBuf::from("/custom/home")),
            ..Default::default()
        };
        assert_eq!(cfg.home(), PathBuf::from("/custom/home"));
    }

    #[test]
    fn test_sandboxes_dir_override() {
        let cfg = LocalConfig {
            paths: PathsConfig {
                sandboxes: Some(PathBuf::from("/custom/sandboxes")),
                ..Default::default()
            },
            ..Default::default()
        };
        assert_eq!(cfg.sandboxes_dir(), PathBuf::from("/custom/sandboxes"));
    }

    #[test]
    fn test_load_config_from_missing_file() {
        let result = read_config_from(Path::new("/nonexistent/config.json"));
        assert!(result.is_err());
    }

    /// Helper to build a `RegistriesConfig` from a list of `(hostname, RegistryEntry)` pairs.
    fn registries(entries: Vec<(&str, RegistryEntry)>) -> RegistriesConfig {
        RegistriesConfig {
            hosts: entries
                .into_iter()
                .map(|(k, v)| (k.to_string(), v))
                .collect(),
            ..Default::default()
        }
    }

    #[test]
    fn test_deserialize_registry_keyring_store() {
        let json = r#"{
            "registries": {
                "hosts": {
                    "ghcr.io": {
                        "auth": {
                            "username": "octocat",
                            "store": "keyring"
                        }
                    }
                }
            }
        }"#;

        let cfg: LocalConfig = serde_json::from_str(json).unwrap();
        let entry = cfg
            .registries
            .hosts
            .get("ghcr.io")
            .unwrap()
            .auth
            .as_ref()
            .unwrap();
        assert_eq!(entry.username, "octocat");
        assert_eq!(entry.store, Some(RegistryCredentialStore::Keyring));
        assert!(entry.password_env.is_none());
        assert!(entry.secret_name.is_none());
    }

    #[test]
    fn test_save_and_read_persisted_config_roundtrip() {
        let temp = tempfile::tempdir().unwrap();
        let path = temp.path().join("config.json");

        let cfg = LocalConfig {
            registries: registries(vec![(
                "ghcr.io",
                RegistryEntry {
                    auth: Some(RegistryAuthEntry {
                        username: "octocat".to_string(),
                        store: Some(RegistryCredentialStore::Keyring),
                        password_env: None,
                        secret_name: None,
                    }),
                    ..Default::default()
                },
            )]),
            ..Default::default()
        };

        let content = serde_json::to_string_pretty(&cfg).unwrap();
        std::fs::write(&path, content).unwrap();

        let loaded = read_config_from(&path).unwrap();
        let entry = loaded
            .registries
            .hosts
            .get("ghcr.io")
            .unwrap()
            .auth
            .as_ref()
            .unwrap();
        assert_eq!(entry.username, "octocat");
        assert_eq!(entry.store, Some(RegistryCredentialStore::Keyring));
    }

    #[test]
    fn test_libkrunfw_candidates_for_build_msb() {
        let msb = PathBuf::from("/repo/build/msb");
        let paths = libkrunfw_candidates_from_msb(&msb, "libkrunfw.5.dylib");
        assert_eq!(paths[0], PathBuf::from("/repo/build/libkrunfw.5.dylib"));
        assert_eq!(paths[1], PathBuf::from("/repo/lib/libkrunfw.5.dylib"));
    }

    #[test]
    fn test_libkrunfw_candidates_for_target_msb() {
        let msb = PathBuf::from("/repo/target/debug/msb");
        let paths = libkrunfw_candidates_from_msb(&msb, "libkrunfw.5.dylib");
        assert_eq!(
            paths[0],
            PathBuf::from("/repo/target/debug/libkrunfw.5.dylib")
        );
        assert_eq!(
            paths[1],
            PathBuf::from("/repo/target/lib/libkrunfw.5.dylib")
        );
        assert_eq!(paths.len(), 2);
    }

    #[test]
    fn test_dev_msb_candidates_from_workspace_root() {
        let temp = tempfile::tempdir().unwrap();
        std::fs::write(temp.path().join("Cargo.toml"), "[workspace]\n").unwrap();

        let paths = dev_msb_candidates_from(temp.path());
        assert_eq!(paths.len(), 1);
        assert_eq!(paths[0], temp.path().join("build").join("msb"));
    }

    #[test]
    fn test_resolve_configured_registry_auth_reads_secret_file() {
        let temp = tempfile::tempdir().unwrap();
        let secret_dir = temp.path().join("registries");
        std::fs::create_dir_all(&secret_dir).unwrap();
        std::fs::write(secret_dir.join("ghcr-token"), "secret-token\n").unwrap();

        let cfg = LocalConfig {
            home: Some(temp.path().to_path_buf()),
            paths: PathsConfig {
                secrets: Some(temp.path().to_path_buf()),
                ..Default::default()
            },
            registries: registries(vec![(
                "ghcr.io",
                RegistryEntry {
                    auth: Some(RegistryAuthEntry {
                        username: "user".to_string(),
                        store: None,
                        password_env: None,
                        secret_name: Some("ghcr-token".to_string()),
                    }),
                    ..Default::default()
                },
            )]),
            ..Default::default()
        };

        let auth = cfg.resolve_configured_registry_auth("ghcr.io").unwrap();
        match auth {
            Some(RegistryAuth::Basic { username, password }) => {
                assert_eq!(username, "user");
                assert_eq!(password, "secret-token");
            }
            other => panic!("expected basic auth, got {other:?}"),
        }
    }

    #[test]
    fn test_resolve_configured_registry_auth_rejects_multiple_sources() {
        let cfg = LocalConfig {
            registries: registries(vec![(
                "ghcr.io",
                RegistryEntry {
                    auth: Some(RegistryAuthEntry {
                        username: "user".to_string(),
                        store: Some(RegistryCredentialStore::Keyring),
                        password_env: Some("GHCR_TOKEN".to_string()),
                        secret_name: None,
                    }),
                    ..Default::default()
                },
            )]),
            ..Default::default()
        };

        let error = cfg.resolve_configured_registry_auth("ghcr.io").unwrap_err();
        assert!(
            error
                .to_string()
                .contains("entry defines multiple credential sources")
        );
    }

    #[test]
    fn test_resolve_registry_auth_with_lookup_prefers_exact_hostname() {
        let auth = resolve_registry_auth_with_lookup("ghcr.io", |server| match server {
            "ghcr.io" => Ok(DockerCredential::UsernamePassword(
                "user".to_string(),
                "token".to_string(),
            )),
            other => panic!("unexpected server lookup: {other}"),
        });

        match auth {
            Some(RegistryAuth::Basic { username, password }) => {
                assert_eq!(username, "user");
                assert_eq!(password, "token");
            }
            other => panic!("expected basic auth, got {other:?}"),
        }
    }

    #[test]
    fn test_resolve_registry_auth_with_lookup_tries_docker_hub_aliases() {
        let auth = resolve_registry_auth_with_lookup("docker.io", |server| match server {
            "https://index.docker.io/v1/" => Ok(DockerCredential::UsernamePassword(
                "docker-user".to_string(),
                "docker-pass".to_string(),
            )),
            _ => Err(CredentialRetrievalError::NoCredentialConfigured),
        });

        match auth {
            Some(RegistryAuth::Basic { username, password }) => {
                assert_eq!(username, "docker-user");
                assert_eq!(password, "docker-pass");
            }
            other => panic!("expected basic auth, got {other:?}"),
        }
    }

    #[test]
    fn test_resolve_registry_auth_with_lookup_skips_identity_tokens() {
        let mut responses = VecDeque::from([
            Ok(DockerCredential::IdentityToken(
                "identity-token".to_string(),
            )),
            Ok(DockerCredential::UsernamePassword(
                "fallback-user".to_string(),
                "fallback-pass".to_string(),
            )),
        ]);

        let auth = resolve_registry_auth_with_lookup("ghcr.io", |_server| {
            responses
                .pop_front()
                .unwrap_or(Err(CredentialRetrievalError::NoCredentialConfigured))
        });

        match auth {
            Some(RegistryAuth::Basic { username, password }) => {
                assert_eq!(username, "fallback-user");
                assert_eq!(password, "fallback-pass");
            }
            other => panic!("expected basic auth, got {other:?}"),
        }
    }

    #[test]
    fn test_deserialize_registry_insecure() {
        let json = r#"{
            "registries": {
                "hosts": {
                    "localhost:5050": { "insecure": true }
                }
            }
        }"#;

        let cfg: LocalConfig = serde_json::from_str(json).unwrap();
        let entry = cfg.registries.hosts.get("localhost:5050").unwrap();
        assert!(entry.insecure);
        assert!(entry.auth.is_none());
    }

    #[test]
    fn test_deserialize_registry_ca_certs_global() {
        let json = r#"{
            "registries": {
                "ca_certs": "/path/to/ca.pem"
            }
        }"#;

        let cfg: LocalConfig = serde_json::from_str(json).unwrap();
        assert_eq!(
            cfg.registries.ca_certs,
            Some(PathBuf::from("/path/to/ca.pem"))
        );
    }

    #[test]
    fn test_deserialize_registry_full_entry() {
        let json = r#"{
            "registries": {
                "ca_certs": "/path/to/ca.pem",
                "hosts": {
                    "localhost:5050": {
                        "insecure": true,
                        "auth": {
                            "username": "user",
                            "password_env": "TOKEN"
                        }
                    }
                }
            }
        }"#;

        let cfg: LocalConfig = serde_json::from_str(json).unwrap();
        assert_eq!(
            cfg.registries.ca_certs,
            Some(PathBuf::from("/path/to/ca.pem"))
        );
        let entry = cfg.registries.hosts.get("localhost:5050").unwrap();
        assert!(entry.insecure);
        let auth = entry.auth.as_ref().unwrap();
        assert_eq!(auth.username, "user");
        assert_eq!(auth.password_env, Some("TOKEN".to_string()));
    }

    #[test]
    fn test_deserialize_empty_registries() {
        let json = r#"{"registries": {}}"#;
        let cfg: LocalConfig = serde_json::from_str(json).unwrap();
        assert!(cfg.registries.hosts.is_empty());
        assert!(cfg.registries.ca_certs.is_none());
    }

    #[tokio::test]
    async fn test_resolve_ca_certs_from_file() {
        let temp = tempfile::tempdir().unwrap();
        let pem_path = temp.path().join("ca.pem");
        let pem_data = b"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n";
        std::fs::write(&pem_path, pem_data).unwrap();

        let cfg = LocalConfig {
            registries: RegistriesConfig {
                ca_certs: Some(pem_path),
                ..Default::default()
            },
            ..Default::default()
        };

        let certs = cfg.resolve_ca_certs().await.unwrap();
        assert_eq!(certs.len(), 1);
        assert_eq!(certs[0], pem_data);
    }

    #[tokio::test]
    async fn test_resolve_ca_certs_missing_file_errors() {
        let cfg = LocalConfig {
            registries: RegistriesConfig {
                ca_certs: Some(PathBuf::from("/nonexistent/ca.pem")),
                ..Default::default()
            },
            ..Default::default()
        };

        let err = cfg.resolve_ca_certs().await.unwrap_err();
        assert!(err.to_string().contains("failed to read CA certs"));
    }

    #[tokio::test]
    async fn test_resolve_ca_certs_none_returns_empty() {
        let cfg = LocalConfig::default();
        let certs = cfg.resolve_ca_certs().await.unwrap();
        assert!(certs.is_empty());
    }

    #[test]
    fn test_insecure_registries() {
        let cfg = LocalConfig {
            registries: registries(vec![
                (
                    "localhost:5050",
                    RegistryEntry {
                        insecure: true,
                        ..Default::default()
                    },
                ),
                (
                    "ghcr.io",
                    RegistryEntry {
                        ..Default::default()
                    },
                ),
            ]),
            ..Default::default()
        };

        let insecure = cfg.insecure_registries();
        assert_eq!(insecure, vec!["localhost:5050"]);
    }

    //----------------------------------------------------------------------------------------------
    // resolve_msb_path precedence
    //----------------------------------------------------------------------------------------------

    fn pb(s: &str) -> PathBuf {
        PathBuf::from(s)
    }

    fn none() -> Option<PathBuf> {
        None
    }

    #[test]
    fn resolve_msb_path_env_wins_over_everything() {
        let got = resolve_msb_path_from(
            Some("/from/env"),
            Some(Path::new("/from/sdk")),
            Some(Path::new("/from/config")),
            &|| Some(pb("/from/debug")),
            &|| Some(pb("/from/home")),
            &|| Some(pb("/from/which")),
        )
        .unwrap();
        assert_eq!(got, pb("/from/env"));
    }

    #[test]
    fn resolve_msb_path_sdk_wins_when_env_missing() {
        let got = resolve_msb_path_from(
            None,
            Some(Path::new("/from/sdk")),
            Some(Path::new("/from/config")),
            &|| Some(pb("/from/debug")),
            &|| Some(pb("/from/home")),
            &|| Some(pb("/from/which")),
        )
        .unwrap();
        assert_eq!(got, pb("/from/sdk"));
    }

    #[test]
    fn resolve_msb_path_config_wins_over_filesystem_tiers() {
        let got = resolve_msb_path_from(
            None,
            None,
            Some(Path::new("/from/config")),
            &|| Some(pb("/from/debug")),
            &|| Some(pb("/from/home")),
            &|| Some(pb("/from/which")),
        )
        .unwrap();
        assert_eq!(got, pb("/from/config"));
    }

    #[test]
    fn resolve_msb_path_debug_probe_wins_over_home_and_which() {
        let got = resolve_msb_path_from(
            None,
            None,
            None,
            &|| Some(pb("/from/debug")),
            &|| Some(pb("/from/home")),
            &|| Some(pb("/from/which")),
        )
        .unwrap();
        assert_eq!(got, pb("/from/debug"));
    }

    #[test]
    fn resolve_msb_path_home_wins_over_which() {
        let got =
            resolve_msb_path_from(None, None, None, &none, &|| Some(pb("/from/home")), &|| {
                Some(pb("/from/which"))
            })
            .unwrap();
        assert_eq!(got, pb("/from/home"));
    }

    #[test]
    fn resolve_msb_path_which_is_last_resort() {
        let got =
            resolve_msb_path_from(None, None, None, &none, &none, &|| Some(pb("/from/which")))
                .unwrap();
        assert_eq!(got, pb("/from/which"));
    }

    #[test]
    fn resolve_msb_path_errors_when_all_tiers_empty() {
        let result = resolve_msb_path_from(None, None, None, &none, &none, &none);
        assert!(matches!(result, Err(MicrosandboxError::Custom(_))));
    }
}
