use crate::backend::backend_type::BackendType;
use crate::backend::conda::CondaBackend;
use crate::backend::pkgx::PkgxBackend;
use crate::backend::platform_target::PlatformTarget;
use crate::config::{Config, Settings};
use crate::env;
use crate::file;
use crate::file::display_path;
use crate::path::PathExt;
use crate::platform::Platform;
use crate::toolset::{ToolSource, ToolVersion, Toolset};
use eyre::{Report, Result, bail, eyre};
use indexmap::IndexSet;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::sync::LazyLock as Lazy;
use std::sync::Mutex;
use std::{
    collections::{BTreeMap, BTreeSet, HashMap, HashSet},
    sync::Arc,
};
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use toml_edit::DocumentMut;
use xx::regex;

/// Global caches for lockfile data - declared here so invalidation can access them
static ALL_LOCKFILES_CACHE: Lazy<Mutex<HashMap<Vec<PathBuf>, Arc<Lockfile>>>> =
    Lazy::new(Default::default);
static SINGLE_LOCKFILE_CACHE: Lazy<Mutex<HashMap<Vec<PathBuf>, Arc<Lockfile>>>> =
    Lazy::new(Default::default);
type LegacyLockfilePathsCacheKey = (PathBuf, Option<bool>, Vec<String>);
static LEGACY_LOCKFILE_PATHS_CACHE: Lazy<
    Mutex<HashMap<LegacyLockfilePathsCacheKey, IndexSet<PathBuf>>>,
> = Lazy::new(Default::default);
const LOCKFILE_HEADER_PREFIX: &str = "# @generated - this file is auto-generated by `mise lock` ";
const DEFAULT_LOCKFILE_DOC_URL: &str = "https://mise.jdx.dev/dev-tools/mise-lock.html";

/// Invalidate all lockfile caches. Call this after modifying a lockfile.
pub fn invalidate_caches() {
    if let Ok(mut cache) = ALL_LOCKFILES_CACHE.lock() {
        cache.clear();
    }
    if let Ok(mut cache) = SINGLE_LOCKFILE_CACHE.lock() {
        cache.clear();
    }
    if let Ok(mut cache) = LEGACY_LOCKFILE_PATHS_CACHE.lock() {
        cache.clear();
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Lockfile {
    #[serde(skip)]
    generated_header_url: Option<String>,
    #[serde(skip)]
    tools: BTreeMap<String, Vec<LockfileTool>>,
    /// Shared conda packages: platform -> basename -> CondaPackageInfo
    /// Basename includes version+build (e.g., "ncurses-6.4-h7ea286d_0")
    #[serde(skip)]
    conda_packages: BTreeMap<String, BTreeMap<String, CondaPackageInfo>>,
    /// Shared pkgx packages: platform -> package@version -> PkgxPackageInfo
    #[serde(skip)]
    pkgx_packages: BTreeMap<String, BTreeMap<String, PkgxPackageInfo>>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockfileTool {
    pub version: String,
    pub backend: Option<String>,
    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
    pub options: BTreeMap<String, String>,
    #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
    pub platforms: BTreeMap<String, PlatformInfo>,
}

type LockfileToolKey = (String, BTreeMap<String, String>);
type MergeToolEntriesResult = (Vec<LockfileTool>, HashSet<LockfileToolKey>);

/// Type of detected or verified provenance, ordered by priority (lowest to highest).
/// The ordering is significant: during verification, higher-priority mechanisms
/// are tried first. Lockfile artifact entries separately record whether the
/// provenance was verified or only detected.
/// SLSA carries an optional URL for the provenance file (.intoto.jsonl).
///
/// If adding or reordering verified attestation variants, also update
/// `VerifiedAttestation` in `crates/vfox/src/hooks/pre_install.rs`.
#[derive(Debug, Clone, strum::Display, strum::EnumIs)]
#[strum(serialize_all = "kebab-case")]
pub enum ProvenanceType {
    Minisign,
    Cosign,
    #[strum(serialize = "slsa")]
    Slsa {
        url: Option<String>,
    },
    GithubAttestations,
}

impl std::str::FromStr for ProvenanceType {
    type Err = String;
    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        match s {
            "minisign" => Ok(Self::Minisign),
            "cosign" => Ok(Self::Cosign),
            "slsa" => Ok(Self::Slsa { url: None }),
            "github-attestations" => Ok(Self::GithubAttestations),
            other => Err(format!("unknown provenance type: {other}")),
        }
    }
}

/// PartialEq, Eq, Hash, and Ord all compare by ordinal (variant priority) only.
/// `Slsa { url: None } == Slsa { url: Some("x") }` — this is intentional so that
/// variant priority determines equality and ordering, not inner data.
/// Do NOT use `ProvenanceType` as a `BTreeMap`/`HashSet` key for this reason.
/// Use `merge()` instead of `max()` when both values may carry data to preserve.
impl PartialEq for ProvenanceType {
    fn eq(&self, other: &Self) -> bool {
        self.ordinal() == other.ordinal()
    }
}

impl Eq for ProvenanceType {}

impl std::hash::Hash for ProvenanceType {
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
        self.ordinal().hash(state);
    }
}

impl ProvenanceType {
    /// Discriminant for ordering (lowest = lowest priority)
    fn ordinal(&self) -> u8 {
        match self {
            Self::Minisign => 1,
            Self::Cosign => 2,
            Self::Slsa { .. } => 3,
            Self::GithubAttestations => 4,
        }
    }

    /// Merge two provenance values, keeping the higher-priority variant.
    /// When both are `Slsa`, preserves the URL from whichever has one.
    fn merge(self, other: Self) -> Self {
        match (&self, &other) {
            (Self::Slsa { url: a }, Self::Slsa { url: b }) => Self::Slsa {
                url: a.clone().or_else(|| b.clone()),
            },
            _ => std::cmp::max(self, other),
        }
    }
}

impl PartialOrd for ProvenanceType {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for ProvenanceType {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.ordinal().cmp(&other.ordinal())
    }
}

impl serde::Serialize for ProvenanceType {
    fn serialize<S: serde::Serializer>(
        &self,
        serializer: S,
    ) -> std::result::Result<S::Ok, S::Error> {
        match self {
            Self::Slsa { url: Some(u) } => {
                // Serialize as { slsa = { url = "..." } } only when URL is present
                use serde::ser::SerializeMap;
                let mut slsa_map = std::collections::BTreeMap::new();
                slsa_map.insert("url", u.as_str());
                let mut outer = serializer.serialize_map(Some(1))?;
                outer.serialize_entry("slsa", &slsa_map)?;
                outer.end()
            }
            // Slsa without URL serializes as plain string, like other variants
            _ => serializer.serialize_str(&self.to_string()),
        }
    }
}

impl<'de> serde::Deserialize<'de> for ProvenanceType {
    fn deserialize<D: serde::Deserializer<'de>>(
        deserializer: D,
    ) -> std::result::Result<Self, D::Error> {
        use serde::de;

        struct ProvenanceVisitor;
        impl<'de> de::Visitor<'de> for ProvenanceVisitor {
            type Value = ProvenanceType;
            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
                write!(f, "a provenance string or table")
            }
            fn visit_str<E: de::Error>(self, s: &str) -> std::result::Result<Self::Value, E> {
                s.parse().map_err(de::Error::custom)
            }
            fn visit_map<A: de::MapAccess<'de>>(
                self,
                mut map: A,
            ) -> std::result::Result<Self::Value, A::Error> {
                let key: String = map
                    .next_key()?
                    .ok_or_else(|| de::Error::custom("empty provenance table"))?;
                let result = match key.as_str() {
                    "slsa" => {
                        #[derive(serde::Deserialize)]
                        struct SlsaInner {
                            url: Option<String>,
                        }
                        let inner: SlsaInner = map.next_value()?;
                        Ok(ProvenanceType::Slsa { url: inner.url })
                    }
                    other => Err(de::Error::custom(format!(
                        "unknown provenance table key: {other}"
                    ))),
                }?;
                // Drain any remaining entries to satisfy strict deserializers
                while map.next_entry::<String, de::IgnoredAny>()?.is_some() {}
                Ok(result)
            }
        }
        deserializer.deserialize_any(ProvenanceVisitor)
    }
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, strum::Display)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
pub enum GithubAttestationsStatus {
    Unavailable,
}

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

fn merge_provenance_state(
    current: Option<ProvenanceType>,
    current_verified: bool,
    other: Option<ProvenanceType>,
    other_verified: bool,
) -> (Option<ProvenanceType>, bool) {
    match (current, other) {
        (Some(current), Some(_)) if current_verified && !other_verified => (Some(current), true),
        (Some(_), Some(other)) if !current_verified && other_verified => (Some(other), true),
        (Some(current), Some(other)) => (
            Some(current.merge(other)),
            current_verified && other_verified,
        ),
        (Some(current), None) => (Some(current), current_verified),
        (None, Some(other)) => (Some(other), other_verified),
        (None, None) => (None, false),
    }
}

#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ArtifactInfo {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub checksum: Option<String>,
    /// Size in bytes (read-only field, preserved from existing lockfiles but not written)
    #[serde(skip_serializing, default)]
    pub size: Option<u64>,
    pub url: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url_api: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub provenance: Option<ProvenanceType>,
    /// Whether `provenance` was cryptographically verified for this artifact.
    #[serde(default, skip_serializing_if = "is_false")]
    pub provenance_verified: bool,
}

impl ArtifactInfo {
    pub fn has_checksum_and_verified_provenance(&self) -> bool {
        self.checksum.is_some() && self.provenance.is_some() && self.provenance_verified
    }

    fn merge_with(&self, other: &ArtifactInfo) -> ArtifactInfo {
        if self.url != other.url {
            return self.clone();
        }
        let checksum = match (&self.checksum, &other.checksum) {
            (Some(self_checksum), Some(other_checksum)) => {
                if other_checksum.starts_with("sha256:") && !self_checksum.starts_with("sha256:") {
                    Some(other_checksum.clone())
                } else {
                    Some(self_checksum.clone())
                }
            }
            (Some(checksum), None) | (None, Some(checksum)) => Some(checksum.clone()),
            (None, None) => None,
        };
        let (provenance, provenance_verified) = merge_provenance_state(
            self.provenance.clone(),
            self.provenance_verified,
            other.provenance.clone(),
            other.provenance_verified,
        );
        ArtifactInfo {
            checksum,
            size: self.size.or(other.size),
            url: self.url.clone(),
            url_api: self.url_api.clone().or_else(|| other.url_api.clone()),
            provenance,
            provenance_verified,
        }
    }
}

fn merge_additional_artifacts(
    current: &[ArtifactInfo],
    other: &[ArtifactInfo],
) -> Vec<ArtifactInfo> {
    let shared_len = current.len().min(other.len());
    let mut merged = current
        .iter()
        .zip(other)
        .map(|(current, other)| current.merge_with(other))
        .collect::<Vec<_>>();
    if current.len() > shared_len {
        merged.extend_from_slice(&current[shared_len..]);
    } else if other.len() > shared_len {
        merged.extend_from_slice(&other[shared_len..]);
    }
    merged
}

#[derive(Debug, Default, Clone, Serialize, PartialEq, Eq)]
pub struct PlatformInfo {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub install: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub checksum: Option<String>,
    /// Size in bytes (read-only field, preserved from existing lockfiles but not written)
    #[serde(skip_serializing, default)]
    pub size: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url_api: Option<String>,
    /// References to conda packages in the shared conda-packages section (by basename)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub conda_deps: Option<Vec<String>>,
    /// References to pkgx packages in the shared pkgx-packages section (by package@version)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pkgx_deps: Option<Vec<String>>,
    /// Pkgx-provided binaries for the main package, captured for locked installs.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pkgx_provides: Option<Vec<String>>,
    /// Pkgx runtime environment for the main package, captured for locked installs.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pkgx_runtime_env: Option<BTreeMap<String, String>>,
    /// Type of provenance detected or verified (SLSA carries its URL).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub provenance: Option<ProvenanceType>,
    /// Whether `provenance` was cryptographically verified for this platform.
    #[serde(default, skip_serializing_if = "is_false")]
    pub provenance_verified: bool,
    /// GitHub attestation probe status when no provenance was verified.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub github_attestations: Option<GithubAttestationsStatus>,
    /// Ordered release artifacts extracted into the primary artifact's install directory.
    #[serde(skip_serializing_if = "Vec::is_empty", default)]
    pub additional_artifacts: Vec<ArtifactInfo>,
}

// Re-export CondaPackageInfo from conda backend for lockfile serialization
pub use crate::backend::conda::CondaPackageInfo;
pub use crate::backend::pkgx::PkgxPackageInfo;

impl<'de> Deserialize<'de> for PlatformInfo {
    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let value = toml::Value::deserialize(deserializer)?;
        PlatformInfo::try_from(value).map_err(serde::de::Error::custom)
    }
}

impl PlatformInfo {
    /// Returns true if this PlatformInfo has no meaningful data (for serde skip)
    pub fn is_empty(&self) -> bool {
        self.install.is_none()
            && self.checksum.is_none()
            && self.url.is_none()
            && self.url_api.is_none()
            && self.conda_deps.is_none()
            && self.pkgx_deps.is_none()
            && self.pkgx_provides.is_none()
            && self.pkgx_runtime_env.is_none()
            && self.provenance.is_none()
            && !self.provenance_verified
            && self.additional_artifacts.is_empty()
    }

    /// Drop the fields that describe one specific downloaded artifact, keeping
    /// the fields that describe how the tool is built or what it depends on.
    /// Used when an entry is known to be for the right tool version but not
    /// necessarily the right artifact.
    pub fn without_artifact_data(&self) -> Self {
        PlatformInfo {
            install: self.install.clone(),
            conda_deps: self.conda_deps.clone(),
            pkgx_deps: self.pkgx_deps.clone(),
            pkgx_provides: self.pkgx_provides.clone(),
            pkgx_runtime_env: self.pkgx_runtime_env.clone(),
            checksum: None,
            size: None,
            url: None,
            url_api: None,
            provenance: None,
            provenance_verified: false,
            github_attestations: None,
            additional_artifacts: Default::default(),
        }
    }

    /// True when the lockfile has checksum-backed, successfully verified provenance.
    pub fn has_checksum_and_verified_provenance(&self) -> bool {
        self.checksum.is_some() && self.provenance.is_some() && self.provenance_verified
    }

    /// Merge this PlatformInfo with another, preserving important data.
    /// - Prefers sha256 checksums over blake3 (more portable/verifiable)
    /// - Preserves URL if missing in self
    /// - Preserves url_api if missing in self
    /// - Drops the other side's checksum/size/url_api when URLs disagree, since
    ///   those fields describe a specific artifact and become stale if the URL
    ///   changes.
    pub fn merge_with(&self, other: &PlatformInfo) -> PlatformInfo {
        let url_changed = self.url.is_some() && other.url.is_some() && self.url != other.url;
        let install_changed = self.install.is_some() && self.install != other.install;
        let artifact_changed = url_changed
            || install_changed
            || (self.url.is_some() && other.install.as_deref() == Some("source"));

        // For checksums, prefer sha256 over blake3 since sha256 comes from
        // official releases and is more portable/verifiable. If artifacts disagree,
        // ignore the other side's artifact-bound fields entirely.
        let checksum = if artifact_changed {
            self.checksum.clone()
        } else {
            match (&self.checksum, &other.checksum) {
                (Some(self_cs), Some(other_cs)) => {
                    let self_is_sha256 = self_cs.starts_with("sha256:");
                    let other_is_sha256 = other_cs.starts_with("sha256:");
                    match (self_is_sha256, other_is_sha256) {
                        (true, _) => Some(self_cs.clone()),
                        (false, true) => Some(other_cs.clone()),
                        (false, false) => Some(self_cs.clone()), // both blake3, use self
                    }
                }
                (Some(cs), None) | (None, Some(cs)) => Some(cs.clone()),
                (None, None) => None,
            }
        };

        let size = if artifact_changed {
            self.size
        } else {
            self.size.or(other.size)
        };

        let url_api = if artifact_changed {
            self.url_api.clone()
        } else {
            self.url_api.clone().or_else(|| other.url_api.clone())
        };

        let (provenance, provenance_verified) = if artifact_changed {
            (self.provenance.clone(), self.provenance_verified)
        } else {
            merge_provenance_state(
                self.provenance.clone(),
                self.provenance_verified,
                other.provenance.clone(),
                other.provenance_verified,
            )
        };
        PlatformInfo {
            install: self.install.clone().or_else(|| {
                if self.url.is_some() {
                    None
                } else {
                    other.install.clone()
                }
            }),
            checksum,
            size,
            url: if artifact_changed {
                self.url.clone()
            } else {
                self.url.clone().or_else(|| other.url.clone())
            },
            url_api,
            conda_deps: self.conda_deps.clone().or_else(|| other.conda_deps.clone()),
            pkgx_deps: self.pkgx_deps.clone().or_else(|| other.pkgx_deps.clone()),
            pkgx_provides: self
                .pkgx_provides
                .clone()
                .or_else(|| other.pkgx_provides.clone()),
            pkgx_runtime_env: self
                .pkgx_runtime_env
                .clone()
                .or_else(|| other.pkgx_runtime_env.clone()),
            provenance,
            provenance_verified,
            github_attestations: None,
            additional_artifacts: if artifact_changed {
                self.additional_artifacts.clone()
            } else {
                merge_additional_artifacts(&self.additional_artifacts, &other.additional_artifacts)
            },
        }
    }
}

impl TryFrom<toml::Value> for PlatformInfo {
    type Error = Report;
    fn try_from(value: toml::Value) -> Result<Self> {
        match value {
            toml::Value::String(checksum) => Ok(PlatformInfo {
                checksum: Some(checksum),
                ..Default::default()
            }),
            toml::Value::Table(mut t) => {
                let install = match t.remove("install") {
                    Some(toml::Value::String(s)) if s == "source" => Some(s),
                    Some(toml::Value::String(s)) => {
                        bail!("unrecognized install {s:?} in lockfile")
                    }
                    _ => None,
                };
                let checksum = match t.remove("checksum") {
                    Some(toml::Value::String(s)) => Some(s),
                    _ => None,
                };
                let size = t
                    .remove("size")
                    .and_then(|v| v.as_integer())
                    .map(|i| i.try_into())
                    .transpose()?;
                let url = match t.remove("url") {
                    Some(toml::Value::String(s)) => Some(s),
                    _ => None,
                };
                let url_api = match t.remove("url_api") {
                    Some(toml::Value::String(s)) => Some(s),
                    _ => None,
                };
                let conda_deps = match t.remove("conda_deps") {
                    Some(toml::Value::Array(arr)) => Some(
                        arr.into_iter()
                            .filter_map(|v| v.as_str().map(String::from))
                            .collect(),
                    ),
                    _ => None,
                };
                let github_attestations = match t.remove("github_attestations") {
                    Some(toml::Value::String(s)) if s == "unavailable" => {
                        Some(GithubAttestationsStatus::Unavailable)
                    }
                    Some(toml::Value::String(s)) => {
                        bail!("unrecognized github_attestations status {s:?} in lockfile")
                    }
                    _ => None,
                };
                let pkgx_deps = match t.remove("pkgx_deps") {
                    Some(toml::Value::Array(arr)) => Some(
                        arr.into_iter()
                            .filter_map(|v| v.as_str().map(String::from))
                            .collect(),
                    ),
                    _ => None,
                };
                let pkgx_provides = match t.remove("pkgx_provides") {
                    Some(toml::Value::Array(arr)) => Some(
                        arr.into_iter()
                            .filter_map(|v| v.as_str().map(String::from))
                            .collect(),
                    ),
                    _ => None,
                };
                let pkgx_runtime_env = match t.remove("pkgx_runtime_env") {
                    Some(toml::Value::Table(table)) => {
                        Some(string_table_from_toml_value(toml::Value::Table(table))?)
                    }
                    _ => None,
                };
                // Legacy: read provenance_url for backwards compat with old lockfiles
                let legacy_provenance_url = match t.remove("provenance_url") {
                    Some(toml::Value::String(s)) => Some(s),
                    _ => None,
                };
                let provenance = match t.remove("provenance") {
                    Some(toml::Value::String(s)) => {
                        let mut prov: ProvenanceType = s
                            .parse()
                            .map_err(|_| eyre!("unrecognized provenance type {s:?} in lockfile"))?;
                        // Attach legacy provenance_url to SLSA if present
                        if let ProvenanceType::Slsa { ref mut url } = prov {
                            *url = legacy_provenance_url;
                        }
                        Some(prov)
                    }
                    Some(toml::Value::Table(mut prov_table)) => {
                        if let Some(slsa_val) = prov_table.remove("slsa") {
                            let slsa_url = match slsa_val {
                                toml::Value::Table(mut st) => match st.remove("url") {
                                    Some(toml::Value::String(u)) => Some(u),
                                    _ => None,
                                },
                                _ => None,
                            };
                            Some(ProvenanceType::Slsa { url: slsa_url })
                        } else {
                            // Unknown table variant
                            let keys: Vec<_> = prov_table.keys().cloned().collect();
                            bail!(
                                "unrecognized provenance table format in lockfile: {:?}",
                                keys
                            );
                        }
                    }
                    _ => None,
                };
                let provenance_verified = provenance.is_some()
                    && matches!(
                        t.remove("provenance_verified"),
                        Some(toml::Value::Boolean(true))
                    );
                let github_attestations = if provenance.is_some() {
                    None
                } else {
                    github_attestations
                };
                let additional_artifacts = match t.remove("additional_artifacts") {
                    Some(toml::Value::Array(values)) => values
                        .into_iter()
                        .map(|value| value.try_into().map_err(Report::from))
                        .collect::<Result<Vec<ArtifactInfo>>>()?,
                    Some(_) => bail!("additional_artifacts must be an array in lockfile"),
                    None => Vec::new(),
                };
                Ok(PlatformInfo {
                    install,
                    checksum,
                    size,
                    url,
                    url_api,
                    conda_deps,
                    pkgx_deps,
                    pkgx_provides,
                    pkgx_runtime_env,
                    provenance,
                    provenance_verified,
                    github_attestations,
                    additional_artifacts,
                })
            }
            _ => bail!("unsupported asset info format"),
        }
    }
}

impl From<PlatformInfo> for toml::Value {
    fn from(platform_info: PlatformInfo) -> Self {
        let mut table = toml::Table::new();
        if let Some(install) = platform_info.install {
            table.insert("install".to_string(), install.into());
        }
        if let Some(checksum) = platform_info.checksum {
            table.insert("checksum".to_string(), checksum.into());
        }
        if let Some(url) = platform_info.url {
            table.insert("url".to_string(), url.into());
        }
        if let Some(url_api) = platform_info.url_api {
            table.insert("url_api".to_string(), url_api.into());
        }
        if let Some(conda_deps) = platform_info.conda_deps {
            let deps: toml::Value = conda_deps
                .into_iter()
                .map(toml::Value::String)
                .collect::<Vec<_>>()
                .into();
            table.insert("conda_deps".to_string(), deps);
        }
        if let Some(pkgx_deps) = platform_info.pkgx_deps {
            let deps: toml::Value = pkgx_deps
                .into_iter()
                .map(toml::Value::String)
                .collect::<Vec<_>>()
                .into();
            table.insert("pkgx_deps".to_string(), deps);
        }
        if let Some(pkgx_provides) = platform_info.pkgx_provides {
            let provides: toml::Value = pkgx_provides
                .into_iter()
                .map(toml::Value::String)
                .collect::<Vec<_>>()
                .into();
            table.insert("pkgx_provides".to_string(), provides);
        }
        if let Some(pkgx_runtime_env) = platform_info.pkgx_runtime_env {
            table.insert(
                "pkgx_runtime_env".to_string(),
                toml::Value::Table(toml_table_from_string_map(pkgx_runtime_env)),
            );
        }
        if !platform_info.additional_artifacts.is_empty() {
            let artifacts = platform_info
                .additional_artifacts
                .into_iter()
                .map(|artifact| {
                    toml::Value::try_from(artifact)
                        .expect("ArtifactInfo should always serialize to TOML")
                })
                .collect::<Vec<_>>();
            table.insert(
                "additional_artifacts".to_string(),
                toml::Value::Array(artifacts),
            );
        }
        if let Some(ref provenance) = platform_info.provenance {
            match provenance {
                ProvenanceType::Slsa { url: Some(url) } => {
                    let mut slsa_table = toml::Table::new();
                    slsa_table.insert("url".to_string(), url.clone().into());
                    let mut prov_table = toml::Table::new();
                    prov_table.insert("slsa".to_string(), toml::Value::Table(slsa_table));
                    table.insert("provenance".to_string(), toml::Value::Table(prov_table));
                }
                // Slsa without URL and all other variants serialize as plain string
                _ => {
                    table.insert("provenance".to_string(), provenance.to_string().into());
                }
            }
        }
        if platform_info.provenance_verified {
            table.insert("provenance_verified".to_string(), true.into());
        }
        toml::Value::Table(table)
    }
}

impl TryFrom<toml::Value> for CondaPackageInfo {
    type Error = Report;
    fn try_from(value: toml::Value) -> Result<Self> {
        match value {
            toml::Value::Table(mut t) => {
                let url = t
                    .remove("url")
                    .and_then(|v| match v {
                        toml::Value::String(s) => Some(s),
                        _ => None,
                    })
                    .ok_or_else(|| eyre::eyre!("missing url in conda package info"))?;
                let checksum = match t.remove("checksum") {
                    Some(toml::Value::String(s)) => Some(s),
                    _ => None,
                };
                Ok(CondaPackageInfo { url, checksum })
            }
            _ => bail!("unsupported conda package info format"),
        }
    }
}

impl TryFrom<toml::Value> for PkgxPackageInfo {
    type Error = Report;
    fn try_from(value: toml::Value) -> Result<Self> {
        match value {
            toml::Value::Table(mut t) => {
                let url = t
                    .remove("url")
                    .and_then(|v| match v {
                        toml::Value::String(s) => Some(s),
                        _ => None,
                    })
                    .ok_or_else(|| eyre::eyre!("missing url in pkgx package info"))?;
                let checksum = match t.remove("checksum") {
                    Some(toml::Value::String(s)) => Some(s),
                    _ => None,
                };
                let pkgx_provides = match t.remove("pkgx_provides") {
                    Some(toml::Value::Array(arr)) => Some(
                        arr.into_iter()
                            .filter_map(|v| v.as_str().map(String::from))
                            .collect(),
                    ),
                    _ => None,
                };
                let pkgx_runtime_env = match t.remove("pkgx_runtime_env") {
                    Some(toml::Value::Table(table)) => {
                        Some(string_table_from_toml_value(toml::Value::Table(table))?)
                    }
                    _ => None,
                };
                Ok(PkgxPackageInfo {
                    url,
                    checksum,
                    pkgx_provides,
                    pkgx_runtime_env,
                })
            }
            _ => bail!("unsupported pkgx package info format"),
        }
    }
}

fn string_table_from_toml_value(value: toml::Value) -> Result<BTreeMap<String, String>> {
    match value {
        toml::Value::Table(table) => Ok(table
            .into_iter()
            .filter_map(|(key, value)| match value {
                toml::Value::String(value) => Some((key, value)),
                _ => None,
            })
            .collect()),
        _ => Ok(BTreeMap::new()),
    }
}

fn toml_table_from_string_map(values: BTreeMap<String, String>) -> toml::Table {
    values.into_iter().map(|(k, v)| (k, v.into())).collect()
}

fn existing_lockfile_doc_url(content: &str) -> Option<String> {
    content
        .lines()
        .next()?
        .strip_prefix(LOCKFILE_HEADER_PREFIX)
        .map(str::trim)
        .filter(|url| !url.is_empty())
        .map(str::to_string)
}

fn existing_lockfile_doc_url_from_path(path: &Path) -> Option<String> {
    file::read_to_string(path)
        .ok()
        .and_then(|content| existing_lockfile_doc_url(&content))
}

impl Lockfile {
    pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
        let path = path.as_ref();
        if !path.exists() {
            return Ok(Lockfile::default());
        }
        trace!("reading lockfile {}", path.display_user());
        let content = file::read_to_string(path)?;
        let generated_header_url = existing_lockfile_doc_url(&content);
        let mut table: toml::Table = toml::from_str(&content)?;

        let tools: toml::Table = table
            .remove("tools")
            .unwrap_or(toml::Table::new().into())
            .try_into()?;

        let mut lockfile = Lockfile {
            generated_header_url,
            ..Default::default()
        };

        for (short, value) in tools {
            let versions = match value {
                toml::Value::Array(arr) => arr
                    .into_iter()
                    .map(LockfileTool::try_from)
                    .collect::<Result<Vec<_>>>()?,
                _ => bail!(
                    "invalid lockfile format for tool {short}: expected array ([[tools.{short}]])"
                ),
            };
            lockfile.tools.insert(short, versions);
        }

        // Parse conda-packages section: platform -> basename -> CondaPackageInfo
        if let Some(conda_packages) = table.remove("conda-packages") {
            let platforms: toml::Table = conda_packages.try_into()?;
            for (platform, packages) in platforms {
                let packages_table: toml::Table = packages.try_into()?;
                for (basename, info) in packages_table {
                    let info: CondaPackageInfo = info.try_into()?;
                    lockfile
                        .conda_packages
                        .entry(platform.clone())
                        .or_default()
                        .insert(basename, info);
                }
            }
        }

        // Parse pkgx-packages section: platform -> package@version -> PkgxPackageInfo
        if let Some(pkgx_packages) = table.remove("pkgx-packages") {
            let platforms: toml::Table = pkgx_packages.try_into()?;
            for (platform, packages) in platforms {
                let packages_table: toml::Table = packages.try_into()?;
                for (id, info) in packages_table {
                    let info: PkgxPackageInfo = info.try_into()?;
                    lockfile
                        .pkgx_packages
                        .entry(platform.clone())
                        .or_default()
                        .insert(id, info);
                }
            }
        }

        Ok(lockfile)
    }

    fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
        let path = path.as_ref();
        let mut lockfile = toml::Table::new();

        // Write conda-packages section first (before tools for nicer ordering)
        if !self.conda_packages.is_empty() {
            let mut conda_packages = toml::Table::new();
            for (platform, packages) in &self.conda_packages {
                let mut platform_table = toml::Table::new();
                for (basename, info) in packages {
                    let mut pkg_table = toml::Table::new();
                    pkg_table.insert("url".to_string(), info.url.clone().into());
                    if let Some(checksum) = &info.checksum {
                        pkg_table.insert("checksum".to_string(), checksum.clone().into());
                    }
                    platform_table.insert(basename.clone(), pkg_table.into());
                }
                conda_packages.insert(platform.clone(), platform_table.into());
            }
            lockfile.insert("conda-packages".to_string(), conda_packages.into());
        }

        if !self.pkgx_packages.is_empty() {
            let mut pkgx_packages = toml::Table::new();
            for (platform, packages) in &self.pkgx_packages {
                let mut platform_table = toml::Table::new();
                for (id, info) in packages {
                    let mut pkg_table = toml::Table::new();
                    pkg_table.insert("url".to_string(), info.url.clone().into());
                    if let Some(checksum) = &info.checksum {
                        pkg_table.insert("checksum".to_string(), checksum.clone().into());
                    }
                    if let Some(provides) = &info.pkgx_provides {
                        pkg_table.insert(
                            "pkgx_provides".to_string(),
                            provides
                                .iter()
                                .cloned()
                                .map(toml::Value::String)
                                .collect::<Vec<_>>()
                                .into(),
                        );
                    }
                    if let Some(runtime_env) = &info.pkgx_runtime_env {
                        pkg_table.insert(
                            "pkgx_runtime_env".to_string(),
                            toml::Value::Table(toml_table_from_string_map(runtime_env.clone())),
                        );
                    }
                    platform_table.insert(id.clone(), pkg_table.into());
                }
                pkgx_packages.insert(platform.clone(), platform_table.into());
            }
            lockfile.insert("pkgx-packages".to_string(), pkgx_packages.into());
        }

        // Write tools section
        let mut tools = toml::Table::new();
        for (short, versions) in &self.tools {
            // Always write Multi-Version format (array format) for consistency
            let value: toml::Value = versions
                .iter()
                .cloned()
                .map(|version| version.into_toml_value())
                .collect::<Vec<toml::Value>>()
                .into();
            tools.insert(short.clone(), value);
        }
        lockfile.insert("tools".to_string(), tools.into());

        let content = toml::to_string_pretty(&toml::Value::Table(lockfile))?;
        let content = format(content.parse()?);
        let doc_url = self
            .generated_header_url
            .clone()
            .or_else(|| existing_lockfile_doc_url_from_path(path))
            .unwrap_or_else(|| DEFAULT_LOCKFILE_DOC_URL.to_string());
        let content = format!("{LOCKFILE_HEADER_PREFIX}{doc_url}\n\n{content}");

        // Resolve the symlink target first, before writing the temp file
        let target = if path.is_symlink() {
            // If the existing lockfile is a symlink, resolve it and update the target instead
            // of replacing the symlink
            trace!(
                "lockfile {} is a symlink, updating target instead of replacing",
                display_path(path)
            );

            match fs::canonicalize(path) {
                Ok(link_target) => {
                    trace!(
                        "resolved lockfile symlink {} to {}",
                        display_path(path),
                        display_path(&link_target)
                    );
                    link_target
                }
                Err(e) => {
                    // Dangling symlink – fall back to overwriting the symlink itself
                    // TODO: Maybe instead of overwriting, we should create the new lockfile at
                    // the symlink's target path?
                    warn!(
                        "lockfile {} is a dangling symlink ({}), overwriting the symlink itself",
                        display_path(path),
                        e
                    );
                    path.to_path_buf()
                }
            }
        } else {
            path.to_path_buf()
        };
        // Use atomic write: write to a uniquely-named temp file, then persist.
        // - Prevents partial writes from corrupting the lockfile.
        // - Unique temp name prevents races when multiple mise processes update
        //   the same lockfile concurrently (e.g. parallel linters each triggering
        //   `install_missing_bin`). A fixed `mise.lock.tmp` collided here and the
        //   loser of the rename race got ENOENT.
        // Write alongside the real target so the rename stays on the same filesystem.
        let parent = target
            .parent()
            .ok_or_else(|| eyre!("lockfile path has no parent: {}", display_path(&target)))?;
        let mut tmp = tempfile::NamedTempFile::with_prefix_in(".mise.lock.", parent)?;
        tmp.as_file_mut().write_all(content.as_bytes())?;
        apply_lockfile_permissions(&tmp, &target)?;
        persist_lockfile_tmp(tmp, &target)?;

        invalidate_caches();
        Ok(())
    }

    /// Add or update a conda package in the shared section
    /// `basename` is without extension, e.g., "ncurses-6.4-h7ea286d_0"
    pub fn set_conda_package(&mut self, platform: &str, basename: &str, info: CondaPackageInfo) {
        self.conda_packages
            .entry(platform.to_string())
            .or_default()
            .insert(basename.to_string(), info);
    }

    /// Get a conda package from the shared section by basename
    pub fn get_conda_package(&self, platform: &str, basename: &str) -> Option<&CondaPackageInfo> {
        self.conda_packages.get(platform)?.get(basename)
    }

    pub fn set_pkgx_package(&mut self, platform: &str, id: &str, info: PkgxPackageInfo) {
        self.pkgx_packages
            .entry(platform.to_string())
            .or_default()
            .insert(id.to_string(), info);
    }

    pub fn get_pkgx_package(&self, platform: &str, id: &str) -> Option<&PkgxPackageInfo> {
        self.pkgx_packages.get(platform)?.get(id)
    }

    /// Remove unreferenced conda packages from the shared section.
    /// A package is unreferenced if no tool's conda_deps references it.
    fn cleanup_unreferenced_conda_packages(&mut self) {
        // Collect all referenced conda packages (platform -> set of basenames)
        let mut referenced: HashMap<String, HashSet<String>> = HashMap::new();
        for tools in self.tools.values() {
            for tool in tools {
                for (platform, info) in &tool.platforms {
                    if let Some(deps) = &info.conda_deps {
                        for dep in deps {
                            referenced
                                .entry(platform.clone())
                                .or_default()
                                .insert(dep.clone());
                        }
                    }
                }
            }
        }

        // Remove unreferenced packages
        for (platform, packages) in &mut self.conda_packages {
            let referenced_for_platform = referenced.get(platform);
            packages.retain(|basename, _| {
                referenced_for_platform
                    .map(|refs| refs.contains(basename))
                    .unwrap_or(false)
            });
        }

        // Remove empty platform entries
        self.conda_packages
            .retain(|_, packages| !packages.is_empty());
    }

    fn cleanup_unreferenced_pkgx_packages(&mut self) {
        let mut referenced: HashMap<String, HashSet<String>> = HashMap::new();
        for tools in self.tools.values() {
            for tool in tools {
                for (platform, info) in &tool.platforms {
                    if let Some(deps) = &info.pkgx_deps {
                        for dep in deps {
                            referenced
                                .entry(platform.clone())
                                .or_default()
                                .insert(dep.clone());
                        }
                    }
                }
            }
        }

        for (platform, packages) in &mut self.pkgx_packages {
            let referenced_for_platform = referenced.get(platform);
            packages.retain(|id, _| {
                referenced_for_platform
                    .map(|refs| refs.contains(id))
                    .unwrap_or(false)
            });
        }

        self.pkgx_packages
            .retain(|_, packages| !packages.is_empty());
    }

    /// Get all platform keys present in the lockfile
    pub fn all_platform_keys(&self) -> BTreeSet<String> {
        let mut platforms = BTreeSet::new();
        for tools in self.tools.values() {
            for tool in tools {
                for platform_key in tool.platforms.keys() {
                    platforms.insert(platform_key.clone());
                }
            }
        }
        platforms
    }

    pub fn tools(&self) -> &BTreeMap<String, Vec<LockfileTool>> {
        &self.tools
    }

    /// Keep only tools matching configured short names or backend identifiers.
    /// Also removes conda packages that become unreferenced.
    pub fn retain_tools_by_short_or_backend(
        &mut self,
        keep_shorts: &BTreeSet<String>,
        keep_backends: &BTreeSet<String>,
    ) {
        self.tools.retain(|short, versions| {
            Self::should_keep_tool(short, versions, keep_shorts, keep_backends)
        });
        self.cleanup_unreferenced_conda_packages();
        self.cleanup_unreferenced_pkgx_packages();
    }

    /// Remove entries for a tool whose version is not in the given set.
    /// Used to prune stale version entries during filtered `mise lock <tool>` runs.
    pub fn retain_tool_versions(&mut self, short: &str, keep_versions: &BTreeSet<String>) {
        if let Some(tools) = self.tools.get_mut(short) {
            tools.retain(|t| keep_versions.contains(&t.version));
            if tools.is_empty() {
                self.tools.remove(short);
            }
        }
        self.cleanup_unreferenced_conda_packages();
        self.cleanup_unreferenced_pkgx_packages();
    }

    /// Return versions of a tool that would be removed by `retain_tool_versions`.
    pub fn stale_tool_versions(
        &self,
        short: &str,
        keep_versions: &BTreeSet<String>,
    ) -> Vec<String> {
        self.tools
            .get(short)
            .map(|tools| {
                tools
                    .iter()
                    .filter(|t| !keep_versions.contains(&t.version))
                    .map(|t| t.version.clone())
                    .collect()
            })
            .unwrap_or_default()
    }

    /// Return tool keys that would be removed by `retain_tools_by_short_or_backend`.
    pub fn stale_tool_shorts(
        &self,
        keep_shorts: &BTreeSet<String>,
        keep_backends: &BTreeSet<String>,
    ) -> BTreeSet<String> {
        self.tools
            .iter()
            .filter_map(|(short, versions)| {
                (!Self::should_keep_tool(short, versions, keep_shorts, keep_backends))
                    .then_some(short.clone())
            })
            .collect()
    }

    fn should_keep_tool(
        short: &str,
        versions: &[LockfileTool],
        keep_shorts: &BTreeSet<String>,
        keep_backends: &BTreeSet<String>,
    ) -> bool {
        keep_shorts.contains(short)
            || versions
                .iter()
                .filter_map(|v| v.backend.as_ref())
                .any(|backend| keep_backends.contains(backend))
    }

    /// Update or add platform info for a tool version
    /// Merges with existing info, preserving fields we don't have new values for
    pub fn set_platform_info(
        &mut self,
        short: &str,
        version: &str,
        backend: Option<&str>,
        options: &BTreeMap<String, String>,
        platform_key: &str,
        platform_info: PlatformInfo,
    ) {
        let tools = self.tools.entry(short.to_string()).or_default();
        // Platform option migration is handled while merging freshly resolved
        // entries. Writes here always target the exact resolved option variant.
        let idx = tools
            .iter()
            .position(|t| t.version == version && &t.options == options);
        if let Some(idx) = idx {
            let tool = &mut tools[idx];
            // Merge with existing platform info, preferring new values when present.
            // When the URL changes, drop existing checksum/size/url_api — those fields
            // describe a specific artifact and are stale once the URL points elsewhere.
            let merged = if let Some(existing) = tool.platforms.get(platform_key) {
                let url_changed = platform_info.url.is_some()
                    && existing.url.is_some()
                    && platform_info.url != existing.url;
                let install_changed =
                    platform_info.install.is_some() && platform_info.install != existing.install;
                let source_to_url = platform_info.url.is_some()
                    && platform_info.install.is_none()
                    && existing.install.as_deref() == Some("source");
                let preserve_artifact_fields = !url_changed && !install_changed && !source_to_url;
                let (provenance, provenance_verified) = if preserve_artifact_fields {
                    merge_provenance_state(
                        platform_info.provenance.clone(),
                        platform_info.provenance_verified,
                        existing.provenance.clone(),
                        existing.provenance_verified,
                    )
                } else {
                    (
                        platform_info.provenance.clone(),
                        platform_info.provenance_verified,
                    )
                };
                PlatformInfo {
                    install: platform_info.install.or_else(|| {
                        if platform_info.url.is_some() {
                            None
                        } else {
                            existing.install.clone()
                        }
                    }),
                    checksum: platform_info.checksum.or_else(|| {
                        if preserve_artifact_fields {
                            existing.checksum.clone()
                        } else {
                            None
                        }
                    }),
                    size: platform_info.size.or(if preserve_artifact_fields {
                        existing.size
                    } else {
                        None
                    }),
                    url: platform_info.url.or_else(|| {
                        if preserve_artifact_fields {
                            existing.url.clone()
                        } else {
                            None
                        }
                    }),
                    url_api: platform_info.url_api.or_else(|| {
                        if preserve_artifact_fields {
                            existing.url_api.clone()
                        } else {
                            None
                        }
                    }),
                    // For dependency lists, always use the new value - None means "no dependencies"
                    // rather than "not computed", so we shouldn't preserve stale deps
                    conda_deps: platform_info.conda_deps,
                    pkgx_deps: platform_info.pkgx_deps,
                    pkgx_provides: platform_info.pkgx_provides,
                    pkgx_runtime_env: platform_info.pkgx_runtime_env,
                    provenance,
                    provenance_verified,
                    github_attestations: None,
                    additional_artifacts: if preserve_artifact_fields {
                        merge_additional_artifacts(
                            &platform_info.additional_artifacts,
                            &existing.additional_artifacts,
                        )
                    } else {
                        platform_info.additional_artifacts
                    },
                }
            } else {
                platform_info
            };
            // Only insert non-empty platform info to avoid `"platforms.linux-x64" = {}`
            if !merged.is_empty() {
                tool.platforms.insert(platform_key.to_string(), merged);
            }
        } else {
            let mut platforms = BTreeMap::new();
            // Only insert non-empty platform info
            if !platform_info.is_empty() {
                platforms.insert(platform_key.to_string(), platform_info);
            }
            tools.push(LockfileTool {
                version: version.to_string(),
                backend: backend.map(|s| s.to_string()),
                options: options.clone(),
                platforms,
            });
        }

        // Do not remove this platform from sibling option variants. A monorepo
        // root lockfile can legitimately contain the same version and platform
        // for multiple requests with different options. Legacy migration is
        // handled by the consensus-based merge pass, where all contributing
        // requests are available.
    }

    /// Save the lockfile to disk (public for mise lock command)
    pub fn write<P: AsRef<Path>>(&self, path: P) -> Result<()> {
        self.save(path)
    }
}

/// Determines the lockfile path for a given config file path
/// Returns (lockfile_path, is_local)
///
/// Lockfiles are placed alongside their config files:
/// - `mise.toml` -> `mise.lock`
/// - `.config/mise.toml` -> `.config/mise.lock`
/// - `.mise/config.toml` -> `.mise/mise.lock`
/// - `.mise/conf.d/foo.toml` -> `.mise/mise.lock` (conf.d files share parent's lockfile)
pub fn lockfile_path_for_config(
    config_path: &Path,
    monorepo_root: Option<&Path>,
) -> (PathBuf, bool) {
    let is_local = is_local_config(config_path);
    let env = extract_env_from_config_path(config_path);
    let lockfile_name = match (&env, is_local) {
        (Some(e), true) => format!("mise.{e}.local.lock"),
        (Some(e), false) => format!("mise.{e}.lock"),
        (None, true) => "mise.local.lock".to_string(),
        (None, false) => "mise.lock".to_string(),
    };

    let parent = config_path.parent().unwrap_or(Path::new("."));
    let parent_name = parent
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or_default();

    // For conf.d files, place lockfile at parent of conf.d so all conf.d files share one lockfile
    let lockfile_dir = if parent_name == "conf.d" {
        parent.parent().unwrap_or(parent)
    } else {
        parent
    };

    let lockfile_dir = if let Some(root) = monorepo_root
        && config_path.starts_with(root)
        && lockfile_dir != root
    {
        root
    } else {
        lockfile_dir
    };

    (lockfile_dir.join(lockfile_name), is_local)
}

/// Determines the lockfile path for a tool source.
///
/// Idiomatic version files are not config files themselves, so their lock entries
/// belong to the nearest active mise config root that contains the version file.
/// If multiple base configs share that root, the later entry in config_files wins
/// so colocated configs have a deterministic lockfile target, such as
/// .mise/config.toml mapping .dummy-version to .mise/mise.lock instead of mise.lock.
pub fn lockfile_path_for_tool_source(
    config: &Config,
    source: &ToolSource,
) -> Option<(PathBuf, bool)> {
    let monorepo_root = config.monorepo_lockfile_root();
    lockfile_path_for_tool_source_with_root(config, source, monorepo_root.as_deref())
}

fn lockfile_path_for_tool_source_with_root(
    config: &Config,
    source: &ToolSource,
    monorepo_root: Option<&Path>,
) -> Option<(PathBuf, bool)> {
    match source {
        ToolSource::MiseToml(path) => Some(lockfile_path_for_config(path, monorepo_root)),
        ToolSource::IdiomaticVersionFile(path) => config
            .config_files
            .iter()
            .enumerate()
            .filter(|(_, (_, cf))| cf.source().is_mise_toml())
            .filter_map(|(idx, (config_path, cf))| {
                let root = cf.project_root().unwrap_or_else(|| cf.config_root());
                let is_base = !is_local_config(config_path)
                    && extract_env_from_config_path(config_path).is_none();
                path.starts_with(&root).then(|| {
                    (
                        root.components().count(),
                        is_base,
                        // Tie-break same-root base configs by config_files order.
                        idx,
                        lockfile_path_for_config(config_path, monorepo_root),
                    )
                })
            })
            .max_by_key(|(root_depth, is_base, idx, _)| (*root_depth, *is_base, *idx))
            .map(|(_, _, _, lockfile)| lockfile),
        _ => None,
    }
}

/// Checks if a config path is a "local" config (should go to mise.local.lock)
fn is_local_config(path: &Path) -> bool {
    let filename = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or_default();
    filename.contains(".local.")
}

/// Extracts environment name from config filename
/// e.g., "mise.test.toml" -> Some("test"), "mise.test.local.toml" -> Some("test"), "mise.toml" -> None
pub fn extract_env_from_config_path(path: &Path) -> Option<String> {
    let filename = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or_default();

    // Pattern matches:
    // - mise.{env}.toml -> captures env
    // - mise.{env}.local.toml -> captures env (env-specific local config)
    // - .mise.{env}.toml -> captures env
    // - config.{env}.toml -> captures env
    // Does NOT match (returns None):
    // - mise.toml, .mise.toml, config.toml (base configs)
    // - mise.local.toml (local without env - filtered by "local" check)
    let re = regex!(r"^(?:\.?mise|config)\.([^.]+)(?:\.local)?\.toml$");
    re.captures(filename)
        .and_then(|caps| caps.get(1))
        .map(|m| m.as_str().to_string())
        .filter(|s| s != "local")
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockfileUpdateMode {
    Normal,
    AllowLocked,
}

impl LockfileUpdateMode {
    fn allow_locked(self) -> bool {
        matches!(self, Self::AllowLocked)
    }
}

pub fn migrate_monorepo_lockfiles(config: &Config) -> Result<()> {
    if !Settings::get().lockfile_enabled() {
        return Ok(());
    }
    let Some(monorepo_root) = config.monorepo_lockfile_root() else {
        return Ok(());
    };
    let mut migrated = 0usize;

    for source in monorepo_legacy_lockfile_paths(config) {
        if !source.exists() {
            continue;
        }
        let Some(filename) = source.file_name() else {
            continue;
        };
        let target = monorepo_root.join(filename);
        if source == target {
            continue;
        }
        let _lock = crate::lock_file::LockFile::new(&target)
            .with_callback(|l| debug!("waiting for lock on {}", display_path(l)))
            .lock()?;
        let mut root_lockfile =
            Lockfile::read(&target).unwrap_or_else(|err| handle_lockfile_read_error(err, &target));
        let subproject_lockfile = match Lockfile::read(&source) {
            Ok(lockfile) => lockfile,
            Err(err) if is_not_found_report(&err) => continue,
            Err(err) => return Err(err),
        };
        merge_lockfile_preserving_root(&mut root_lockfile, subproject_lockfile);
        root_lockfile.save(&target)?;
        if let Err(err) = fs::remove_file(&source)
            && err.kind() != ErrorKind::NotFound
        {
            return Err(err.into());
        }
        migrated += 1;
    }

    if migrated > 0 {
        miseprintln!(
            "migrated {migrated} subproject lockfile(s) into {} (monorepo uses a single root lockfile)",
            display_path(&monorepo_root)
        );
    }

    Ok(())
}

fn is_not_found_report(err: &Report) -> bool {
    err.downcast_ref::<std::io::Error>()
        .is_some_and(|err| err.kind() == ErrorKind::NotFound)
}

fn monorepo_legacy_lockfile_paths(config: &Config) -> IndexSet<PathBuf> {
    let Some(cache_key) = config.monorepo_lockfile_discovery_key() else {
        return IndexSet::new();
    };
    cached_legacy_lockfile_paths(cache_key, || {
        discover_monorepo_legacy_lockfile_paths(config)
    })
}

fn cached_legacy_lockfile_paths(
    cache_key: LegacyLockfilePathsCacheKey,
    discover: impl FnOnce() -> IndexSet<PathBuf>,
) -> IndexSet<PathBuf> {
    let mut cache = LEGACY_LOCKFILE_PATHS_CACHE
        .lock()
        .unwrap_or_else(|err| err.into_inner());
    if let Some(paths) = cache.get(&cache_key) {
        return paths.clone();
    }
    let paths = discover();
    cache.insert(cache_key, paths.clone());
    paths
}

fn discover_monorepo_legacy_lockfile_paths(config: &Config) -> IndexSet<PathBuf> {
    let Some(monorepo_root) = config.monorepo_lockfile_root() else {
        return IndexSet::new();
    };
    let config_roots = match config.monorepo_config_root_dirs_for_lockfiles() {
        Ok(config_roots) => config_roots,
        Err(err) => {
            warn_once!(
                "[monorepo] lockfile = true is set, but [monorepo].config_roots could not be resolved: {err:#}; using root lockfiles without migration"
            );
            return IndexSet::new();
        }
    };
    let mut source_lockfiles = IndexSet::new();

    for config_root in config_roots {
        if config_root == monorepo_root {
            continue;
        }
        for source in lockfile_variant_paths_in_dir(&config_root) {
            if source.parent() != Some(monorepo_root.as_path()) {
                source_lockfiles.insert(source);
            }
        }
        for config_path in crate::config::config_paths_in_dir(&config_root) {
            if let Some(source) = legacy_lockfile_path_for_config(&config_path, &monorepo_root) {
                source_lockfiles.insert(source);
            }
        }
    }

    source_lockfiles
}

pub(crate) fn lockfile_variant_paths_in_dir(dir: &Path) -> Vec<PathBuf> {
    let mut paths = IndexSet::new();

    for path in crate::config::glob(dir, "mise*.lock").unwrap_or_default() {
        paths.insert(path);
    }
    for env_name in env::MISE_ENV.iter().chain(env::AUTO_ENV_NAMES.iter()) {
        paths.insert(dir.join(format!("mise.{env_name}.local.lock")));
        paths.insert(dir.join(format!("mise.{env_name}.lock")));
    }
    paths.insert(dir.join("mise.local.lock"));
    paths.insert(dir.join("mise.lock"));

    paths.into_iter().collect()
}

fn legacy_lockfile_path_for_config(config_path: &Path, monorepo_root: &Path) -> Option<PathBuf> {
    if !config_path.starts_with(monorepo_root) {
        return None;
    }
    let (source, _) = lockfile_path_for_config(config_path, None);
    (source.parent() != Some(monorepo_root)).then_some(source)
}

fn merge_legacy_lockfile_if_present(lockfile: &mut Lockfile, legacy_path: &Path) -> Result<()> {
    match Lockfile::read(legacy_path) {
        Ok(legacy) => {
            merge_lockfile_preserving_root(lockfile, legacy);
            Ok(())
        }
        Err(err) if is_not_found_report(&err) => Ok(()),
        Err(err) => Err(err),
    }
}

fn merge_lockfile_preserving_root(root: &mut Lockfile, other: Lockfile) {
    for (short, tools) in other.tools {
        let root_tools = root.tools.entry(short).or_default();
        let mut keys: HashSet<(String, BTreeMap<String, String>)> = root_tools
            .iter()
            .map(|tool| (tool.version.clone(), tool.options.clone()))
            .collect();
        for tool in tools {
            let key = (tool.version.clone(), tool.options.clone());
            if keys.insert(key) {
                root_tools.push(tool);
            }
        }
    }

    for (platform, packages) in other.conda_packages {
        let root_packages = root.conda_packages.entry(platform).or_default();
        for (basename, info) in packages {
            root_packages.entry(basename).or_insert(info);
        }
    }

    for (platform, packages) in other.pkgx_packages {
        let root_packages = root.pkgx_packages.entry(platform).or_default();
        for (id, info) in packages {
            root_packages.entry(id).or_insert(info);
        }
    }
}

pub fn update_lockfiles(
    config: &Config,
    ts: &Toolset,
    new_versions: &[ToolVersion],
    mode: LockfileUpdateMode,
) -> Result<bool> {
    if !Settings::get().lockfile_enabled() || (Settings::get().locked && !mode.allow_locked()) {
        return Ok(false);
    }

    // Collect tools by source (config file)
    let mut tools_by_source: HashMap<ToolSource, HashMap<String, Vec<ToolVersion>>> =
        HashMap::new();
    for (ba, tvl) in &ts.versions {
        for tv in &tvl.versions {
            tools_by_source
                .entry(tv.request.source().clone())
                .or_default()
                .entry(ba.short.to_string())
                .or_default()
                .push(tv.clone());
        }
    }

    // Add versions added within this session (from `mise use` or `mise up`)
    for (backend, group) in &new_versions.iter().chunk_by(|tv| tv.ba()) {
        let tvs = group.cloned().collect_vec();
        let source = tvs[0].request.source().clone();
        let source_tools = tools_by_source.entry(source.clone()).or_default();

        let existing_versions = source_tools.entry(backend.short.to_string()).or_default();
        for new_tv in tvs {
            existing_versions.retain(|tv| tv.request.version() != new_tv.request.version());
            existing_versions.push(new_tv);
        }
    }

    // Group config files by target lockfile path
    let mut lockfile_configs: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
    for (config_path, cf) in config.config_files.iter().rev() {
        if !cf.source().is_mise_toml() {
            continue;
        }
        let (lockfile_path, _is_local) =
            lockfile_path_for_config(config_path, config.monorepo_lockfile_root().as_deref());
        lockfile_configs
            .entry(lockfile_path)
            .or_default()
            .push(config_path.clone());
    }
    for source in tools_by_source.keys() {
        if let Some((lockfile_path, _)) = lockfile_path_for_tool_source(config, source) {
            lockfile_configs.entry(lockfile_path).or_default();
        }
    }

    debug!("updating {} lockfiles", lockfile_configs.len());

    // Process each lockfile, deferring provenance errors until all lockfiles are saved.
    let mut provenance_errors: Vec<String> = Vec::new();
    let mut has_deferred_provenance = false;

    for (lockfile_path, configs) in lockfile_configs {
        // When lockfiles are enabled implicitly, only maintain files the user has
        // already created. An explicit `lockfile = true` opts into project
        // lockfile creation too; global lockfiles remain explicit via the
        // `mise lock --global` command.
        if !lockfile_path.exists() {
            let source_maps_to_lockfile = |source: &ToolSource| {
                lockfile_path_for_tool_source(config, source)
                    .is_some_and(|(path, _)| path == lockfile_path)
            };
            let updates_project_lockfiles = new_versions.is_empty()
                || new_versions.iter().any(|tv| {
                    tv.request
                        .source()
                        .path()
                        .is_some_and(|path| !crate::config::is_global_config(path))
                });
            let has_tool_contribution = updates_project_lockfiles
                && tools_by_source
                    .iter()
                    .any(|(source, tools)| !tools.is_empty() && source_maps_to_lockfile(source));
            let create_project_lockfile = config.lockfile_creation_enabled()
                && configs
                    .iter()
                    .any(|path| !crate::config::is_global_config(path))
                && has_tool_contribution;
            if !create_project_lockfile {
                continue;
            }
        }
        let is_monorepo_root_lockfile = config
            .monorepo_lockfile_root()
            .is_some_and(|root| lockfile_path.parent() == Some(root.as_path()));
        let _lock = crate::lock_file::LockFile::new(&lockfile_path)
            .with_callback(|l| debug!("waiting for lock on {}", display_path(l)))
            .lock()?;

        let mut existing_lockfile = Lockfile::read(&lockfile_path)
            .unwrap_or_else(|err| handle_lockfile_read_error(err, &lockfile_path));

        // Collect all tools from all contributing configs. This starts from the
        // resolved toolset, then overlays newly installed versions because a
        // fuzzy request may still resolve through the old lockfile entry until
        // this update is written.
        let mut tool_versions_by_short: HashMap<String, Vec<ToolVersion>> = HashMap::new();
        let mut contributing_sources = 0;

        for (source, tools) in &tools_by_source {
            let Some((source_lockfile, _)) = lockfile_path_for_tool_source(config, source) else {
                continue;
            };
            if source_lockfile == lockfile_path {
                contributing_sources += 1;
                for (short, versions) in tools {
                    tool_versions_by_short
                        .entry(short.clone())
                        .or_default()
                        .extend(versions.clone());
                }
            }
        }

        trace!(
            "updating lockfile {} from {} source(s)",
            display_path(&lockfile_path),
            contributing_sources
        );

        for new_version in new_versions {
            if let Some((source_lockfile, _)) =
                lockfile_path_for_tool_source(config, new_version.request.source())
            {
                if source_lockfile != lockfile_path {
                    continue;
                }

                let versions = tool_versions_by_short
                    .entry(new_version.short().to_string())
                    .or_default();
                versions.retain(|tv| {
                    tv.ba() != new_version.ba()
                        || tv.request.version() != new_version.request.version()
                        || tv.request.source() != new_version.request.source()
                });
                versions.push(new_version.clone());
            } else if let Some(versions) = tool_versions_by_short.get_mut(new_version.short()) {
                if let Some((idx, request)) = versions
                    .iter()
                    .enumerate()
                    .exactly_one()
                    .ok()
                    .map(|(idx, tv)| (idx, tv.request.clone()))
                {
                    // Only propagate when the new install resolves the same
                    // version specifier as the config. `mise x node` (no
                    // version) is rewritten by `with_default_to_latest` to
                    // carry the config's version string, so it matches and
                    // updates the lockfile as expected. `mise x node@latest`
                    // with mise.toml `node = "24"` carries a "latest"
                    // specifier that doesn't match — that's an ad-hoc CLI
                    // override and pairing the "24" request with a 25.x
                    // install would produce a nonsensical lockfile entry.
                    if new_version.request.version() != request.version() {
                        trace!(
                            "skipping lockfile update for {}@{} in {}: CLI override does not match config request {}",
                            new_version.short(),
                            new_version.version,
                            display_path(&lockfile_path),
                            request.version(),
                        );
                        continue;
                    }
                    let mut new_version = new_version.clone();
                    new_version.request = request;
                    versions.remove(idx);
                    versions.push(new_version);
                } else {
                    trace!(
                        "skipping lockfile update for {}@{} in {}: ambiguous config entries",
                        new_version.short(),
                        new_version.version,
                        display_path(&lockfile_path)
                    );
                }
            }
        }

        let tools_by_short: HashMap<String, Vec<LockfileTool>> = tool_versions_by_short
            .iter()
            .map(|(short, versions)| {
                Ok((
                    short.clone(),
                    versions
                        .iter()
                        .map(lockfile_tool_from_tool_version)
                        .collect::<Result<Vec<_>>>()?,
                ))
            })
            .collect::<Result<_>>()?;

        // Check for provenance regression before merging (which drops old version entries).
        // For github backend tools, error if the highest prior version had provenance but
        // the new version does not — this could indicate a supply chain attack.
        // Confirmed regressions are excluded from merge to preserve the old provenance entry.
        // Unverified upgrades (already-installed versions) instead preserve the prior
        // provenance entry as a baseline for the later auto-lock pass (see #11225).
        let ProvenanceRegressionCheck {
            regressing: regressing_tools,
            errors: regression_errors,
            deferred_baselines,
        } = check_provenance_regression(&existing_lockfile, &tools_by_short);
        has_deferred_provenance |= !deferred_baselines.is_empty();
        provenance_errors.extend(regression_errors);

        // Process each tool with deduplication, skipping regressing tools
        for (short, entries) in tools_by_short {
            if regressing_tools.contains(&short) {
                continue;
            }
            let versions = &tool_versions_by_short[&short];
            // Normal monorepo installs load only the current config hierarchy,
            // not every sibling project. Consensus among those visible requests
            // is therefore not authoritative for the shared root lockfile. Fail
            // closed there and preserve legacy entries; ordinary lockfiles can
            // safely migrate when all requests for the file agree.
            let rekey_decisions = if is_monorepo_root_lockfile {
                RekeyDecisions::new()
            } else {
                build_rekey_decisions(versions, existing_lockfile.tools.get(&short))
            };
            let (mut merged_tools, consumed_keys) = merge_tool_entries(
                entries,
                existing_lockfile.tools.get(&short),
                |version, platform| {
                    rekey_decisions
                        .get(&(version.to_string(), platform.to_key()))
                        .cloned()
                        .flatten()
                },
            );
            if is_monorepo_root_lockfile {
                preserve_absent_tool_entries(
                    &mut merged_tools,
                    existing_lockfile.tools.get(&short),
                    &consumed_keys,
                );
            }
            // Keep the prior provenance-bearing entry alive for unverified upgrades so
            // the auto-lock pass can verify the new version and compare against it. The
            // merge above drops superseded versions; without this the baseline would be
            // gone before auto-lock runs, letting a genuine regression slip through. The
            // stale baseline self-heals on the next update once the new version is locked.
            reinsert_deferred_baselines(&mut merged_tools, &deferred_baselines, &short);
            existing_lockfile.tools.insert(short, merged_tools);
        }

        // Merge conda packages from new_versions into the lockfile
        for tv in new_versions {
            for ((platform, basename), pkg_info) in &tv.conda_packages {
                existing_lockfile.set_conda_package(platform, basename, pkg_info.clone());
            }
            for ((platform, id), pkg_info) in &tv.pkgx_packages {
                existing_lockfile.set_pkgx_package(platform, id, pkg_info.clone());
            }
        }

        // Clean up any conda packages that are no longer referenced by any tool
        existing_lockfile.cleanup_unreferenced_conda_packages();
        existing_lockfile.cleanup_unreferenced_pkgx_packages();

        existing_lockfile.save(&lockfile_path)?;
    }

    // Return all provenance errors after all lockfiles have been saved
    if !provenance_errors.is_empty() {
        return Err(eyre!("{}", provenance_errors.join("\n")));
    }

    Ok(has_deferred_provenance)
}

/// Find the prior lockfile entry a version would regress against by losing provenance.
///
/// Returns the highest prior github semver version that has verified provenance on
/// this platform when `version` is a semver upgrade over it and carries no provenance
/// itself. Returns `None` when there is no such baseline (not a github tool, the new
/// version already has provenance, no comparable prior version, or the new version is
/// not an upgrade). Shared by the regression check and error formatting so detection
/// logic lives in one place.
fn find_provenance_regression_baseline<'a>(
    existing_tools: Option<&'a [LockfileTool]>,
    version: &str,
    backend: &str,
    platform_key: &str,
    new_provenance: Option<&ProvenanceType>,
) -> Option<&'a LockfileTool> {
    if new_provenance.is_some() || !backend.starts_with("github:") {
        return None;
    }

    let tools = existing_tools?;
    let new_version = parse_provenance_version(version)?;

    // Find the highest prior github semver version with provenance on this platform.
    // Non-semver tags are opaque to mise, so do not guess whether they are upgrades.
    let (prior_version, prior) = tools
        .iter()
        .filter(|t| t.version != version)
        .filter(|t| t.backend.as_deref() == Some(backend))
        .filter(|t| {
            t.platforms
                .get(platform_key)
                .is_some_and(|pi| pi.provenance.is_some())
        })
        .filter_map(|t| Some((parse_provenance_version(&t.version)?, t)))
        .max_by(|(a, _), (b, _)| a.cmp(b))?;

    // Only flag upgrades — intentional downgrades are allowed
    if new_version <= prior_version {
        return None;
    }

    Some(prior)
}

/// Format the supply-chain regression error for a version that lost provenance
/// relative to `baseline`.
fn provenance_regression_message(
    short: &str,
    version: &str,
    platform_key: &str,
    baseline: &LockfileTool,
) -> String {
    let prov = baseline.platforms[platform_key]
        .provenance
        .as_ref()
        .expect("baseline entry must have provenance on this platform");
    format!(
        "{short}@{version} has no provenance verification on {platform_key}, \
         but {short}@{} had {prov}. This could indicate a supply chain \
         attack. Verify the release is authentic before proceeding.",
        baseline.version,
    )
}

/// Check if a single tool version is losing provenance relative to prior lockfile entries.
///
/// Returns an error message if a github backend tool upgrade loses provenance,
/// or None if the check passes. Used by `apply_lock_result` (the authoritative
/// lock-time pass that has already downloaded and verified the artifact).
fn check_single_tool_provenance(
    existing_tools: Option<&[LockfileTool]>,
    short: &str,
    version: &str,
    backend: &str,
    platform_key: &str,
    new_provenance: Option<&ProvenanceType>,
) -> Option<String> {
    let baseline = find_provenance_regression_baseline(
        existing_tools,
        version,
        backend,
        platform_key,
        new_provenance,
    )?;
    Some(provenance_regression_message(
        short,
        version,
        platform_key,
        baseline,
    ))
}

fn parse_provenance_version(version: &str) -> Option<nodejs_semver::Version> {
    nodejs_semver::Version::parse(strip_leading_v(version)).ok()
}

/// Outcome of the pre-merge provenance regression check.
#[derive(Default)]
struct ProvenanceRegressionCheck {
    /// Tools with a confirmed regression: exclude from merge to preserve the old
    /// provenance-verified entry, and fail the command with `errors`.
    regressing: HashSet<String>,
    /// Error messages for confirmed regressions.
    errors: Vec<String>,
    /// Prior provenance-bearing entries to preserve through the merge (keyed by
    /// tool short) for upgrades that could not be verified in this pass. Keeping
    /// the baseline lets the authoritative `auto_lock_new_versions` pass — which
    /// downloads and verifies the artifact — compare against it, since the merge
    /// otherwise drops superseded versions before auto-lock runs.
    deferred_baselines: HashMap<String, Vec<LockfileTool>>,
}

fn reinsert_deferred_baselines(
    merged_tools: &mut Vec<LockfileTool>,
    deferred_baselines: &HashMap<String, Vec<LockfileTool>>,
    short: &str,
) {
    let Some(baselines) = deferred_baselines.get(short) else {
        return;
    };
    for baseline in baselines {
        if !merged_tools
            .iter()
            .any(|tool| tool.version == baseline.version && tool.options == baseline.options)
        {
            merged_tools.push(baseline.clone());
        }
    }
}

/// Check if any github backend tool is losing provenance when upgrading versions.
///
/// Only checks the current platform because new `LockfileTool` entries (from
/// `ToolVersionList`) only have `lock_platforms` populated for the platform where
/// installation ran. Checking other platforms would produce false positives.
///
/// A regression is only *confirmed* when the new version was actually downloaded and
/// resolved this session — signalled by a `url` on the current platform. When a user
/// "upgrades" to a version that is *already installed* on disk, mise skips the download
/// entirely, so no provenance is populated and the platform entry has no url. Its
/// missing provenance means "not checked", not "verified absent" — flagging it as a
/// regression is a false positive (#11225). For those, the prior provenance-bearing
/// entry is instead preserved as a baseline so the authoritative `auto_lock_new_versions`
/// pass can download, verify, and compare against it.
fn check_provenance_regression(
    existing_lockfile: &Lockfile,
    new_tools: &HashMap<String, Vec<LockfileTool>>,
) -> ProvenanceRegressionCheck {
    let current_platform = Platform::current().to_key();
    let mut result = ProvenanceRegressionCheck::default();

    for (short, new_entries) in new_tools {
        for new_entry in new_entries {
            let backend = new_entry.backend.as_deref().unwrap_or("");
            let platform_info = new_entry.platforms.get(&current_platform);
            let new_provenance = platform_info.and_then(|pi| pi.provenance.as_ref());

            let Some(baseline) = find_provenance_regression_baseline(
                existing_lockfile.tools.get(short).map(Vec::as_slice),
                &new_entry.version,
                backend,
                &current_platform,
                new_provenance,
            ) else {
                continue;
            };

            if platform_info.and_then(|pi| pi.url.as_ref()).is_some() {
                // Freshly downloaded and verified: the new version genuinely lacks
                // provenance the prior version had — a confirmed regression.
                result.regressing.insert(short.clone());
                result.errors.push(provenance_regression_message(
                    short,
                    &new_entry.version,
                    &current_platform,
                    baseline,
                ));
            } else {
                // Not downloaded this session (already installed on disk): unverified.
                // Preserve the baseline so auto-lock can make the authoritative call.
                result
                    .deferred_baselines
                    .entry(short.clone())
                    .or_default()
                    .push(baseline.clone());
            }
        }
    }
    result
}

/// Snapshot the platform keys present in each lockfile that the upcoming install run
/// will touch. Must be called BEFORE `update_lockfiles` writes any current-platform
/// entries — the snapshot is what lets `auto_lock_new_versions` tell a user-curated
/// lockfile (existing entries are authoritative) apart from a fresh one whose only
/// current-platform entry was just added by this install.
pub fn snapshot_pre_install_platforms(
    config: &Config,
    ts: &Toolset,
    new_versions: &[ToolVersion],
) -> HashMap<PathBuf, BTreeSet<String>> {
    let mut result: HashMap<PathBuf, BTreeSet<String>> = HashMap::new();
    for tv in ts
        .versions
        .values()
        .flat_map(|tvl| tvl.versions.iter())
        .chain(new_versions.iter())
    {
        let Some((lockfile_path, _)) = lockfile_path_for_tool_source(config, tv.request.source())
        else {
            continue;
        };
        if result.contains_key(&lockfile_path) {
            continue;
        }
        let keys = if lockfile_path.exists() {
            Lockfile::read(&lockfile_path)
                .map(|lf| lf.all_platform_keys())
                .unwrap_or_default()
        } else {
            BTreeSet::new()
        };
        result.insert(lockfile_path, keys);
    }
    result
}

fn tool_version_matches_lockfile_target<F>(
    lockfile: &Lockfile,
    tv: &ToolVersion,
    target_platforms: &[Platform],
    predicate: F,
) -> Result<bool>
where
    F: Fn(&[LockfileTool], &str, &str, &BTreeMap<String, String>) -> bool,
{
    let Some(tools) = lockfile.tools.get(tv.short()) else {
        return Ok(false);
    };
    // The toolset passed to auto-locking can still contain the version that was
    // active before an upgrade. If that version has already been pruned from the
    // lockfile, it is stale and must not be resolved back into the file.
    if !tools.iter().any(|tool| tool.version == tv.version) {
        return Ok(false);
    }
    let backend = tv.request.backend()?;

    for platform in target_platforms {
        for variant in backend.platform_variants(platform) {
            let target = PlatformTarget::new(variant.clone());
            let options = backend.resolve_lockfile_options(&tv.request, &target)?;
            let platform_key = variant.to_key();
            if predicate(tools, &tv.version, &platform_key, &options) {
                return Ok(true);
            }
        }
    }

    Ok(false)
}

fn tool_version_needs_auto_lock(
    lockfile: &Lockfile,
    tv: &ToolVersion,
    target_platforms: &[Platform],
) -> Result<bool> {
    tool_version_matches_lockfile_target(
        lockfile,
        tv,
        target_platforms,
        lockfile_target_needs_auto_lock,
    )
}

fn lockfile_target_needs_auto_lock(
    tools: &[LockfileTool],
    version: &str,
    platform_key: &str,
    options: &BTreeMap<String, String>,
) -> bool {
    let Some(tool) = tools
        .iter()
        .find(|tool| tool.version == version && &tool.options == options)
    else {
        return true;
    };
    let Some(info) = tool.platforms.get(platform_key) else {
        return true;
    };
    if info.checksum.is_none() || info.url.is_none() {
        return true;
    }

    // A complete-looking same-version row can still be an unresolved deferred
    // provenance upgrade. Force resolution while a verified prior baseline exists.
    find_provenance_regression_baseline(
        Some(tools),
        version,
        tool.backend.as_deref().unwrap_or(""),
        platform_key,
        info.provenance.as_ref(),
    )
    .is_some()
}

fn lockfile_target_has_deferred_provenance(
    tools: &[LockfileTool],
    version: &str,
    platform_key: &str,
    options: &BTreeMap<String, String>,
) -> bool {
    let Some(tool) = tools
        .iter()
        .find(|tool| tool.version == version && &tool.options == options)
    else {
        return false;
    };
    let provenance = tool
        .platforms
        .get(platform_key)
        .and_then(|info| info.provenance.as_ref());
    find_provenance_regression_baseline(
        Some(tools),
        version,
        tool.backend.as_deref().unwrap_or(""),
        platform_key,
        provenance,
    )
    .is_some()
}

fn tool_version_needs_deferred_provenance_retry(
    lockfile: &Lockfile,
    tv: &ToolVersion,
    target_platforms: &[Platform],
) -> Result<bool> {
    tool_version_matches_lockfile_target(
        lockfile,
        tv,
        target_platforms,
        lockfile_target_has_deferred_provenance,
    )
}

/// Determine target platforms for auto-lock from the pre-install lockfile snapshot.
///
/// If the lockfile had platform entries before this install run, those are authoritative
/// (plus the current platform). Otherwise — the lockfile was fresh — fall back to the
/// common platform set so a brand-new lockfile gets cross-platform-populated. The
/// `lockfile_platforms` setting overrides both paths.
fn determine_target_platforms_from_lockfile(
    pre_install_keys: &BTreeSet<String>,
) -> Result<Vec<Platform>> {
    if let Some(configured) = Settings::get().lockfile_platforms()? {
        let mut platforms: BTreeSet<Platform> = configured.into_iter().collect();
        platforms.insert(Platform::current());
        return Ok(platforms.into_iter().collect());
    }

    if !pre_install_keys.is_empty() {
        let mut platforms: BTreeSet<Platform> = BTreeSet::new();
        for platform_key in pre_install_keys {
            if let Ok(p) = Platform::parse(platform_key)
                && p.validate().is_ok()
            {
                platforms.insert(p);
            }
        }
        if !platforms.is_empty() {
            platforms.insert(Platform::current());
            return Ok(platforms.into_iter().collect());
        }
    }

    // Fresh lockfile (or no parseable keys) — populate common defaults + current.
    let mut platforms: BTreeSet<Platform> = Platform::common_platforms().into_iter().collect();
    platforms.insert(Platform::current());
    Ok(platforms.into_iter().collect())
}

/// Determine target platforms from an existing lockfile for explicit `mise lock` calls.
/// If the lockfile already has platform entries, only those are targeted.
/// Otherwise, falls back to all common platforms + current platform.
pub fn determine_existing_platforms(lockfile_path: &Path) -> Result<Vec<Platform>> {
    // If lockfile_platforms setting is configured, use it as the authoritative set
    if let Some(configured) = Settings::get().lockfile_platforms()? {
        let mut platforms: BTreeSet<Platform> = configured.into_iter().collect();
        platforms.insert(Platform::current());
        return Ok(platforms.into_iter().collect());
    }

    if let Ok(lockfile) = Lockfile::read(lockfile_path) {
        let existing_keys = lockfile.all_platform_keys();
        if !existing_keys.is_empty() {
            let mut platforms: BTreeSet<Platform> = BTreeSet::new();
            for platform_key in existing_keys {
                if let Ok(p) = Platform::parse(&platform_key)
                    && p.validate().is_ok()
                {
                    platforms.insert(p);
                }
            }
            if !platforms.is_empty() {
                return Ok(platforms.into_iter().collect());
            }
        }
    }
    // No lockfile, no platforms yet, or no valid platform keys — use common defaults
    let mut platforms: BTreeSet<Platform> = Platform::common_platforms().into_iter().collect();
    platforms.insert(Platform::current());
    Ok(platforms.into_iter().collect())
}

/// After installing new tool versions, resolve checksums/URLs for all common platforms
/// so the lockfile is complete and doesn't change when other developers on different
/// platforms run `mise install`. When no versions were installed, retry only deferred
/// provenance verification left incomplete by an earlier auto-lock failure.
pub async fn auto_lock_new_versions(
    config: &Config,
    ts: &Toolset,
    new_versions: &[ToolVersion],
    pre_install_platforms: &HashMap<PathBuf, BTreeSet<String>>,
    mode: LockfileUpdateMode,
) -> Result<()> {
    if !Settings::get().lockfile_enabled() || (Settings::get().locked && !mode.allow_locked()) {
        return Ok(());
    }

    let settings = Settings::get();
    let jobs = crate::jobs::normalize(settings.jobs);
    let deferred_retry_only = new_versions.is_empty();
    let mut all_provenance_errors: Vec<String> = Vec::new();

    let empty_keys: BTreeSet<String> = BTreeSet::new();
    let mut candidate_versions_by_lockfile: HashMap<PathBuf, Vec<ToolVersion>> = HashMap::new();
    let mut seen_candidates: HashMap<PathBuf, HashSet<(String, String, String, ToolSource)>> =
        HashMap::new();
    for tv in ts
        .versions
        .values()
        .flat_map(|tvl| tvl.versions.iter())
        .chain(new_versions.iter())
    {
        let Ok(backend) = tv.request.backend() else {
            continue;
        };
        if !backend.supports_lockfile_url() {
            continue;
        }
        if let Some((lockfile_path, _)) = lockfile_path_for_tool_source(config, tv.request.source())
        {
            let candidate_key = (
                tv.ba().short.clone(),
                tv.version.clone(),
                tv.request.version().to_string(),
                tv.request.source().clone(),
            );
            if seen_candidates
                .entry(lockfile_path.clone())
                .or_default()
                .insert(candidate_key)
            {
                candidate_versions_by_lockfile
                    .entry(lockfile_path)
                    .or_default()
                    .push(tv.clone());
            }
        }
    }

    for (lockfile_path, versions) in candidate_versions_by_lockfile {
        // Only update existing lockfiles (consistent with update_lockfiles)
        if !lockfile_path.exists() {
            continue;
        }

        let _lock = crate::lock_file::LockFile::new(&lockfile_path)
            .with_callback(|l| debug!("waiting for lock on {}", display_path(l)))
            .lock()?;
        let mut lockfile = Lockfile::read(&lockfile_path)
            .unwrap_or_else(|err| handle_lockfile_read_error(err, &lockfile_path));

        let existing_keys;
        let pre_install_keys = if deferred_retry_only {
            existing_keys = lockfile.all_platform_keys();
            &existing_keys
        } else {
            pre_install_platforms
                .get(&lockfile_path)
                .unwrap_or(&empty_keys)
        };
        let target_platforms = determine_target_platforms_from_lockfile(pre_install_keys)?;

        let versions: Vec<_> = versions
            .into_iter()
            .filter(|tv| {
                let needs_auto_lock = if deferred_retry_only {
                    tool_version_needs_deferred_provenance_retry(&lockfile, tv, &target_platforms)
                } else {
                    tool_version_needs_auto_lock(&lockfile, tv, &target_platforms)
                };
                needs_auto_lock.unwrap_or_else(|err| {
                    debug!(
                        "auto-lock candidate check failed for {}@{} in {}: {}",
                        tv.short(),
                        tv.version,
                        display_path(&lockfile_path),
                        err
                    );
                    false
                })
            })
            .collect();
        if versions.is_empty() {
            continue;
        }

        let semaphore = Arc::new(Semaphore::new(jobs));
        let mut jset: JoinSet<LockResolutionResult> = JoinSet::new();

        for tv in &versions {
            let ba = tv.ba().clone();
            let backend = crate::backend::get(&ba);

            for platform in &target_platforms {
                // Expand platform variants from the backend
                let variants = if let Some(ref backend) = backend {
                    backend.platform_variants(platform)
                } else {
                    vec![platform.clone()]
                };

                for variant in variants {
                    let platform_key = variant.to_key();
                    let target = PlatformTarget::new(variant.clone());

                    // Skip complete targets unless a prior provenance-bearing version
                    // means this target still needs authoritative verification.
                    if let Some(ref backend) = backend
                        && let Ok(options) = backend.resolve_lockfile_options(&tv.request, &target)
                        && let Some(tools) = lockfile.tools.get(&ba.short)
                        && !lockfile_target_needs_auto_lock(
                            tools,
                            &tv.version,
                            &platform_key,
                            &options,
                        )
                    {
                        continue;
                    }

                    let semaphore = semaphore.clone();
                    let ba = ba.clone();
                    let tv = (*tv).clone();
                    let backend = backend.clone();

                    jset.spawn(async move {
                        let _permit = semaphore.acquire().await;
                        resolve_tool_lock_info(ba, tv, variant, backend).await
                    });
                }
            }
        }

        // Collect results and update lockfile
        // Defer provenance errors until after saving so unaffected tools' entries aren't lost.
        let mut provenance_errors: Vec<String> = Vec::new();
        while let Some(result) = jset.join_next().await {
            match result {
                Ok(resolution) => {
                    if let Err(msg) = &resolution.4 {
                        let (short, version, backend, platform) =
                            (&resolution.0, &resolution.1, &resolution.2, &resolution.3);
                        let platform_key = platform.to_key();
                        // Auto-lock is best-effort, so resolution failures are normally
                        // just logged. But if this url-less version is a deferred
                        // provenance upgrade — a prior provenance-bearing version is
                        // still present as a baseline — a failure here means the
                        // deferred supply-chain check could not be completed. Fail
                        // closed rather than silently locking the upgrade without
                        // provenance (#11225); it self-heals on a later successful run.
                        if let Some(err) = deferred_provenance_resolution_error(
                            lockfile.tools.get(short).map(Vec::as_slice),
                            short,
                            version,
                            backend,
                            &platform_key,
                            msg,
                        ) {
                            provenance_errors.push(err);
                        } else {
                            debug!("auto-lock: {msg}");
                        }
                    }
                    if let Err(e) = apply_lock_result(&mut lockfile, resolution) {
                        provenance_errors.push(e.to_string());
                    }
                }
                Err(e) => {
                    debug!("auto-lock task failed: {}", e);
                }
            }
        }

        lockfile.save(&lockfile_path)?;

        all_provenance_errors.extend(provenance_errors);
    }

    if !all_provenance_errors.is_empty() {
        return Err(eyre!("{}", all_provenance_errors.join("\n")));
    }

    Ok(())
}

fn deferred_provenance_resolution_error(
    existing_tools: Option<&[LockfileTool]>,
    short: &str,
    version: &str,
    backend: &str,
    platform_key: &str,
    resolution_error: &str,
) -> Option<String> {
    find_provenance_regression_baseline(existing_tools, version, backend, platform_key, None)?;
    Some(format!(
        "could not verify provenance for {short}@{version} on \
         {platform_key}: {resolution_error}\nA prior version had provenance, so \
         mise refuses to lock this upgrade without verifying it. \
         Retry once the release source is reachable."
    ))
}

/// Result type for lock resolution tasks (shared by `mise lock` and auto-lock).
///
/// Fields: (short_name, version, backend_full, platform, info_or_error, options,
/// conda_packages, pkgx_packages, error_is_fatal).
/// The `info_or_error` field is `Ok(info)` on success or `Err(message)` on failure,
/// allowing callers to log at the appropriate level. `error_is_fatal` distinguishes
/// genuine conda solve failures from backends that use errors to skip unsupported targets.
pub type LockResolutionResult = (
    String,
    String,
    String,
    Platform,
    Result<PlatformInfo, String>,
    BTreeMap<String, String>,
    BTreeMap<String, CondaPackageInfo>,
    BTreeMap<String, PkgxPackageInfo>,
    bool,
);

/// Resolve lock info for a single tool/platform combination.
///
/// Returns a tuple of (short_name, version, backend_full, platform, info_or_error, options,
/// conda_packages, pkgx_packages, error_is_fatal).
/// Does not log errors — callers decide the appropriate log level.
pub async fn resolve_tool_lock_info(
    ba: crate::cli::args::BackendArg,
    tv: ToolVersion,
    platform: Platform,
    backend: Option<crate::backend::ABackend>,
) -> LockResolutionResult {
    let target = PlatformTarget::new(platform.clone());
    let error_is_fatal = backend
        .as_ref()
        .is_some_and(|backend| backend.get_type() == BackendType::Conda);

    let (info, options, conda_packages, pkgx_packages) = if let Some(backend) = backend {
        let options = match backend.resolve_lockfile_options(&tv.request, &target) {
            Ok(options) => options,
            Err(e) => {
                return (
                    ba.short.clone(),
                    tv.version.clone(),
                    ba.stored_full(),
                    platform,
                    Err(e.to_string()),
                    BTreeMap::new(),
                    BTreeMap::new(),
                    BTreeMap::new(),
                    error_is_fatal,
                );
            }
        };
        match backend.resolve_lock_info(&tv, &target).await {
            Ok(info) => {
                let conda_packages = if backend.get_type() == BackendType::Conda {
                    let conda_backend = CondaBackend::from_arg(ba.clone());
                    match conda_backend.resolve_conda_packages(&tv, &target).await {
                        Ok(packages) => packages,
                        Err(e) => {
                            return (
                                ba.short.clone(),
                                tv.version.clone(),
                                ba.stored_full(),
                                platform,
                                Err(format!(
                                    "failed to resolve conda packages for {} on {}: {}",
                                    ba.short,
                                    target.to_key(),
                                    e
                                )),
                                options,
                                BTreeMap::new(),
                                BTreeMap::new(),
                                error_is_fatal,
                            );
                        }
                    }
                } else {
                    BTreeMap::new()
                };
                let pkgx_packages = if backend.get_type() == BackendType::Pkgx {
                    let pkgx_backend = PkgxBackend::from_arg(ba.clone());
                    match pkgx_backend.resolve_pkgx_packages(&tv, &target).await {
                        Ok(packages) => packages,
                        Err(e) => {
                            return (
                                ba.short.clone(),
                                tv.version.clone(),
                                ba.stored_full(),
                                platform,
                                Err(format!(
                                    "failed to resolve pkgx packages for {} on {}: {}",
                                    ba.short,
                                    target.to_key(),
                                    e
                                )),
                                options,
                                BTreeMap::new(),
                                BTreeMap::new(),
                                error_is_fatal,
                            );
                        }
                    }
                } else {
                    BTreeMap::new()
                };
                (Ok(info), options, conda_packages, pkgx_packages)
            }
            Err(e) => (
                Err(format!(
                    "failed to resolve {} for {}: {}",
                    ba.short,
                    platform.to_key(),
                    e
                )),
                options,
                BTreeMap::new(),
                BTreeMap::new(),
            ),
        }
    } else {
        (
            Err(format!("backend not found for {}", ba.short)),
            BTreeMap::new(),
            BTreeMap::new(),
            BTreeMap::new(),
        )
    };

    (
        ba.short.clone(),
        tv.version.clone(),
        ba.full(),
        platform,
        info,
        options,
        conda_packages,
        pkgx_packages,
        error_is_fatal,
    )
}

/// Apply a lock resolution result to a lockfile, updating platform info and conda packages.
/// Only applies data when the resolution succeeded (info is `Ok`).
///
/// Returns whether the resolution contributed any data. A backend that can't
/// resolve metadata without installing falls back to an empty `PlatformInfo`.
/// This still creates a tool/version entry when one does not exist, but it does
/// not overwrite an existing entry's platform metadata or count as a resolved
/// platform.
///
/// Returns an error if a github backend tool loses provenance on version upgrade,
/// which could indicate a supply chain attack.
pub fn apply_lock_result(lockfile: &mut Lockfile, result: LockResolutionResult) -> Result<bool> {
    let (
        short,
        version,
        backend,
        platform,
        info,
        options,
        conda_packages,
        pkgx_packages,
        _error_is_fatal,
    ) = result;
    let platform_key = platform.to_key();
    let mut applied = false;
    if let Ok(ref info) = info {
        if let Some(err) = check_single_tool_provenance(
            lockfile.tools.get(&short).map(Vec::as_slice),
            &short,
            &version,
            &backend,
            &platform_key,
            info.provenance.as_ref(),
        ) {
            return Err(eyre!("{err}"));
        }
        if info.is_empty() {
            let tool_exists = lockfile.tools.get(&short).is_some_and(|tools| {
                tools
                    .iter()
                    .any(|tool| tool.version == version && tool.options == options)
            });
            if !tool_exists {
                lockfile.set_platform_info(
                    &short,
                    &version,
                    Some(&backend),
                    &options,
                    &platform_key,
                    info.clone(),
                );
            }
        } else {
            applied = true;
            lockfile.set_platform_info(
                &short,
                &version,
                Some(&backend),
                &options,
                &platform_key,
                info.clone(),
            );
        }
    }
    for (basename, pkg_info) in conda_packages {
        applied = true;
        lockfile.set_conda_package(&platform_key, &basename, pkg_info);
    }
    for (id, pkg_info) in pkgx_packages {
        applied = true;
        lockfile.set_pkgx_package(&platform_key, &id, pkg_info);
    }
    Ok(applied)
}

type RekeyDecisions = HashMap<(String, String), Option<BTreeMap<String, String>>>;

fn record_rekey_decision(
    decisions: &mut RekeyDecisions,
    key: (String, String),
    resolved: Option<BTreeMap<String, String>>,
) {
    use std::collections::hash_map::Entry;

    match decisions.entry(key) {
        Entry::Vacant(entry) => {
            entry.insert(resolved);
        }
        Entry::Occupied(mut entry) if entry.get() != &resolved => {
            // Resolution errors and disagreement between same-version requests
            // both fail closed: leave the legacy platform where it is.
            entry.insert(None);
        }
        Entry::Occupied(_) => {}
    }
}

fn build_rekey_decisions(
    versions: &[ToolVersion],
    existing_tools: Option<&Vec<LockfileTool>>,
) -> RekeyDecisions {
    let legacy_platforms: HashMap<&str, BTreeSet<&str>> = existing_tools
        .into_iter()
        .flatten()
        .filter(|tool| tool.options.is_empty())
        .fold(HashMap::new(), |mut platforms, tool| {
            platforms
                .entry(tool.version.as_str())
                .or_default()
                .extend(tool.platforms.keys().map(String::as_str));
            platforms
        });
    let mut decisions = HashMap::new();

    for tv in versions {
        let Some(platforms) = legacy_platforms.get(tv.version.as_str()) else {
            continue;
        };
        for platform_key in platforms {
            let resolved = Platform::parse(platform_key).ok().and_then(|platform| {
                let backend = tv.request.backend().ok()?;
                if backend.lockfile_options_are_host_specific() {
                    return None;
                }
                backend
                    .resolve_lockfile_options(&tv.request, &PlatformTarget::new(platform))
                    .ok()
            });
            record_rekey_decision(
                &mut decisions,
                (tv.version.clone(), (*platform_key).to_string()),
                resolved,
            );
        }
    }

    decisions
}

/// Merge tool entries with deduplication by (version, options).
/// Merges platform info for entries with the same key.
/// Preserves existing platform info for matching entries.
/// Existing empty-options entries are rekeyed platform-by-platform using the
/// backend's resolved options. This migrates legacy entries without guessing
/// from platform overlap while preserving data when resolution is ambiguous.
fn merge_tool_entries<F>(
    entries: Vec<LockfileTool>,
    existing_tools: Option<&Vec<LockfileTool>>,
    mut resolve: F,
) -> MergeToolEntriesResult
where
    F: FnMut(&str, &Platform) -> Option<BTreeMap<String, String>>,
{
    // Group by (version, options) - the key for deduplication
    let mut by_key: HashMap<(String, BTreeMap<String, String>), LockfileTool> = HashMap::new();
    let mut consumed_keys = HashSet::new();

    for tool in entries {
        let key = (tool.version.clone(), tool.options.clone());
        let entry = by_key.entry(key).or_insert_with(|| tool.clone());

        // Merge platforms - properly combine platform info to preserve URLs and prefer sha256
        for (platform, info) in tool.platforms {
            entry
                .platforms
                .entry(platform)
                .and_modify(|existing| *existing = info.merge_with(existing))
                .or_insert(info);
        }
    }
    let fresh_versions: HashSet<String> =
        by_key.keys().map(|(version, _)| version.clone()).collect();

    // Merge with existing tools to preserve platform info
    if let Some(existing) = existing_tools {
        for existing_tool in existing {
            let key = (existing_tool.version.clone(), existing_tool.options.clone());
            if !existing_tool.options.is_empty() {
                if let Some(entry) = by_key.get_mut(&key) {
                    // Preserve platform data only for an exact non-empty option
                    // match. Moving it to another real variant could attach a
                    // stale checksum to a different artifact.
                    for (platform, info) in &existing_tool.platforms {
                        entry
                            .platforms
                            .entry(platform.clone())
                            .and_modify(|existing| *existing = existing.merge_with(info))
                            .or_insert(info.clone());
                    }
                }
                continue;
            }
            if !fresh_versions.contains(&existing_tool.version) {
                continue;
            }

            for (platform_key, info) in &existing_tool.platforms {
                let resolved_options = Platform::parse(platform_key)
                    .ok()
                    .and_then(|platform| resolve(&existing_tool.version, &platform));
                let Some(resolved_options) = resolved_options else {
                    by_key
                        .entry(key.clone())
                        .or_insert_with(|| LockfileTool {
                            version: existing_tool.version.clone(),
                            backend: existing_tool.backend.clone(),
                            options: existing_tool.options.clone(),
                            platforms: BTreeMap::new(),
                        })
                        .platforms
                        .insert(platform_key.clone(), info.clone());
                    continue;
                };

                let target_key = (existing_tool.version.clone(), resolved_options.clone());
                let target = by_key.entry(target_key).or_insert_with(|| LockfileTool {
                    version: existing_tool.version.clone(),
                    backend: existing_tool.backend.clone(),
                    options: resolved_options,
                    platforms: BTreeMap::new(),
                });
                target
                    .platforms
                    .entry(platform_key.clone())
                    .and_modify(|fresh| *fresh = fresh.merge_with(info))
                    .or_insert_with(|| info.clone());
            }
            if !by_key.contains_key(&key) {
                consumed_keys.insert(key);
            }
        }
    }

    let tools = by_key
        .into_values()
        .sorted_by(|a, b| {
            a.version
                .cmp(&b.version)
                .then_with(|| a.options.cmp(&b.options))
        })
        .collect();
    (tools, consumed_keys)
}

fn preserve_absent_tool_entries(
    merged_tools: &mut Vec<LockfileTool>,
    existing_tools: Option<&Vec<LockfileTool>>,
    consumed_keys: &HashSet<LockfileToolKey>,
) {
    let Some(existing_tools) = existing_tools else {
        return;
    };
    let mut keys: HashSet<(String, BTreeMap<String, String>)> = merged_tools
        .iter()
        .map(|tool| (tool.version.clone(), tool.options.clone()))
        .collect();

    for existing_tool in existing_tools {
        let key = (existing_tool.version.clone(), existing_tool.options.clone());
        if keys.contains(&key) || consumed_keys.contains(&key) {
            continue;
        }
        if keys.insert(key) {
            merged_tools.push(existing_tool.clone());
        }
    }
    merged_tools.sort_by(|a, b| {
        a.version
            .cmp(&b.version)
            .then_with(|| a.options.cmp(&b.options))
    });
}

fn read_all_lockfiles(config: &Config) -> Arc<Lockfile> {
    // Create a cache key from the config file paths
    let monorepo_root = config.monorepo_lockfile_root();
    let legacy_lockfiles = monorepo_legacy_lockfile_paths(config);
    let cache_key: Vec<PathBuf> = config
        .config_files
        .keys()
        .map(|path| lockfile_path_for_config(path, monorepo_root.as_deref()).0)
        .chain(legacy_lockfiles.iter().cloned())
        .unique()
        .collect();

    // Use unwrap_or_else to recover from poisoned mutex (thread panicked while holding lock)
    let mut cache = ALL_LOCKFILES_CACHE
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    if let Some(cached) = cache.get(&cache_key) {
        return Arc::clone(cached);
    }

    let mut seen_roots: HashSet<PathBuf> = HashSet::new();
    let mut all: Vec<Lockfile> = Vec::new();

    for (path, cf) in config.config_files.iter().rev() {
        if !cf.source().is_mise_toml() {
            continue;
        }

        let (lockfile_path, _) = lockfile_path_for_config(path, monorepo_root.as_deref());
        let root = lockfile_path.parent().unwrap_or(path).to_path_buf();
        if seen_roots.contains(&root) {
            continue;
        }
        seen_roots.insert(root.clone());

        // Read lockfiles in priority order (highest first):
        // 1. mise.<env>.local.lock (explicit MISE_ENV, then auto platform envs)
        // 2. mise.local.lock
        // 3. mise.<env>.lock (explicit MISE_ENV, then auto platform envs)
        // 4. mise.lock
        // AUTO_ENV_NAMES is ordered least to most specific, so reverse it here
        // to read the most specific (e.g. macos-arm64) first.
        for env_name in env::MISE_ENV.iter().chain(env::AUTO_ENV_NAMES.iter().rev()) {
            let p = root.join(format!("mise.{env_name}.local.lock"));
            if let Ok(l) = Lockfile::read(&p) {
                all.push(l);
            }
        }
        let local_path = root.join("mise.local.lock");
        if let Ok(local) = Lockfile::read(&local_path) {
            all.push(local);
        }
        for env_name in env::MISE_ENV.iter().chain(env::AUTO_ENV_NAMES.iter().rev()) {
            let p = root.join(format!("mise.{env_name}.lock"));
            if let Ok(l) = Lockfile::read(&p) {
                all.push(l);
            }
        }
        let main_path = root.join("mise.lock");
        if let Ok(main) = Lockfile::read(&main_path) {
            all.push(main);
        }
    }
    for legacy_path in legacy_lockfiles {
        if let Ok(legacy) = Lockfile::read(&legacy_path) {
            all.push(legacy);
        }
    }

    let result = all.into_iter().fold(Lockfile::default(), |mut acc, l| {
        for (short, tools) in l.tools {
            let existing = acc.tools.entry(short).or_default();
            for tool in tools {
                // Avoid duplicates (same version+options)
                if !existing
                    .iter()
                    .any(|t| t.version == tool.version && t.options == tool.options)
                {
                    existing.push(tool);
                }
            }
        }
        acc
    });

    let result = Arc::new(result);
    cache.insert(cache_key, Arc::clone(&result));
    result
}

fn read_lockfile_for(config: &Config, path: &Path) -> Arc<Lockfile> {
    let monorepo_root = config.monorepo_lockfile_root();
    let (lockfile_path, _is_local) = lockfile_path_for_config(path, monorepo_root.as_deref());
    let legacy_path = monorepo_root
        .as_deref()
        .and_then(|root| legacy_lockfile_path_for_config(path, root));

    read_lockfile_at(lockfile_path, legacy_path)
}

fn read_lockfile_at(lockfile_path: PathBuf, legacy_path: Option<PathBuf>) -> Arc<Lockfile> {
    let cache_key: Vec<PathBuf> = std::iter::once(lockfile_path.clone())
        .chain(legacy_path.iter().cloned())
        .collect();

    // Use unwrap_or_else to recover from poisoned mutex (thread panicked while holding lock)
    let mut cache = SINGLE_LOCKFILE_CACHE
        .lock()
        .unwrap_or_else(|e| e.into_inner());
    if let Some(cached) = cache.get(&cache_key) {
        return Arc::clone(cached);
    }

    let mut lockfile = Lockfile::read(&lockfile_path)
        .unwrap_or_else(|err| handle_lockfile_read_error(err, &lockfile_path));
    if let Some(legacy_path) = &legacy_path
        && let Err(err) = merge_legacy_lockfile_if_present(&mut lockfile, legacy_path)
    {
        warn!(
            "failed to read legacy monorepo lockfile {} (possible corruption): {err:?}",
            display_path(legacy_path)
        );
    }

    let lockfile = Arc::new(lockfile);
    cache.insert(cache_key, Arc::clone(&lockfile));
    lockfile
}

pub(crate) fn read_lockfile_for_config_path(config: &Config, path: &Path) -> Lockfile {
    read_lockfile_for(config, path).as_ref().clone()
}

pub(crate) fn read_lockfile_for_tool_source(
    config: &Config,
    source: &ToolSource,
) -> Result<Lockfile> {
    if let ToolSource::MiseToml(path) = source {
        return Ok(read_lockfile_for_config_path(config, path));
    }

    let monorepo_root = config.monorepo_lockfile_root();
    let Some((lockfile_path, _)) =
        lockfile_path_for_tool_source_with_root(config, source, monorepo_root.as_deref())
    else {
        return Ok(Lockfile::default());
    };
    let legacy_path = monorepo_root
        .as_deref()
        .and_then(|_| lockfile_path_for_tool_source_with_root(config, source, None))
        .map(|(path, _)| path)
        .filter(|path| path != &lockfile_path);

    Ok(read_lockfile_at(lockfile_path, legacy_path)
        .as_ref()
        .clone())
}

/// `legacy_options_fallback` lets a backend whose options describe the writing
/// host (see `Backend::lockfile_options_are_host_specific`) fall back to an
/// entry written before those options existed. The version pin is honored — it
/// is the only record of what this project resolved to — but the artifact data
/// is dropped, since there's no way to tell which variant it describes.
pub fn get_locked_version(
    config: &Config,
    path: Option<&Path>,
    short: &str,
    prefix: &str,
    require_prefix_boundary: bool,
    request_options: &BTreeMap<String, String>,
    legacy_options_fallback: bool,
) -> Result<Option<LockfileTool>> {
    let settings = Settings::get();
    if !settings.lockfile_enabled() {
        return Ok(None);
    }

    let lockfile = match path {
        Some(path) => {
            trace!(
                "[{short}@{prefix}] reading lockfile for {}",
                display_path(path)
            );
            read_lockfile_for(config, path)
        }
        None => {
            trace!("[{short}@{prefix}] reading all lockfiles");
            read_all_lockfiles(config)
        }
    };

    if let Some(tools) = lockfile.tools.get(short) {
        let version_matches = |v: &LockfileTool| {
            lockfile_version_matches(prefix, &v.version)
                && (!require_prefix_boundary
                    || lockfile_version_matches_prefix_boundary(prefix, &v.version))
        };
        // Filter by version prefix and options
        let mut matching: Vec<_> = tools
            .iter()
            .filter(|v| version_matches(v) && &v.options == request_options)
            .collect();

        // Only sort when prefix is "latest" and we have multiple matches
        // This is expensive, so avoid it for specific version prefixes
        if prefix == "latest" && matching.len() > 1 {
            matching.sort_by(|a, b| {
                versions::Versioning::new(&b.version).cmp(&versions::Versioning::new(&a.version))
            });
        }

        if let Some(found) = matching.first() {
            trace!("[{short}@{prefix}] found {} in lockfile", found.version);
            return Ok(Some((*found).clone()));
        }

        if legacy_options_fallback && !request_options.is_empty() {
            let mut legacy: Vec<_> = tools
                .iter()
                .filter(|v| version_matches(v) && v.options.is_empty())
                .collect();
            if prefix == "latest" && legacy.len() > 1 {
                legacy.sort_by(|a, b| {
                    versions::Versioning::new(&b.version)
                        .cmp(&versions::Versioning::new(&a.version))
                });
            }
            if let Some(found) = legacy.first() {
                trace!(
                    "[{short}@{prefix}] found {} in lockfile without options, keeping the version pin and dropping its artifact data",
                    found.version
                );
                let mut found = (*found).clone();
                found.options = request_options.clone();
                found.platforms = found
                    .platforms
                    .into_iter()
                    .map(|(key, info)| (key, info.without_artifact_data()))
                    .filter(|(_, info)| !info.is_empty())
                    .collect();
                return Ok(Some(found));
            }
        }
    }

    Ok(None)
}

fn lockfile_version_matches(prefix: &str, version: &str) -> bool {
    prefix == "latest" || strip_leading_v(version).starts_with(strip_leading_v(prefix))
}

/// Boundary-aware check applied additionally for `prefix:` requests: the
/// stored version must equal the prefix or continue with a version separator
/// right after it. This keeps a stale `10.0.0` entry from satisfying
/// `prefix:1` (which prefix resolution itself would never pick — it matches at
/// separator boundaries) and keeps an empty prefix from matching everything.
fn lockfile_version_matches_prefix_boundary(prefix: &str, version: &str) -> bool {
    let prefix = strip_leading_v(prefix);
    if prefix.is_empty() {
        return false;
    }
    match strip_leading_v(version).strip_prefix(prefix) {
        None => false,
        Some(rest) => rest.is_empty() || prefix.ends_with('-') || rest.starts_with(['.', '-', '+']),
    }
}

fn strip_leading_v(version: &str) -> &str {
    version
        .strip_prefix(['v', 'V'])
        .filter(|stripped| stripped.chars().next().is_some_and(|c| c.is_ascii_digit()))
        .unwrap_or(version)
}

/// Get the backend for a tool from the lockfile, ignoring options.
/// This is used for backend discovery where we just need any entry's backend.
pub fn get_locked_backend(config: &Config, short: &str) -> Option<String> {
    let settings = Settings::get();
    if !settings.lockfile_enabled() {
        return None;
    }

    let lockfile = read_all_lockfiles(config);

    lockfile
        .tools
        .get(short)
        .and_then(|tools| tools.first())
        .and_then(|tool| tool.backend.clone())
}

fn handle_lockfile_read_error(err: Report, lockfile_path: &Path) -> Lockfile {
    // Differentiate between "file not found" (fine) and "parse error" (problem)
    // File not found is expected when lockfile doesn't exist yet
    if let Some(io_err) = err.downcast_ref::<std::io::Error>()
        && io_err.kind() == std::io::ErrorKind::NotFound
    {
        trace!(
            "lockfile {} not found, using empty lockfile",
            display_path(lockfile_path)
        );
        return Lockfile::default();
    }
    // For other errors (parse errors, permission issues), warn the user
    // as this could indicate data loss or corruption
    warn!(
        "failed to read lockfile {} (possible corruption): {err:?}",
        display_path(lockfile_path)
    );
    Lockfile::default()
}

#[cfg(unix)]
fn apply_lockfile_permissions(tmp: &tempfile::NamedTempFile, target: &Path) -> Result<()> {
    use std::os::unix::fs::PermissionsExt;

    match fs::metadata(target) {
        Ok(metadata) => {
            let mode = metadata.permissions().mode();
            tmp.as_file()
                .set_permissions(fs::Permissions::from_mode(mode))?;
        }
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
        Err(err) => return Err(err.into()),
    }
    Ok(())
}

#[cfg(not(unix))]
fn apply_lockfile_permissions(_tmp: &tempfile::NamedTempFile, _target: &Path) -> Result<()> {
    Ok(())
}

fn persist_lockfile_tmp(mut tmp: tempfile::NamedTempFile, target: &Path) -> Result<()> {
    const RETRIES: u32 = 20;

    for attempt in 0..=RETRIES {
        match tmp.persist(target) {
            Ok(_) => return Ok(()),
            Err(err) if should_retry_lockfile_persist(&err.error) && attempt < RETRIES => {
                tmp = err.file;
                std::thread::sleep(std::time::Duration::from_millis(5 * u64::from(attempt + 1)));
            }
            Err(err) => return Err(err.error.into()),
        }
    }

    unreachable!("lockfile persist retry loop should always return");
}

#[cfg(windows)]
fn should_retry_lockfile_persist(err: &std::io::Error) -> bool {
    err.kind() == std::io::ErrorKind::PermissionDenied
}

#[cfg(not(windows))]
fn should_retry_lockfile_persist(_err: &std::io::Error) -> bool {
    false
}

impl TryFrom<toml::Value> for LockfileTool {
    type Error = Report;
    fn try_from(value: toml::Value) -> Result<Self> {
        let tool = match value {
            toml::Value::String(v) => LockfileTool {
                version: v,
                backend: Default::default(),
                options: Default::default(),
                platforms: Default::default(),
            },
            toml::Value::Table(mut t) => {
                let mut platforms = BTreeMap::new();
                // Handle nested platforms table format: [tools.X.platforms.linux-x64]
                if let Some(platforms_table) = t.remove("platforms") {
                    let platforms_table: toml::Table = platforms_table.try_into()?;
                    for (platform, platform_info) in platforms_table {
                        platforms.insert(platform, platform_info.try_into()?);
                    }
                }
                // Handle inline table format: "platforms.linux-x64" = { ... }
                let platform_keys: Vec<_> = t
                    .keys()
                    .filter(|k| k.starts_with("platforms."))
                    .cloned()
                    .collect();
                for key in platform_keys {
                    if let Some(platform_info) = t.remove(&key) {
                        let platform_name = key.strip_prefix("platforms.").unwrap().to_string();
                        platforms.insert(platform_name, platform_info.try_into()?);
                    }
                }
                let mut options = BTreeMap::new();
                if let Some(opts) = t.remove("options") {
                    let opts_table: toml::Table = opts.try_into()?;
                    for (key, value) in opts_table {
                        if let toml::Value::String(s) = value {
                            options.insert(key, s);
                        }
                    }
                }
                // Silently discard env field from old lockfiles for backwards compat
                t.remove("env");
                LockfileTool {
                    version: t
                        .remove("version")
                        .map(|v| v.try_into())
                        .transpose()?
                        .unwrap_or_default(),
                    backend: t
                        .remove("backend")
                        .map(|v| v.try_into())
                        .transpose()?
                        .unwrap_or_default(),
                    options,
                    platforms,
                }
            }
            _ => bail!("unsupported lockfile format {}", value),
        };
        Ok(tool)
    }
}

impl LockfileTool {
    fn into_toml_value(self) -> toml::Value {
        let mut table = toml::Table::new();
        table.insert("version".to_string(), self.version.into());
        if let Some(backend) = self.backend {
            table.insert("backend".to_string(), backend.into());
        }
        if !self.options.is_empty() {
            let opts_table: toml::Table = self
                .options
                .into_iter()
                .map(|(k, v)| (k, toml::Value::String(v)))
                .collect();
            table.insert("options".to_string(), toml::Value::Table(opts_table));
        }
        if !self.platforms.is_empty() {
            table.insert("platforms".to_string(), self.platforms.clone().into());
        }
        table.into()
    }
}

fn lockfile_tool_from_tool_version(tv: &ToolVersion) -> Result<LockfileTool> {
    let mut platforms = BTreeMap::new();

    // Convert tool version lock_platforms to lockfile platforms
    for (platform, platform_info) in &tv.lock_platforms {
        platforms.insert(platform.clone(), platform_info.clone());
    }

    // Resolve lockfile options from the backend
    let options = if let Ok(backend) = tv.request.backend() {
        let target = PlatformTarget::from_current();
        backend.resolve_lockfile_options(&tv.request, &target)?
    } else {
        BTreeMap::new()
    };

    Ok(LockfileTool {
        version: tv.version.clone(),
        backend: Some(tv.ba().stored_full()),
        options,
        platforms,
    })
}

fn format(mut doc: DocumentMut) -> String {
    if let Some(tools) = doc.get_mut("tools") {
        for (_k, v) in tools.as_table_mut().unwrap().iter_mut() {
            if let toml_edit::Item::ArrayOfTables(art) = v {
                for t in art.iter_mut() {
                    t.sort_values_by(|a, _, b, _| {
                        if a == "version" {
                            return std::cmp::Ordering::Less;
                        }
                        if b == "version" {
                            return std::cmp::Ordering::Greater;
                        }
                        a.to_string().cmp(&b.to_string())
                    });
                    // TODO: use TOML 1.1 multiline inline tables once toml_edit supports
                    // InlineTable::set_multiline(). See https://github.com/toml-rs/toml/issues/1027
                    // Convert platforms to dotted-key subtables (multi-line)
                    if let Some(toml_edit::Item::Table(platforms_table)) = t.remove("platforms") {
                        for (platform_key, platform_value) in platforms_table.iter() {
                            if let toml_edit::Item::Table(platform_info) = platform_value {
                                let dotted_key = format!("platforms.{}", platform_key);
                                let mut subtable = toml_edit::Table::new();
                                let mut keys: Vec<_> =
                                    platform_info.iter().map(|(k, _)| k.to_string()).collect();
                                keys.sort_by_key(|k| match k.as_str() {
                                    "checksum" => 0,
                                    "size" => 1,
                                    "url" => 2,
                                    "url_api" => 3,
                                    "provenance" => 4,
                                    _ => 5,
                                });
                                for k in &keys {
                                    if let Some(item) = platform_info.get(k) {
                                        subtable.insert(k, item.clone());
                                    }
                                }
                                subtable.set_implicit(true);
                                t.insert(&dotted_key, toml_edit::Item::Table(subtable));
                            }
                        }
                    }
                }
            }
        }
    }

    doc.to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::{BTreeMap, BTreeSet};
    use std::sync::Arc;
    use std::sync::atomic::{AtomicUsize, Ordering};

    fn basic_tool(version: &str, backend: &str) -> LockfileTool {
        LockfileTool {
            version: version.to_string(),
            backend: Some(backend.to_string()),
            options: BTreeMap::new(),
            platforms: BTreeMap::new(),
        }
    }

    #[test]
    fn test_legacy_lockfile_path_discovery_is_cached() {
        let temp_dir = tempfile::tempdir().unwrap();
        let cache_key = (
            temp_dir.path().join("mise.toml"),
            Some(true),
            vec!["packages/*".to_string()],
        );
        let discoveries = AtomicUsize::new(0);
        let expected = IndexSet::from([temp_dir.path().join("packages/a/mise.lock")]);

        for _ in 0..3 {
            let paths = cached_legacy_lockfile_paths(cache_key.clone(), || {
                discoveries.fetch_add(1, Ordering::Relaxed);
                expected.clone()
            });
            assert_eq!(paths, expected);
        }

        assert_eq!(discoveries.load(Ordering::Relaxed), 1);

        invalidate_caches();
        let paths = cached_legacy_lockfile_paths(cache_key, || {
            discoveries.fetch_add(1, Ordering::Relaxed);
            expected.clone()
        });
        assert_eq!(paths, expected);
        assert_eq!(discoveries.load(Ordering::Relaxed), 2);
    }

    #[test]
    fn test_legacy_lockfile_path_discovery_uses_effective_monorepo_config() {
        let temp_dir = tempfile::tempdir().unwrap();
        let config_path = temp_dir.path().join("mise.toml");
        let discoveries = AtomicUsize::new(0);

        for roots in [["packages/*"], ["crates/*"]] {
            cached_legacy_lockfile_paths(
                (
                    config_path.clone(),
                    Some(true),
                    roots.into_iter().map(str::to_string).collect(),
                ),
                || {
                    discoveries.fetch_add(1, Ordering::Relaxed);
                    IndexSet::new()
                },
            );
        }

        assert_eq!(discoveries.load(Ordering::Relaxed), 2);
    }

    fn basic_tv(backend: &str, version: &str) -> ToolVersion {
        let tool_name = backend
            .rsplit_once('/')
            .map(|(_, name)| name)
            .unwrap_or_else(|| {
                backend
                    .rsplit_once(':')
                    .map(|(_, name)| name)
                    .unwrap_or(backend)
            })
            .to_string();
        let short = tool_name.clone();
        let request = crate::toolset::ToolRequest::Version {
            backend: Arc::new(crate::cli::args::BackendArg::new_raw(
                short,
                Some(backend.to_string()),
                tool_name,
                None,
                crate::cli::args::BackendResolution::new(true),
            )),
            version: version.to_string(),
            options: crate::toolset::ToolVersionOptions::default(),
            source: ToolSource::Unknown,
        };
        ToolVersion::new(request, version.to_string())
    }

    fn tool_with_conda_dep(
        version: &str,
        backend: &str,
        platform: &str,
        dep: &str,
    ) -> LockfileTool {
        let mut platforms = BTreeMap::new();
        platforms.insert(
            platform.to_string(),
            PlatformInfo {
                checksum: None,
                size: None,
                url: None,
                url_api: None,
                conda_deps: Some(vec![dep.to_string()]),
                ..Default::default()
            },
        );
        LockfileTool {
            version: version.to_string(),
            backend: Some(backend.to_string()),
            options: BTreeMap::new(),
            platforms,
        }
    }

    fn add_test_conda_package(lockfile: &mut Lockfile, platform: &str, basename: &str) {
        lockfile.set_conda_package(
            platform,
            basename,
            CondaPackageInfo {
                url: format!("https://example.com/{basename}.conda"),
                checksum: Some(format!("sha256:{basename}")),
            },
        );
    }

    #[test]
    fn empty_resolution_preserves_existing_conda_dependencies() {
        let platform_key = "linux-x64";
        let dependency = "libfoo-1.0-h123_0";
        let mut lockfile = Lockfile::default();
        lockfile.tools.insert(
            "ffmpeg".to_string(),
            vec![tool_with_conda_dep(
                "7.1.1",
                "conda:ffmpeg",
                platform_key,
                dependency,
            )],
        );
        add_test_conda_package(&mut lockfile, platform_key, dependency);

        let result = (
            "ffmpeg".to_string(),
            "7.1.1".to_string(),
            "conda:ffmpeg".to_string(),
            Platform::parse(platform_key).unwrap(),
            Ok(PlatformInfo::default()),
            BTreeMap::new(),
            BTreeMap::new(),
            BTreeMap::new(),
            false,
        );

        assert!(!apply_lock_result(&mut lockfile, result).unwrap());
        lockfile.cleanup_unreferenced_conda_packages();

        let platform = &lockfile.tools["ffmpeg"][0].platforms[platform_key];
        assert_eq!(
            platform.conda_deps.as_deref(),
            Some(&[dependency.to_string()][..])
        );
        assert!(
            lockfile
                .get_conda_package(platform_key, dependency)
                .is_some()
        );
    }

    #[test]
    fn empty_resolution_creates_new_tool_entry_without_platform_metadata() {
        let mut lockfile = Lockfile::default();
        let result = (
            "dummy".to_string(),
            "1.0.0".to_string(),
            "asdf:dummy".to_string(),
            Platform::parse("linux-x64").unwrap(),
            Ok(PlatformInfo::default()),
            BTreeMap::new(),
            BTreeMap::new(),
            BTreeMap::new(),
            false,
        );

        assert!(!apply_lock_result(&mut lockfile, result).unwrap());
        let tool = &lockfile.tools["dummy"][0];
        assert_eq!(tool.version, "1.0.0");
        assert!(tool.platforms.is_empty());
    }

    #[test]
    fn test_array_format_required() {
        // Test that multi-version (array) format is read correctly
        let multi_version_toml = r#"
[[tools.node]]
version = "20.10.0"
backend = "core:node"

[[tools.python]]
version = "3.11.0"
backend = "core:python"
"#;

        let table: toml::Table = toml::from_str(multi_version_toml).unwrap();
        let tools: toml::Table = table.get("tools").unwrap().clone().try_into().unwrap();

        let mut lockfile = Lockfile::default();
        for (short, value) in tools {
            let versions = match value {
                toml::Value::Array(arr) => arr
                    .into_iter()
                    .map(LockfileTool::try_from)
                    .collect::<Result<Vec<_>>>()
                    .unwrap(),
                _ => panic!("expected array format"),
            };
            lockfile.tools.insert(short, versions);
        }

        // Verify that we have the expected tools
        assert_eq!(lockfile.tools.len(), 2);
        assert!(lockfile.tools.contains_key("node"));
        assert!(lockfile.tools.contains_key("python"));

        // Verify node
        let node_versions = &lockfile.tools["node"];
        assert_eq!(node_versions.len(), 1);
        assert_eq!(node_versions[0].version, "20.10.0");
        assert_eq!(node_versions[0].backend, Some("core:node".to_string()));

        // Verify python
        let python_versions = &lockfile.tools["python"];
        assert_eq!(python_versions.len(), 1);
        assert_eq!(python_versions[0].version, "3.11.0");
    }

    #[test]
    fn test_save_uses_array_format() {
        let mut lockfile = Lockfile::default();
        let mut platforms = BTreeMap::new();
        platforms.insert(
            "macos-arm64".to_string(),
            PlatformInfo {
                checksum: Some("sha256:abc123".to_string()),
                size: Some(12345678),
                url: Some("https://example.com/node.tar.gz".to_string()),
                url_api: Some("https://api.github.com.com/repos/test/1234".to_string()),
                conda_deps: None,
                ..Default::default()
            },
        );

        let tool = LockfileTool {
            version: "20.10.0".to_string(),
            backend: Some("core:node".to_string()),
            options: BTreeMap::new(),
            platforms,
        };

        lockfile.tools.insert("node".to_string(), vec![tool]);

        // Create a temporary file to test saving
        let temp_dir = std::env::temp_dir();
        let test_lockfile = temp_dir.join("test_lockfile.lock");

        // Save and verify it uses multi-version format
        lockfile.save(&test_lockfile).unwrap();

        let content = std::fs::read_to_string(&test_lockfile).unwrap();

        // Should use [[tools.node]] array syntax, not [tools.node] single version
        assert!(content.contains("[[tools.node]]"));
        // Verify it doesn't use single-version format (but allow platforms sections)
        assert!(!content.lines().any(|line| line.trim() == "[tools.node]"));

        // Clean up
        let _ = std::fs::remove_file(&test_lockfile);
    }

    #[test]
    fn test_concurrent_save_no_enoent() {
        // Regression: two concurrent save() calls used to share a fixed
        // `mise.lock.tmp` path, so the loser of the rename race got
        // "No such file or directory". Each save must now use a unique
        // temp file so concurrent writers never trip over each other.
        let temp_dir = tempfile::tempdir().unwrap();
        let path = Arc::new(temp_dir.path().join("concurrent.lock"));
        // Pre-create the file so this mirrors the real update path, where
        // save() only runs on lockfiles that already exist.
        std::fs::write(&*path, "").unwrap();

        let threads: Vec<_> = (0..8)
            .map(|_| {
                let path = path.clone();
                std::thread::spawn(move || {
                    for _ in 0..20 {
                        Lockfile::default().save(&*path).unwrap();
                    }
                })
            })
            .collect();
        for t in threads {
            t.join().unwrap();
        }

        // Any leftover temp files would indicate persist() never ran.
        let leftovers: Vec<_> = std::fs::read_dir(temp_dir.path())
            .unwrap()
            .filter_map(|e| e.ok())
            .filter(|e| e.file_name() != std::ffi::OsStr::new("concurrent.lock"))
            .collect();
        assert!(
            leftovers.is_empty(),
            "unexpected temp files left behind: {leftovers:?}"
        );
    }

    #[cfg(unix)]
    #[test]
    fn test_save_preserves_existing_permissions() {
        use std::os::unix::fs::PermissionsExt;

        let temp_dir = tempfile::tempdir().unwrap();
        let path = temp_dir.path().join("mise.lock");
        std::fs::write(&path, "").unwrap();
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o664)).unwrap();

        Lockfile::default().save(&path).unwrap();

        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
        assert_eq!(mode, 0o664);
    }

    #[test]
    fn test_save_preserves_existing_header_url() {
        let temp_dir = tempfile::tempdir().unwrap();
        let path = temp_dir.path().join("mise.lock");
        std::fs::write(
            &path,
            "# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html\n\n[tools]\n",
        )
        .unwrap();

        let lockfile = Lockfile::read(&path).unwrap();
        lockfile.save(&path).unwrap();

        let content = std::fs::read_to_string(&path).unwrap();
        assert!(content.starts_with(
            "# @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html\n"
        ));
    }

    #[test]
    fn test_options_field_parsing_and_serialization() {
        // Test parsing lockfile with options
        let toml_with_options = r#"
[[tools.ripgrep]]
version = "14.0.0"
backend = "ubi:BurntSushi/ripgrep"
options = { exe = "rg", matching = "musl" }

[tools.ripgrep.platforms.linux-x64]
checksum = "blake3:abc123"
"#;

        let table: toml::Table = toml::from_str(toml_with_options).unwrap();
        let tools: toml::Table = table.get("tools").unwrap().clone().try_into().unwrap();

        let mut lockfile = Lockfile::default();
        for (short, value) in tools {
            let versions = match value {
                toml::Value::Array(arr) => arr
                    .into_iter()
                    .map(LockfileTool::try_from)
                    .collect::<Result<Vec<_>>>()
                    .unwrap(),
                _ => vec![LockfileTool::try_from(value).unwrap()],
            };
            lockfile.tools.insert(short, versions);
        }

        // Verify options were parsed correctly
        let ripgrep = &lockfile.tools["ripgrep"][0];
        assert_eq!(ripgrep.options.get("exe"), Some(&"rg".to_string()));
        assert_eq!(ripgrep.options.get("matching"), Some(&"musl".to_string()));
    }

    #[test]
    fn test_options_field_not_serialized_when_empty() {
        let mut lockfile = Lockfile::default();
        let tool = LockfileTool {
            version: "14.0.0".to_string(),
            backend: Some("ubi:BurntSushi/ripgrep".to_string()),
            options: BTreeMap::new(), // Empty options
            platforms: BTreeMap::new(),
        };
        lockfile.tools.insert("ripgrep".to_string(), vec![tool]);

        let temp_dir = std::env::temp_dir();
        let test_lockfile = temp_dir.join("test_lockfile_no_options.lock");

        lockfile.save(&test_lockfile).unwrap();
        let content = std::fs::read_to_string(&test_lockfile).unwrap();

        // Should NOT contain "options" when it's empty
        assert!(!content.contains("options"));

        let _ = std::fs::remove_file(&test_lockfile);
    }

    #[test]
    fn test_options_field_serialized_when_present() {
        let mut lockfile = Lockfile::default();
        let mut options = BTreeMap::new();
        options.insert("exe".to_string(), "rg".to_string());
        options.insert("matching".to_string(), "musl".to_string());

        let tool = LockfileTool {
            version: "14.0.0".to_string(),
            backend: Some("ubi:BurntSushi/ripgrep".to_string()),
            options,
            platforms: BTreeMap::new(),
        };
        lockfile.tools.insert("ripgrep".to_string(), vec![tool]);

        let temp_dir = std::env::temp_dir();
        let test_lockfile = temp_dir.join("test_lockfile_with_options.lock");

        lockfile.save(&test_lockfile).unwrap();
        let content = std::fs::read_to_string(&test_lockfile).unwrap();

        // Should contain options
        assert!(content.contains("options"));
        assert!(content.contains("exe"));
        assert!(content.contains("rg"));

        let _ = std::fs::remove_file(&test_lockfile);
    }

    #[test]
    fn test_options_matching_in_get_locked_version() {
        // This tests that get_locked_version requires exact options match
        let toml_with_options = r#"
[[tools.ripgrep]]
version = "14.0.0"
backend = "ubi:BurntSushi/ripgrep"
options = { exe = "rg", matching = "musl" }

[[tools.ripgrep]]
version = "14.0.0"
backend = "ubi:BurntSushi/ripgrep"
options = { exe = "rg" }
"#;

        let table: toml::Table = toml::from_str(toml_with_options).unwrap();
        let tools: toml::Table = table.get("tools").unwrap().clone().try_into().unwrap();

        let mut lockfile = Lockfile::default();
        for (short, value) in tools {
            let versions = match value {
                toml::Value::Array(arr) => arr
                    .into_iter()
                    .map(LockfileTool::try_from)
                    .collect::<Result<Vec<_>>>()
                    .unwrap(),
                _ => vec![LockfileTool::try_from(value).unwrap()],
            };
            lockfile.tools.insert(short, versions);
        }

        // Verify we have 2 entries for ripgrep with different options
        assert_eq!(lockfile.tools["ripgrep"].len(), 2);
        assert_eq!(lockfile.tools["ripgrep"][0].options.len(), 2);
        assert_eq!(lockfile.tools["ripgrep"][1].options.len(), 1);
    }

    #[test]
    fn test_lockfile_version_matches_prefix_boundary() {
        assert!(lockfile_version_matches_prefix_boundary("1", "1"));
        assert!(lockfile_version_matches_prefix_boundary("1", "1.0.0"));
        assert!(lockfile_version_matches_prefix_boundary("1.7", "1.7.1"));
        assert!(lockfile_version_matches_prefix_boundary("1", "1-rc1"));
        assert!(lockfile_version_matches_prefix_boundary("1", "v1.0.0"));
        assert!(lockfile_version_matches_prefix_boundary("v1", "1.0.0"));
        assert!(lockfile_version_matches_prefix_boundary(
            "temurin-",
            "temurin-25.0.1"
        ));
        // the textual trap: a stale 10.0.0 must NOT satisfy prefix:1
        assert!(!lockfile_version_matches_prefix_boundary("1", "10.0.0"));
        assert!(!lockfile_version_matches_prefix_boundary("0.8", "0.81.0"));
        // an empty prefix must not match everything
        assert!(!lockfile_version_matches_prefix_boundary("", "1.0.0"));
        assert!(!lockfile_version_matches_prefix_boundary("2", "1.0.0"));
    }

    #[test]
    fn test_lockfile_version_matches_normalizes_v_prefix() {
        assert!(lockfile_version_matches("latest", "not-a-semver"));
        assert!(lockfile_version_matches("1.2", "v1.2.3"));
        assert!(lockfile_version_matches("v1.2", "1.2.3"));
        assert!(lockfile_version_matches("V1.2", "v1.2.3"));
        assert!(!lockfile_version_matches("1.3", "v1.2.3"));
        assert!(!lockfile_version_matches("v", "1.2.3"));
        assert!(!lockfile_version_matches("v", "v1.2.3"));
        assert!(lockfile_version_matches("v", "versioned"));
    }

    #[test]
    fn test_lockfile_path_for_config() {
        // Simple case: mise.toml in project root
        let (path, is_local) = lockfile_path_for_config(Path::new("/foo/bar/mise.toml"), None);
        assert_eq!(path, PathBuf::from("/foo/bar/mise.lock"));
        assert!(!is_local);

        // Local config
        let (path, is_local) =
            lockfile_path_for_config(Path::new("/foo/bar/mise.local.toml"), None);
        assert_eq!(path, PathBuf::from("/foo/bar/mise.local.lock"));
        assert!(is_local);

        // Config in .config directory
        let (path, is_local) =
            lockfile_path_for_config(Path::new("/foo/bar/.config/mise.toml"), None);
        assert_eq!(path, PathBuf::from("/foo/bar/.config/mise.lock"));
        assert!(!is_local);

        // Config in .mise directory
        let (path, is_local) =
            lockfile_path_for_config(Path::new("/foo/bar/.mise/config.toml"), None);
        assert_eq!(path, PathBuf::from("/foo/bar/.mise/mise.lock"));
        assert!(!is_local);

        // Config in conf.d directory - should go to parent of conf.d
        let (path, is_local) =
            lockfile_path_for_config(Path::new("/foo/bar/.mise/conf.d/foo.toml"), None);
        assert_eq!(path, PathBuf::from("/foo/bar/.mise/mise.lock"));
        assert!(!is_local);

        // Config in .config/mise/conf.d directory
        let (path, is_local) =
            lockfile_path_for_config(Path::new("/foo/bar/.config/mise/conf.d/foo.toml"), None);
        assert_eq!(path, PathBuf::from("/foo/bar/.config/mise/mise.lock"));
        assert!(!is_local);
    }

    #[test]
    fn test_lockfile_path_for_config_monorepo_root_routing() {
        let monorepo_root = Path::new("/repo");

        let (path, is_local) = lockfile_path_for_config(
            Path::new("/repo/packages/api/mise.toml"),
            Some(monorepo_root),
        );
        assert_eq!(path, PathBuf::from("/repo/mise.lock"));
        assert!(!is_local);

        let (path, is_local) = lockfile_path_for_config(
            Path::new("/repo/packages/api/mise.ci.toml"),
            Some(monorepo_root),
        );
        assert_eq!(path, PathBuf::from("/repo/mise.ci.lock"));
        assert!(!is_local);

        let (path, is_local) = lockfile_path_for_config(
            Path::new("/repo/packages/api/mise.local.toml"),
            Some(monorepo_root),
        );
        assert_eq!(path, PathBuf::from("/repo/mise.local.lock"));
        assert!(is_local);

        let (path, is_local) =
            lockfile_path_for_config(Path::new("/repo/mise.toml"), Some(monorepo_root));
        assert_eq!(path, PathBuf::from("/repo/mise.lock"));
        assert!(!is_local);

        let (path, is_local) =
            lockfile_path_for_config(Path::new("/outside/mise.toml"), Some(monorepo_root));
        assert_eq!(path, PathBuf::from("/outside/mise.lock"));
        assert!(!is_local);

        let (path, is_local) = lockfile_path_for_config(
            Path::new("/repo/packages/api/.mise/conf.d/foo.toml"),
            Some(monorepo_root),
        );
        assert_eq!(path, PathBuf::from("/repo/mise.lock"));
        assert!(!is_local);
    }

    #[test]
    fn test_preserve_absent_tool_entries_for_monorepo_upsert() {
        let existing = vec![basic_tool("20.0.0", "core:node")];
        let (mut merged, consumed) = merge_tool_entries(
            vec![basic_tool("22.0.0", "core:node")],
            Some(&existing),
            |_, _| None,
        );

        preserve_absent_tool_entries(&mut merged, Some(&existing), &consumed);

        let versions = merged
            .iter()
            .map(|tool| tool.version.as_str())
            .collect_vec();
        assert_eq!(versions.len(), 2);
        assert!(versions.contains(&"20.0.0"));
        assert!(versions.contains(&"22.0.0"));
    }

    #[test]
    fn test_reinsert_deferred_baselines_restores_dropped_entry_without_duplicates() {
        let baseline = basic_tool("0.11.0", "github:owner/repo");
        let deferred =
            HashMap::from([("repo".to_string(), vec![baseline.clone(), baseline.clone()])]);
        let mut merged = vec![basic_tool("0.12.0", "github:owner/repo")];

        reinsert_deferred_baselines(&mut merged, &deferred, "repo");

        assert_eq!(
            merged
                .iter()
                .filter(|tool| tool.version == baseline.version && tool.options == baseline.options)
                .count(),
            1
        );
    }

    #[test]
    fn test_preserve_absent_does_not_resurrect_rekeyed_empty_options_entry() {
        // #10564: after merge_tool_entries rekeys an empty-options entry into a
        // same-version optioned entry, the monorepo preserve pass must not
        // resurrect the old empty-options entry as a duplicate sibling.
        let mut platforms = BTreeMap::new();
        platforms.insert(
            "linux-x64".to_string(),
            PlatformInfo {
                checksum: Some("sha256:OLD".to_string()),
                ..Default::default()
            },
        );
        let existing = vec![LockfileTool {
            version: "26.0.1".to_string(),
            backend: Some("core:java".to_string()),
            options: BTreeMap::new(),
            platforms,
        }];
        let mut options = BTreeMap::new();
        options.insert("shorthand_vendor".to_string(), "openjdk".to_string());
        let fresh_platforms = BTreeMap::from([(
            "linux-x64".to_string(),
            PlatformInfo {
                checksum: Some("sha256:NEW".to_string()),
                ..Default::default()
            },
        )]);
        let (mut merged, consumed) = merge_tool_entries(
            vec![LockfileTool {
                version: "26.0.1".to_string(),
                backend: Some("core:java".to_string()),
                options: options.clone(),
                platforms: fresh_platforms,
            }],
            Some(&existing),
            |_, _| Some(options.clone()),
        );

        preserve_absent_tool_entries(&mut merged, Some(&existing), &consumed);

        // Single entry: rekeyed, not duplicated — and it kept the platforms.
        assert_eq!(merged.len(), 1, "rekeyed entry duplicated: {merged:?}");
        assert_eq!(
            merged[0].platforms["linux-x64"].checksum.as_deref(),
            Some("sha256:NEW")
        );

        // Counter-case: when the tool was NOT resolved this run (merged has no
        // same-version optioned entry), a fragmented existing lockfile must be
        // preserved verbatim — both siblings survive, no data loss.
        let mut fragmented = existing.clone();
        fragmented.push(LockfileTool {
            version: "26.0.1".to_string(),
            backend: Some("core:java".to_string()),
            options: BTreeMap::from([("shorthand_vendor".to_string(), "openjdk".to_string())]),
            platforms: BTreeMap::new(),
        });
        let mut merged = vec![basic_tool("22.0.0", "core:node")];
        preserve_absent_tool_entries(&mut merged, Some(&fragmented), &HashSet::new());
        assert_eq!(
            merged.len(),
            3,
            "unresolved entries must be preserved: {merged:?}"
        );
    }

    #[test]
    fn test_merge_tool_entries_rekeys_empty_options_entry_on_new_options() {
        // Regression guard for #10564: when a freshly-resolved entry for a
        // version gains non-empty `options` (e.g. java's shorthand_vendor)
        // while the on-disk lockfile still has that version keyed under empty
        // options, the old entry's platforms must be rekeyed, not dropped.
        let mut existing_platforms = BTreeMap::new();
        existing_platforms.insert(
            "linux-x64".to_string(),
            PlatformInfo {
                checksum: Some("sha256:OLD".to_string()),
                url: Some("https://example.com/java-linux-x64.tar.gz".to_string()),
                ..Default::default()
            },
        );
        existing_platforms.insert(
            "macos-arm64".to_string(),
            PlatformInfo {
                checksum: Some("sha256:OLD-MAC".to_string()),
                url: Some("https://example.com/java-macos-arm64.tar.gz".to_string()),
                ..Default::default()
            },
        );
        let existing = vec![LockfileTool {
            version: "26.0.1".to_string(),
            backend: Some("core:java".to_string()),
            options: BTreeMap::new(),
            platforms: existing_platforms,
        }];

        let mut new_options = BTreeMap::new();
        new_options.insert("shorthand_vendor".to_string(), "openjdk".to_string());
        let mut fresh_platforms = BTreeMap::new();
        fresh_platforms.insert(
            "linux-x64".to_string(),
            PlatformInfo {
                checksum: Some("sha256:NEW".to_string()),
                url: Some("https://example.com/java-linux-x64.tar.gz".to_string()),
                ..Default::default()
            },
        );
        let fresh = vec![LockfileTool {
            version: "26.0.1".to_string(),
            backend: Some("core:java".to_string()),
            options: new_options.clone(),
            platforms: fresh_platforms,
        }];

        let (merged, consumed) =
            merge_tool_entries(fresh, Some(&existing), |_, _| Some(new_options.clone()));

        assert!(consumed.contains(&("26.0.1".to_string(), BTreeMap::new())));
        assert_eq!(
            merged.len(),
            1,
            "expected a single merged entry, got {merged:?}"
        );
        let tool = &merged[0];
        assert_eq!(tool.options, new_options);
        assert_eq!(tool.platforms.len(), 2);
        assert_eq!(
            tool.platforms["linux-x64"].checksum.as_deref(),
            Some("sha256:NEW")
        );
        assert_eq!(
            tool.platforms["macos-arm64"].checksum.as_deref(),
            Some("sha256:OLD-MAC")
        );
    }

    #[test]
    fn test_merge_tool_entries_preserves_disjoint_empty_options_variant() {
        let unix_options = BTreeMap::from([("compile".to_string(), "false".to_string())]);
        let existing = vec![
            LockfileTool {
                version: "3.4.2".to_string(),
                backend: Some("core:ruby".to_string()),
                options: unix_options.clone(),
                platforms: BTreeMap::from([(
                    "linux-x64".to_string(),
                    PlatformInfo {
                        url: Some("https://example.com/ruby-linux.tar.gz".to_string()),
                        ..Default::default()
                    },
                )]),
            },
            LockfileTool {
                version: "3.4.2".to_string(),
                backend: Some("core:ruby".to_string()),
                options: BTreeMap::new(),
                platforms: BTreeMap::from([(
                    "windows-x64".to_string(),
                    PlatformInfo {
                        url: Some("https://example.com/ruby-windows.7z".to_string()),
                        ..Default::default()
                    },
                )]),
            },
        ];
        let fresh = vec![
            LockfileTool {
                version: "3.4.2".to_string(),
                backend: Some("core:ruby".to_string()),
                options: unix_options,
                platforms: BTreeMap::from([(
                    "linux-x64".to_string(),
                    PlatformInfo {
                        url: Some("https://example.com/ruby-linux.tar.gz".to_string()),
                        ..Default::default()
                    },
                )]),
            },
            LockfileTool {
                version: "3.4.2".to_string(),
                backend: Some("core:ruby".to_string()),
                options: BTreeMap::new(),
                platforms: BTreeMap::from([(
                    "windows-x64".to_string(),
                    PlatformInfo {
                        url: Some("https://example.com/ruby-windows.7z".to_string()),
                        ..Default::default()
                    },
                )]),
            },
        ];

        let (merged, consumed) = merge_tool_entries(fresh, Some(&existing), |_, platform| {
            if platform.is_windows() {
                Some(BTreeMap::new())
            } else {
                Some(BTreeMap::from([(
                    "compile".to_string(),
                    "false".to_string(),
                )]))
            }
        });

        assert!(consumed.is_empty());
        assert_eq!(
            merged.len(),
            2,
            "option variants were conflated: {merged:?}"
        );
        assert!(
            merged.iter().any(|tool| {
                tool.options.is_empty() && tool.platforms.contains_key("windows-x64")
            })
        );
        assert!(merged.iter().any(|tool| {
            !tool.options.is_empty() && !tool.platforms.contains_key("windows-x64")
        }));

        let mut lockfile = Lockfile::default();
        lockfile.tools.insert("ruby".to_string(), merged);
        let temp = tempfile::tempdir().unwrap();
        let path = temp.path().join("mise.lock");
        lockfile.save(&path).unwrap();
        let first = std::fs::read_to_string(&path).unwrap();
        Lockfile::read(&path).unwrap().save(&path).unwrap();
        let second = std::fs::read_to_string(&path).unwrap();

        assert_eq!(first, second, "lockfile serialization was not idempotent");
        assert_eq!(second.matches("[[tools.ruby]]").count(), 2);
        assert_eq!(second.matches("platforms.windows-x64").count(), 1);
    }

    #[test]
    fn test_merge_tool_entries_does_not_copy_changed_options_data() {
        let old_options = BTreeMap::from([("flavor".to_string(), "old".to_string())]);
        let new_options = BTreeMap::from([("flavor".to_string(), "new".to_string())]);
        let existing = vec![LockfileTool {
            version: "1.0.0".to_string(),
            backend: Some("example:tool".to_string()),
            options: old_options,
            platforms: BTreeMap::from([(
                "linux-x64".to_string(),
                PlatformInfo {
                    checksum: Some("sha256:OLD".to_string()),
                    ..Default::default()
                },
            )]),
        }];
        let fresh = vec![LockfileTool {
            version: "1.0.0".to_string(),
            backend: Some("example:tool".to_string()),
            options: new_options.clone(),
            platforms: BTreeMap::from([(
                "linux-x64".to_string(),
                PlatformInfo {
                    checksum: Some("sha256:NEW".to_string()),
                    ..Default::default()
                },
            )]),
        }];

        let (merged, consumed) =
            merge_tool_entries(fresh, Some(&existing), |_, _| Some(new_options.clone()));

        assert!(consumed.is_empty());
        assert_eq!(merged.len(), 1);
        assert_eq!(
            merged[0].platforms["linux-x64"].checksum.as_deref(),
            Some("sha256:NEW")
        );
    }

    #[test]
    fn test_merge_tool_entries_preserves_data_when_resolution_fails() {
        let existing = vec![LockfileTool {
            version: "26.0.1".to_string(),
            backend: Some("core:java".to_string()),
            options: BTreeMap::new(),
            platforms: BTreeMap::from([(
                "linux-x64".to_string(),
                PlatformInfo {
                    checksum: Some("sha256:OLD".to_string()),
                    ..Default::default()
                },
            )]),
        }];
        let fresh = vec![LockfileTool {
            version: "26.0.1".to_string(),
            backend: Some("core:java".to_string()),
            options: BTreeMap::from([("shorthand_vendor".to_string(), "openjdk".to_string())]),
            platforms: BTreeMap::new(),
        }];

        let (merged, consumed) = merge_tool_entries(fresh, Some(&existing), |_, _| None);

        assert!(consumed.is_empty());
        assert_eq!(merged.len(), 2);
        assert!(merged.iter().any(|tool| {
            tool.options.is_empty()
                && tool.platforms["linux-x64"].checksum.as_deref() == Some("sha256:OLD")
        }));
    }

    #[test]
    fn test_record_rekey_decision_requires_consensus() {
        let key = ("1.0.0".to_string(), "linux-x64".to_string());
        let options_a = BTreeMap::from([("flavor".to_string(), "a".to_string())]);
        let options_b = BTreeMap::from([("flavor".to_string(), "b".to_string())]);
        let mut decisions = RekeyDecisions::new();

        record_rekey_decision(&mut decisions, key.clone(), Some(options_a.clone()));
        record_rekey_decision(&mut decisions, key.clone(), Some(options_a));
        assert!(decisions[&key].is_some());

        record_rekey_decision(&mut decisions, key.clone(), Some(options_b));
        assert_eq!(decisions[&key], None);

        // Once requests disagree, later candidates cannot make the decision
        // authoritative again.
        record_rekey_decision(
            &mut decisions,
            key.clone(),
            Some(BTreeMap::from([("flavor".to_string(), "a".to_string())])),
        );
        assert_eq!(decisions[&key], None);
    }

    #[test]
    fn test_merge_tool_entries_repairs_empty_non_empty_duplicate() {
        let unix_options = BTreeMap::from([("compile".to_string(), "false".to_string())]);
        let existing = vec![
            LockfileTool {
                version: "3.4.2".to_string(),
                backend: Some("core:ruby".to_string()),
                options: BTreeMap::new(),
                platforms: BTreeMap::from([(
                    "linux-x64".to_string(),
                    PlatformInfo {
                        checksum: Some("sha256:LEGACY".to_string()),
                        ..Default::default()
                    },
                )]),
            },
            LockfileTool {
                version: "3.4.2".to_string(),
                backend: Some("core:ruby".to_string()),
                options: unix_options.clone(),
                platforms: BTreeMap::from([(
                    "linux-x64".to_string(),
                    PlatformInfo {
                        checksum: Some("sha256:OLD".to_string()),
                        ..Default::default()
                    },
                )]),
            },
        ];
        let fresh = vec![LockfileTool {
            version: "3.4.2".to_string(),
            backend: Some("core:ruby".to_string()),
            options: unix_options.clone(),
            platforms: BTreeMap::from([(
                "linux-x64".to_string(),
                PlatformInfo {
                    checksum: Some("sha256:NEW".to_string()),
                    ..Default::default()
                },
            )]),
        }];

        let (merged, consumed) =
            merge_tool_entries(fresh, Some(&existing), |_, _| Some(unix_options.clone()));

        assert!(consumed.contains(&("3.4.2".to_string(), BTreeMap::new())));
        assert_eq!(merged.len(), 1);
        assert_eq!(
            merged[0].platforms["linux-x64"].checksum.as_deref(),
            Some("sha256:NEW")
        );
    }

    #[test]
    fn test_preserve_absent_keeps_other_non_empty_monorepo_variant() {
        let current_options = BTreeMap::from([("vendor".to_string(), "current".to_string())]);
        let other_options = BTreeMap::from([("vendor".to_string(), "other".to_string())]);
        let existing = vec![LockfileTool {
            version: "1.0.0".to_string(),
            backend: Some("example:tool".to_string()),
            options: other_options.clone(),
            platforms: BTreeMap::new(),
        }];
        let fresh = vec![LockfileTool {
            version: "1.0.0".to_string(),
            backend: Some("example:tool".to_string()),
            options: current_options,
            platforms: BTreeMap::new(),
        }];
        let (mut merged, consumed) = merge_tool_entries(fresh, Some(&existing), |_, _| None);

        preserve_absent_tool_entries(&mut merged, Some(&existing), &consumed);

        assert_eq!(merged.len(), 2);
        assert!(merged.iter().any(|tool| tool.options == other_options));
    }

    #[test]
    fn test_tool_version_needs_auto_lock_for_bare_updated_entry() {
        let mut lockfile = Lockfile::default();
        lockfile.tools.insert(
            "ruff".to_string(),
            vec![basic_tool("0.15.20", "aqua:astral-sh/ruff")],
        );

        let tv = basic_tv("aqua:astral-sh/ruff", "0.15.20");

        assert!(tool_version_needs_auto_lock(&lockfile, &tv, &[Platform::current()]).unwrap());
    }

    #[test]
    fn test_tool_version_needs_auto_lock_ignores_complete_entry() {
        let mut lockfile = Lockfile::default();
        let mut tool = basic_tool("0.15.20", "aqua:astral-sh/ruff");
        tool.platforms.insert(
            Platform::current().to_key(),
            PlatformInfo {
                checksum: Some("sha256:abc123".to_string()),
                url: Some("https://example.com/ruff.tar.gz".to_string()),
                ..Default::default()
            },
        );
        lockfile.tools.insert("ruff".to_string(), vec![tool]);

        let tv = basic_tv("aqua:astral-sh/ruff", "0.15.20");

        assert!(!tool_version_needs_auto_lock(&lockfile, &tv, &[Platform::current()]).unwrap());
    }

    #[test]
    fn test_tool_version_needs_auto_lock_ignores_pruned_version() {
        let mut lockfile = Lockfile::default();
        lockfile.tools.insert(
            "ruff".to_string(),
            vec![basic_tool("0.15.20", "aqua:astral-sh/ruff")],
        );

        let stale = basic_tv("aqua:astral-sh/ruff", "0.15.19");

        assert!(!tool_version_needs_auto_lock(&lockfile, &stale, &[Platform::current()]).unwrap());
    }

    #[test]
    fn test_tool_version_needs_auto_lock_uses_target_specific_options() {
        let unix_options = BTreeMap::from([("compile".to_string(), "false".to_string())]);
        let tools = vec![
            LockfileTool {
                version: "3.4.2".to_string(),
                backend: Some("core:ruby".to_string()),
                options: unix_options.clone(),
                platforms: BTreeMap::from([(
                    "linux-x64".to_string(),
                    PlatformInfo {
                        checksum: Some("sha256:linux".to_string()),
                        url: Some("https://example.com/ruby-linux.tar.gz".to_string()),
                        ..Default::default()
                    },
                )]),
            },
            LockfileTool {
                version: "3.4.2".to_string(),
                backend: Some("core:ruby".to_string()),
                options: BTreeMap::new(),
                platforms: BTreeMap::from([(
                    "windows-x64".to_string(),
                    PlatformInfo {
                        checksum: Some("sha256:windows".to_string()),
                        url: Some("https://example.com/ruby-windows.7z".to_string()),
                        ..Default::default()
                    },
                )]),
            },
        ];

        assert!(!lockfile_target_needs_auto_lock(
            &tools,
            "3.4.2",
            "linux-x64",
            &unix_options,
        ));
        assert!(!lockfile_target_needs_auto_lock(
            &tools,
            "3.4.2",
            "windows-x64",
            &BTreeMap::new(),
        ));
        assert!(lockfile_target_needs_auto_lock(
            &tools,
            "3.4.2",
            "windows-x64",
            &unix_options,
        ));
    }

    #[test]
    fn test_complete_target_still_auto_locks_deferred_provenance_upgrade() {
        let platform = Platform::current().to_key();
        let mut baseline = basic_tool("0.11.0", "github:owner/repo");
        baseline.platforms.insert(
            platform.clone(),
            PlatformInfo {
                provenance: Some(ProvenanceType::GithubAttestations),
                ..Default::default()
            },
        );
        let mut pending = basic_tool("0.12.0", "github:owner/repo");
        pending.platforms.insert(
            platform.clone(),
            PlatformInfo {
                checksum: Some("sha256:pending".to_string()),
                url: Some("https://example.com/repo-0.12.0.tar.gz".to_string()),
                ..Default::default()
            },
        );
        let mut tools = vec![baseline, pending];

        assert!(
            lockfile_target_needs_auto_lock(&tools, "0.12.0", &platform, &BTreeMap::new()),
            "complete URL/checksum data must not skip deferred provenance verification"
        );

        tools[1].platforms.get_mut(&platform).unwrap().provenance =
            Some(ProvenanceType::GithubAttestations);
        assert!(!lockfile_target_needs_auto_lock(
            &tools,
            "0.12.0",
            &platform,
            &BTreeMap::new()
        ));
    }

    #[test]
    fn test_no_new_versions_retries_only_deferred_provenance() {
        let platform = Platform::current();
        let platform_key = platform.to_key();
        let tv = basic_tv("github:owner/repo", "0.12.0");
        let mut baseline = basic_tool("0.11.0", "github:owner/repo");
        baseline.platforms.insert(
            platform_key.clone(),
            PlatformInfo {
                provenance: Some(ProvenanceType::GithubAttestations),
                ..Default::default()
            },
        );
        let pending = basic_tool("0.12.0", "github:owner/repo");
        let mut lockfile = Lockfile::default();
        lockfile
            .tools
            .insert("repo".to_string(), vec![baseline, pending.clone()]);

        assert!(
            tool_version_needs_deferred_provenance_retry(
                &lockfile,
                &tv,
                std::slice::from_ref(&platform),
            )
            .unwrap(),
            "a later install with no new versions must retry deferred verification"
        );

        lockfile.tools.insert("repo".to_string(), vec![pending]);
        assert!(
            !tool_version_needs_deferred_provenance_retry(
                &lockfile,
                &tv,
                std::slice::from_ref(&platform),
            )
            .unwrap(),
            "ordinary incomplete lock entries must not broaden the no-new-version retry"
        );
    }

    #[test]
    fn test_merge_lockfile_preserving_root_keeps_root_on_conflict() {
        let mut root = Lockfile::default();
        root.tools
            .insert("node".to_string(), vec![basic_tool("20.0.0", "core:node")]);

        let mut subproject = Lockfile::default();
        subproject.tools.insert(
            "node".to_string(),
            vec![
                basic_tool("20.0.0", "core:node-alt"),
                basic_tool("22.0.0", "core:node"),
            ],
        );

        merge_lockfile_preserving_root(&mut root, subproject);

        let tools = root.tools.get("node").unwrap();
        assert_eq!(tools.len(), 2);
        assert_eq!(tools[0].version, "20.0.0");
        assert_eq!(tools[0].backend.as_deref(), Some("core:node"));
        assert_eq!(tools[1].version, "22.0.0");
    }

    #[test]
    fn test_conda_packages_parsing() {
        let toml_with_conda = r#"
[conda-packages."macos-arm64"]
"ncurses-6.4-h7ea286d_0" = { url = "https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.4-h7ea286d_0.conda", checksum = "sha256:abc123" }
"readline-8.2-h92ec313_1" = { url = "https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h92ec313_1.conda" }

[[tools.jq]]
version = "1.7.1"
backend = "conda:jq"
"platforms.macos-arm64" = { url = "https://example.com/jq.conda", checksum = "sha256:def456", conda_deps = ["ncurses-6.4-h7ea286d_0", "readline-8.2-h92ec313_1"] }
"#;

        let temp_dir = std::env::temp_dir();
        let test_lockfile = temp_dir.join("test_conda_parse.lock");
        std::fs::write(&test_lockfile, toml_with_conda).unwrap();

        let lockfile = Lockfile::read(&test_lockfile).unwrap();

        // Verify conda packages were parsed
        let macos_packages = lockfile.conda_packages.get("macos-arm64").unwrap();
        assert_eq!(macos_packages.len(), 2);

        let ncurses = macos_packages.get("ncurses-6.4-h7ea286d_0").unwrap();
        assert_eq!(
            ncurses.url,
            "https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.4-h7ea286d_0.conda"
        );
        assert_eq!(ncurses.checksum, Some("sha256:abc123".to_string()));

        let readline = macos_packages.get("readline-8.2-h92ec313_1").unwrap();
        assert!(readline.checksum.is_none());

        // Verify tool with conda_deps was parsed
        let jq = &lockfile.tools["jq"][0];
        let platform_info = jq.platforms.get("macos-arm64").unwrap();
        assert_eq!(
            platform_info.conda_deps,
            Some(vec![
                "ncurses-6.4-h7ea286d_0".to_string(),
                "readline-8.2-h92ec313_1".to_string()
            ])
        );

        let _ = std::fs::remove_file(&test_lockfile);
    }

    #[test]
    fn test_conda_packages_serialization() {
        let mut lockfile = Lockfile::default();

        // Add conda packages
        lockfile.set_conda_package(
            "macos-arm64",
            "ncurses-6.4-h7ea286d_0",
            CondaPackageInfo {
                url: "https://example.com/ncurses.conda".to_string(),
                checksum: Some("sha256:abc123".to_string()),
            },
        );

        // Add a tool with conda_deps
        let mut platforms = BTreeMap::new();
        platforms.insert(
            "macos-arm64".to_string(),
            PlatformInfo {
                url: Some("https://example.com/jq.conda".to_string()),
                checksum: Some("sha256:def456".to_string()),
                size: None,
                url_api: None,
                conda_deps: Some(vec!["ncurses-6.4-h7ea286d_0".to_string()]),
                ..Default::default()
            },
        );
        lockfile.tools.insert(
            "jq".to_string(),
            vec![LockfileTool {
                version: "1.7.1".to_string(),
                backend: Some("conda:jq".to_string()),
                options: BTreeMap::new(),
                platforms,
            }],
        );

        let temp_dir = std::env::temp_dir();
        let test_lockfile = temp_dir.join("test_conda_serialize.lock");

        lockfile.save(&test_lockfile).unwrap();
        let content = std::fs::read_to_string(&test_lockfile).unwrap();

        // Verify content contains conda-packages section (TOML formats it as nested tables)
        assert!(
            content.contains("conda-packages"),
            "content should contain conda-packages section: {content}"
        );
        assert!(
            content.contains("ncurses-6.4-h7ea286d_0"),
            "content should contain package name: {content}"
        );
        assert!(
            content.contains("https://example.com/ncurses.conda"),
            "content should contain URL: {content}"
        );

        // Verify tool section contains conda_deps
        assert!(
            content.contains("conda_deps"),
            "content should contain conda_deps: {content}"
        );

        // Round-trip test: read it back
        let reloaded = Lockfile::read(&test_lockfile).unwrap();
        let packages = reloaded.conda_packages.get("macos-arm64").unwrap();
        assert!(packages.contains_key("ncurses-6.4-h7ea286d_0"));

        let _ = std::fs::remove_file(&test_lockfile);
    }

    #[test]
    fn test_cleanup_unreferenced_conda_packages() {
        let mut lockfile = Lockfile::default();

        // Add some conda packages
        lockfile.set_conda_package(
            "macos-arm64",
            "referenced-pkg",
            CondaPackageInfo {
                url: "https://example.com/referenced.conda".to_string(),
                checksum: Some("sha256:abc123".to_string()),
            },
        );
        lockfile.set_conda_package(
            "macos-arm64",
            "unreferenced-pkg",
            CondaPackageInfo {
                url: "https://example.com/unreferenced.conda".to_string(),
                checksum: Some("sha256:def456".to_string()),
            },
        );
        lockfile.set_conda_package(
            "linux-x64",
            "orphan-platform-pkg",
            CondaPackageInfo {
                url: "https://example.com/orphan.conda".to_string(),
                checksum: None,
            },
        );

        // Add a tool that only references one package
        let mut platforms = BTreeMap::new();
        platforms.insert(
            "macos-arm64".to_string(),
            PlatformInfo {
                url: Some("https://example.com/tool.conda".to_string()),
                checksum: None,
                size: None,
                url_api: None,
                conda_deps: Some(vec!["referenced-pkg".to_string()]),
                ..Default::default()
            },
        );
        lockfile.tools.insert(
            "mytool".to_string(),
            vec![LockfileTool {
                version: "1.0.0".to_string(),
                backend: Some("conda:mytool".to_string()),
                options: BTreeMap::new(),
                platforms,
            }],
        );

        // Verify we have all packages before cleanup
        assert_eq!(lockfile.conda_packages.len(), 2);
        assert_eq!(lockfile.conda_packages["macos-arm64"].len(), 2);
        assert_eq!(lockfile.conda_packages["linux-x64"].len(), 1);

        // Run cleanup
        lockfile.cleanup_unreferenced_conda_packages();

        // Verify only referenced package remains
        assert_eq!(lockfile.conda_packages.len(), 1);
        assert!(lockfile.conda_packages.contains_key("macos-arm64"));
        assert!(!lockfile.conda_packages.contains_key("linux-x64"));
        assert_eq!(lockfile.conda_packages["macos-arm64"].len(), 1);
        assert!(lockfile.conda_packages["macos-arm64"].contains_key("referenced-pkg"));
        assert!(!lockfile.conda_packages["macos-arm64"].contains_key("unreferenced-pkg"));
    }

    #[test]
    fn test_retain_tools_by_short_prunes_removed_tools() {
        let mut lockfile = Lockfile::default();
        lockfile
            .tools
            .insert("dummy".to_string(), vec![basic_tool("1.0.0", "asdf:dummy")]);
        lockfile
            .tools
            .insert("tiny".to_string(), vec![basic_tool("2.1.0", "asdf:tiny")]);

        let keep_shorts = BTreeSet::from(["tiny".to_string()]);
        lockfile.retain_tools_by_short_or_backend(&keep_shorts, &BTreeSet::new());

        assert!(!lockfile.tools.contains_key("dummy"));
        assert!(lockfile.tools.contains_key("tiny"));
    }

    #[test]
    fn test_stale_tool_shorts_identifies_removed_tools() {
        let mut lockfile = Lockfile::default();
        lockfile
            .tools
            .insert("dummy".to_string(), vec![basic_tool("1.0.0", "asdf:dummy")]);
        lockfile
            .tools
            .insert("tiny".to_string(), vec![basic_tool("2.1.0", "asdf:tiny")]);

        let keep_shorts = BTreeSet::from(["tiny".to_string()]);
        let stale = lockfile.stale_tool_shorts(&keep_shorts, &BTreeSet::new());
        assert_eq!(stale, BTreeSet::from(["dummy".to_string()]));
    }

    #[test]
    fn test_stale_tool_shorts_respects_backend_identifiers() {
        let mut lockfile = Lockfile::default();
        lockfile.tools.insert(
            "jq".to_string(),
            vec![basic_tool("1.7.1", "aqua:jqlang/jq")],
        );

        let keep_backends = BTreeSet::from(["aqua:jqlang/jq".to_string()]);
        let stale = lockfile.stale_tool_shorts(&BTreeSet::new(), &keep_backends);
        assert!(stale.is_empty());
    }

    #[test]
    fn test_retain_tools_by_short_cleans_unreferenced_conda_packages() {
        let mut lockfile = Lockfile::default();

        add_test_conda_package(&mut lockfile, "linux-x64", "keep-pkg");
        add_test_conda_package(&mut lockfile, "linux-x64", "drop-pkg");

        lockfile.tools.insert(
            "tiny".to_string(),
            vec![tool_with_conda_dep(
                "2.1.0",
                "conda:tiny",
                "linux-x64",
                "keep-pkg",
            )],
        );
        lockfile.tools.insert(
            "dummy".to_string(),
            vec![tool_with_conda_dep(
                "1.0.0",
                "conda:dummy",
                "linux-x64",
                "drop-pkg",
            )],
        );

        let keep_shorts = BTreeSet::from(["tiny".to_string()]);
        lockfile.retain_tools_by_short_or_backend(&keep_shorts, &BTreeSet::new());

        assert!(lockfile.tools.contains_key("tiny"));
        assert!(!lockfile.tools.contains_key("dummy"));

        let linux_packages = lockfile.conda_packages.get("linux-x64").unwrap();
        assert!(linux_packages.contains_key("keep-pkg"));
        assert!(!linux_packages.contains_key("drop-pkg"));
    }

    #[test]
    fn test_retain_tools_by_short_or_backend_preserves_legacy_keyed_entries() {
        let mut lockfile = Lockfile::default();
        lockfile.tools.insert(
            "jq".to_string(),
            vec![LockfileTool {
                version: "1.7.1".to_string(),
                backend: Some("aqua:jqlang/jq".to_string()),
                options: BTreeMap::new(),

                platforms: BTreeMap::new(),
            }],
        );

        let keep_shorts = BTreeSet::from(["aqua:jqlang/jq".to_string()]);
        let keep_backends = BTreeSet::from(["aqua:jqlang/jq".to_string()]);
        lockfile.retain_tools_by_short_or_backend(&keep_shorts, &keep_backends);

        assert!(lockfile.tools.contains_key("jq"));
    }

    #[test]
    fn test_platform_info_merge_prefers_sha256() {
        // The sha256-vs-blake3 preference only matters when both checksums
        // describe the same artifact, so use a shared URL here.
        let url = Some("https://example.com/a".to_string());
        let sha256_info = PlatformInfo {
            checksum: Some("sha256:abc123".to_string()),
            url: url.clone(),
            ..Default::default()
        };
        let blake3_info = PlatformInfo {
            checksum: Some("blake3:def456".to_string()),
            url: url.clone(),
            ..Default::default()
        };

        // sha256 + blake3 -> sha256
        let merged = sha256_info.merge_with(&blake3_info);
        assert_eq!(merged.checksum, Some("sha256:abc123".to_string()));

        // blake3 + sha256 -> sha256
        let merged = blake3_info.merge_with(&sha256_info);
        assert_eq!(merged.checksum, Some("sha256:abc123".to_string()));

        // blake3 + blake3 -> self (first)
        let another_blake3 = PlatformInfo {
            checksum: Some("blake3:ghi789".to_string()),
            url: url.clone(),
            ..Default::default()
        };
        let merged = blake3_info.merge_with(&another_blake3);
        assert_eq!(merged.checksum, Some("blake3:def456".to_string()));

        // Preserves URL from other if self is missing
        let no_url = PlatformInfo {
            checksum: Some("sha256:abc123".to_string()),
            url: None,
            ..Default::default()
        };
        let merged = no_url.merge_with(&blake3_info);
        assert_eq!(merged.url, url);
    }

    #[test]
    fn test_platform_info_merge_drops_stale_checksum_on_url_change() {
        // When URLs disagree, the other side's checksum/size/url_api describe
        // a different artifact and must not be carried over (e.g. node musl
        // URL bumped but old glibc checksum still on disk).
        let new_info = PlatformInfo {
            checksum: None,
            size: None,
            url: Some("https://example.com/v2.tar.gz".to_string()),
            url_api: None,
            ..Default::default()
        };
        let stale_info = PlatformInfo {
            checksum: Some("sha256:OLD".to_string()),
            size: Some(123),
            url: Some("https://example.com/v1.tar.gz".to_string()),
            url_api: Some("https://api.example.com/v1".to_string()),
            ..Default::default()
        };

        let merged = new_info.merge_with(&stale_info);
        assert_eq!(merged.url.as_deref(), Some("https://example.com/v2.tar.gz"));
        assert_eq!(merged.checksum, None);
        assert_eq!(merged.size, None);
        assert_eq!(merged.url_api, None);

        // Same artifact (matching URLs) preserves the missing fields.
        let same_url = PlatformInfo {
            url: Some("https://example.com/v1.tar.gz".to_string()),
            ..Default::default()
        };
        let merged = same_url.merge_with(&stale_info);
        assert_eq!(merged.checksum.as_deref(), Some("sha256:OLD"));
        assert_eq!(merged.size, Some(123));
    }

    #[test]
    fn test_platform_info_merge_clears_stale_source_marker_on_url() {
        let new_info = PlatformInfo {
            checksum: Some("sha256:NEW".to_string()),
            url: Some("https://example.com/v2.tar.gz".to_string()),
            ..Default::default()
        };
        let stale_source = PlatformInfo {
            install: Some("source".to_string()),
            checksum: Some("sha256:OLD".to_string()),
            url: Some("https://example.com/source.tar.gz".to_string()),
            ..Default::default()
        };

        let merged = new_info.merge_with(&stale_source);
        assert_eq!(merged.install, None);
        assert_eq!(merged.url.as_deref(), Some("https://example.com/v2.tar.gz"));
        assert_eq!(merged.checksum.as_deref(), Some("sha256:NEW"));
    }

    #[test]
    fn test_provenance_fields_roundtrip() {
        let info = PlatformInfo {
            checksum: Some("sha256:abc123".to_string()),
            url: Some("https://example.com/tool.tar.gz".to_string()),
            provenance: Some(ProvenanceType::Slsa {
                url: Some("https://example.com/tool.intoto.jsonl".to_string()),
            }),
            provenance_verified: true,
            ..Default::default()
        };

        // Test toml roundtrip — SLSA serializes as a table with url inside
        let toml_val: toml::Value = info.clone().into();
        let table = toml_val.as_table().unwrap();
        let prov_table = table.get("provenance").unwrap().as_table().unwrap();
        let slsa_table = prov_table.get("slsa").unwrap().as_table().unwrap();
        assert_eq!(
            slsa_table.get("url").unwrap().as_str().unwrap(),
            "https://example.com/tool.intoto.jsonl"
        );
        assert_eq!(
            table.get("provenance_verified").and_then(|v| v.as_bool()),
            Some(true)
        );
        let parsed: PlatformInfo = toml_val.try_into().unwrap();
        assert!(parsed.provenance.as_ref().unwrap().is_slsa());
        assert!(parsed.provenance_verified);
        match &parsed.provenance {
            Some(ProvenanceType::Slsa { url }) => {
                assert_eq!(
                    url.as_deref(),
                    Some("https://example.com/tool.intoto.jsonl")
                );
            }
            _ => panic!("expected Slsa provenance"),
        }
    }

    #[test]
    fn test_additional_artifacts_roundtrip() {
        let info = PlatformInfo {
            checksum: Some("sha256:primary".to_string()),
            url: Some("https://example.com/tool.tar.gz".to_string()),
            additional_artifacts: vec![
                ArtifactInfo {
                    checksum: Some("sha256:rocm".to_string()),
                    url: "https://example.com/tool-rocm.tar.gz".to_string(),
                    provenance: Some(ProvenanceType::GithubAttestations),
                    provenance_verified: true,
                    ..Default::default()
                },
                ArtifactInfo {
                    checksum: Some("blake3:debug".to_string()),
                    url: "https://example.com/tool-debug.zip".to_string(),
                    ..Default::default()
                },
            ],
            ..Default::default()
        };

        let mut toml_val: toml::Value = info.clone().into();
        toml_val
            .as_table_mut()
            .unwrap()
            .get_mut("additional_artifacts")
            .unwrap()
            .as_array_mut()
            .unwrap()[0]
            .as_table_mut()
            .unwrap()
            .insert(
                "future_lockfile_field".to_string(),
                toml::Value::String("ignored by older mise versions".to_string()),
            );
        let parsed: PlatformInfo = toml_val.try_into().unwrap();

        assert_eq!(parsed, info);
        assert!(!parsed.is_empty());
    }

    #[test]
    fn test_additional_artifacts_merge_integrity_for_same_urls() {
        let resolved = PlatformInfo {
            url: Some("https://example.com/tool.tar.gz".to_string()),
            additional_artifacts: vec![ArtifactInfo {
                url: "https://example.com/tool-rocm.tar.gz".to_string(),
                url_api: Some("https://api.example.com/rocm".to_string()),
                ..Default::default()
            }],
            ..Default::default()
        };
        let installed = PlatformInfo {
            url: resolved.url.clone(),
            additional_artifacts: vec![ArtifactInfo {
                checksum: Some("blake3:rocm".to_string()),
                url: "https://example.com/tool-rocm.tar.gz".to_string(),
                provenance: Some(ProvenanceType::Slsa { url: None }),
                ..Default::default()
            }],
            ..Default::default()
        };

        let merged = resolved.merge_with(&installed);
        assert_eq!(
            merged.additional_artifacts,
            vec![ArtifactInfo {
                checksum: Some("blake3:rocm".to_string()),
                url: "https://example.com/tool-rocm.tar.gz".to_string(),
                url_api: Some("https://api.example.com/rocm".to_string()),
                provenance: Some(ProvenanceType::Slsa { url: None }),
                ..Default::default()
            }]
        );
    }

    #[test]
    fn test_additional_artifacts_merge_preserves_longer_list() {
        let short = vec![ArtifactInfo {
            checksum: Some("sha256:base".to_string()),
            url: "https://example.com/base.tar.gz".to_string(),
            ..Default::default()
        }];
        let long = vec![
            ArtifactInfo {
                url: short[0].url.clone(),
                url_api: Some("https://api.example.com/base".to_string()),
                ..Default::default()
            },
            ArtifactInfo {
                checksum: Some("sha256:extra".to_string()),
                url: "https://example.com/extra.tar.gz".to_string(),
                ..Default::default()
            },
        ];
        let expected = vec![
            ArtifactInfo {
                checksum: short[0].checksum.clone(),
                url: short[0].url.clone(),
                url_api: long[0].url_api.clone(),
                ..Default::default()
            },
            long[1].clone(),
        ];

        assert_eq!(merge_additional_artifacts(&short, &long), expected);
        assert_eq!(merge_additional_artifacts(&long, &short), expected);
    }

    #[test]
    fn test_provenance_legacy_provenance_url_compat() {
        // Old lockfile format: provenance = "slsa" + provenance_url = "..."
        // Must go through TryFrom<toml::Value> which handles the legacy field
        let mut table = toml::Table::new();
        table.insert("provenance".to_string(), "slsa".into());
        table.insert(
            "provenance_url".to_string(),
            "https://example.com/tool.intoto.jsonl".into(),
        );
        let parsed = PlatformInfo::try_from(toml::Value::Table(table)).unwrap();
        assert!(parsed.provenance.as_ref().unwrap().is_slsa());
        match &parsed.provenance {
            Some(ProvenanceType::Slsa { url }) => {
                assert_eq!(
                    url.as_deref(),
                    Some("https://example.com/tool.intoto.jsonl")
                );
            }
            _ => panic!("expected Slsa provenance"),
        }
    }

    #[test]
    fn test_github_attestations_unavailable_legacy_parse_only() {
        let info = PlatformInfo {
            checksum: Some("sha256:abc123".to_string()),
            url: Some("https://example.com/tool.tar.gz".to_string()),
            github_attestations: Some(GithubAttestationsStatus::Unavailable),
            ..Default::default()
        };

        let toml_val: toml::Value = info.clone().into();
        let table = toml_val.as_table().unwrap();
        assert!(table.get("github_attestations").is_none());
        assert!(table.get("provenance").is_none());

        let mut legacy = table.clone();
        legacy.insert("github_attestations".to_string(), "unavailable".into());
        let parsed: PlatformInfo = toml::Value::Table(legacy).try_into().unwrap();
        assert_eq!(
            parsed.github_attestations,
            Some(GithubAttestationsStatus::Unavailable)
        );
        assert!(parsed.provenance.is_none());
        assert!(!parsed.has_checksum_and_verified_provenance());
    }

    #[test]
    fn test_github_attestations_unavailable_ignored_with_provenance() {
        let mut table = toml::Table::new();
        table.insert("checksum".to_string(), "sha256:abc123".into());
        table.insert("provenance".to_string(), "slsa".into());
        table.insert("github_attestations".to_string(), "unavailable".into());

        let parsed: PlatformInfo = toml::Value::Table(table.clone()).try_into().unwrap();
        assert!(parsed.provenance.as_ref().unwrap().is_slsa());
        assert!(parsed.github_attestations.is_none());
        assert!(!parsed.has_checksum_and_verified_provenance());

        table.insert("provenance_verified".to_string(), true.into());
        let parsed: PlatformInfo = toml::Value::Table(table).try_into().unwrap();
        assert!(parsed.has_checksum_and_verified_provenance());

        let serialized: toml::Value = PlatformInfo {
            checksum: Some("sha256:abc123".to_string()),
            provenance: Some(ProvenanceType::GithubAttestations),
            provenance_verified: true,
            github_attestations: Some(GithubAttestationsStatus::Unavailable),
            ..Default::default()
        }
        .into();
        let table = serialized.as_table().unwrap();
        assert!(table.get("provenance").is_some());
        assert_eq!(
            table.get("provenance_verified").and_then(|v| v.as_bool()),
            Some(true)
        );
        assert!(table.get("github_attestations").is_none());
    }

    #[test]
    fn test_detected_cross_platform_provenance_is_not_verified() {
        let detected = PlatformInfo {
            checksum: Some("sha256:detected".to_string()),
            provenance: Some(ProvenanceType::GithubAttestations),
            ..Default::default()
        };
        assert!(!detected.has_checksum_and_verified_provenance());

        let verified = PlatformInfo {
            provenance_verified: true,
            ..detected.clone()
        };
        assert!(verified.has_checksum_and_verified_provenance());

        let detected_artifact = ArtifactInfo {
            checksum: detected.checksum,
            url: "https://example.com/supplemental.tar.gz".to_string(),
            provenance: detected.provenance,
            ..Default::default()
        };
        assert!(!detected_artifact.has_checksum_and_verified_provenance());

        let verified_artifact = ArtifactInfo {
            provenance_verified: true,
            ..detected_artifact
        };
        assert!(verified_artifact.has_checksum_and_verified_provenance());
    }

    #[test]
    fn test_merge_provenance_state_prefers_verified() {
        let verified = Some(ProvenanceType::GithubAttestations);
        let detected = Some(ProvenanceType::Slsa { url: None });

        assert_eq!(
            merge_provenance_state(verified.clone(), true, detected.clone(), false),
            (verified.clone(), true)
        );
        assert_eq!(
            merge_provenance_state(detected, false, verified.clone(), true),
            (verified, true)
        );
    }

    #[test]
    fn test_provenance_merge_preserves_existing() {
        let with_provenance = PlatformInfo {
            provenance: Some(ProvenanceType::GithubAttestations),
            ..Default::default()
        };
        let without = PlatformInfo::default();

        // Merging with empty preserves provenance
        let merged = with_provenance.merge_with(&without);
        assert_eq!(merged.provenance, Some(ProvenanceType::GithubAttestations));

        // Merging empty (new) with provenance (old) preserves existing provenance
        let merged = without.merge_with(&with_provenance);
        assert_eq!(merged.provenance, Some(ProvenanceType::GithubAttestations));

        // Merging Slsa { url: None } with Slsa { url: Some(...) } preserves URL
        let with_url = PlatformInfo {
            provenance: Some(ProvenanceType::Slsa {
                url: Some("https://example.com/provenance.intoto.jsonl".to_string()),
            }),
            ..Default::default()
        };
        let without_url = PlatformInfo {
            provenance: Some(ProvenanceType::Slsa { url: None }),
            ..Default::default()
        };
        let merged = without_url.merge_with(&with_url);
        assert!(merged.provenance.as_ref().unwrap().is_slsa());
        match &merged.provenance {
            Some(ProvenanceType::Slsa { url }) => {
                assert_eq!(
                    url.as_deref(),
                    Some("https://example.com/provenance.intoto.jsonl")
                );
            }
            _ => panic!("expected Slsa provenance"),
        }
        // Also in reverse order
        let merged = with_url.merge_with(&without_url);
        match &merged.provenance {
            Some(ProvenanceType::Slsa { url }) => {
                assert_eq!(
                    url.as_deref(),
                    Some("https://example.com/provenance.intoto.jsonl")
                );
            }
            _ => panic!("expected Slsa provenance"),
        }

        // A negative GitHub attestation cache entry must not override verified provenance.
        let github_unavailable = PlatformInfo {
            github_attestations: Some(GithubAttestationsStatus::Unavailable),
            ..Default::default()
        };
        let merged = github_unavailable.merge_with(&with_url);
        assert!(merged.provenance.as_ref().unwrap().is_slsa());
        assert!(merged.github_attestations.is_none());
        let merged = with_url.merge_with(&github_unavailable);
        assert!(merged.provenance.as_ref().unwrap().is_slsa());
        assert!(merged.github_attestations.is_none());

        // Merging with empty drops the legacy negative GitHub attestation cache.
        let merged = github_unavailable.merge_with(&PlatformInfo::default());
        assert!(merged.github_attestations.is_none());
    }

    #[test]
    fn test_provenance_not_empty() {
        let info = PlatformInfo {
            provenance: Some(ProvenanceType::Slsa { url: None }),
            ..Default::default()
        };
        assert!(!info.is_empty());

        let info = PlatformInfo {
            github_attestations: Some(GithubAttestationsStatus::Unavailable),
            ..Default::default()
        };
        assert!(info.is_empty());
    }

    #[test]
    fn test_github_attestations_unavailable_counts_as_missing_for_regression() {
        let platform = Platform::current().to_key();
        let mut prior = basic_tool("1.0.0", "github:owner/repo");
        prior.platforms.insert(
            platform.clone(),
            PlatformInfo {
                provenance: Some(ProvenanceType::GithubAttestations),
                ..Default::default()
            },
        );
        let existing = vec![prior];

        let err = check_single_tool_provenance(
            Some(&existing),
            "tool",
            "1.1.0",
            "github:owner/repo",
            &platform,
            None,
        )
        .unwrap();
        assert!(err.contains("has no provenance verification"));

        let mut prior = basic_tool("1.0.0", "github:owner/repo");
        prior.platforms.insert(
            platform.clone(),
            PlatformInfo {
                github_attestations: Some(GithubAttestationsStatus::Unavailable),
                ..Default::default()
            },
        );
        let existing = vec![prior];
        assert!(
            check_single_tool_provenance(
                Some(&existing),
                "tool",
                "1.1.0",
                "github:owner/repo",
                &platform,
                None,
            )
            .is_none()
        );
    }

    #[test]
    fn test_provenance_regression_skips_unverified_already_installed_version() {
        // Reproduces #11225: upgrading a locked tool to a newer version that is
        // *already installed* on disk skips the download, so the new entry has no
        // platform info (no url, no provenance). That missing provenance means
        // "not checked", not "verified absent", and must NOT be flagged as a
        // supply-chain regression.
        let platform = Platform::current().to_key();

        let mut prior = basic_tool("0.11.0", "github:owner/repo");
        prior.platforms.insert(
            platform.clone(),
            PlatformInfo {
                url: Some("https://example.com/repo-0.11.0.tar.gz".to_string()),
                provenance: Some(ProvenanceType::GithubAttestations),
                ..Default::default()
            },
        );
        let mut existing = Lockfile::default();
        existing.tools.insert("repo".to_string(), vec![prior]);

        // New version with an empty platform map (nothing downloaded/verified).
        let new_tools: HashMap<String, Vec<LockfileTool>> = [(
            "repo".to_string(),
            vec![basic_tool("0.12.0", "github:owner/repo")],
        )]
        .into_iter()
        .collect();

        let check = check_provenance_regression(&existing, &new_tools);
        assert!(
            check.regressing.is_empty() && check.errors.is_empty(),
            "already-installed upgrade with no fresh verification must not regress: {:?}",
            check.errors
        );
        // ...but the prior provenance-bearing entry must be preserved as a baseline so
        // the auto-lock pass can still compare against it after the merge.
        let baselines = check
            .deferred_baselines
            .get("repo")
            .expect("baseline must be preserved for the unverified upgrade");
        assert!(
            baselines.iter().any(|t| t.version == "0.11.0"),
            "expected 0.11.0 baseline to be preserved, got {baselines:?}"
        );

        // The platform can also be present but still unverified when installation
        // populated an empty PlatformInfo. The URL predicate must defer this case too.
        let mut present_unverified = basic_tool("0.12.0", "github:owner/repo");
        present_unverified
            .platforms
            .insert(platform.clone(), PlatformInfo::default());
        let new_tools = HashMap::from([("repo".to_string(), vec![present_unverified])]);
        let check = check_provenance_regression(&existing, &new_tools);
        assert!(check.regressing.is_empty() && check.errors.is_empty());
        assert!(
            check
                .deferred_baselines
                .get("repo")
                .is_some_and(|baselines| baselines.iter().any(|t| t.version == "0.11.0")),
            "present-but-unverified platform must preserve the provenance baseline"
        );

        // Contrast: a freshly-installed version populates a url but genuinely has
        // no provenance — that is a real regression and must still be flagged.
        let mut fresh = basic_tool("0.12.0", "github:owner/repo");
        fresh.platforms.insert(
            platform.clone(),
            PlatformInfo {
                url: Some("https://example.com/repo-0.12.0.tar.gz".to_string()),
                provenance: None,
                ..Default::default()
            },
        );
        let new_tools: HashMap<String, Vec<LockfileTool>> =
            [("repo".to_string(), vec![fresh])].into_iter().collect();
        let check = check_provenance_regression(&existing, &new_tools);
        assert!(
            check.regressing.contains("repo") && !check.errors.is_empty(),
            "freshly-installed version that lost provenance must still regress"
        );
        assert!(
            check.deferred_baselines.is_empty(),
            "confirmed regression must not be deferred"
        );
    }

    #[test]
    fn test_deferred_resolution_failure_blocks_when_baseline_present() {
        // Models the auto-lock escalation gate (#11225): when an already-installed
        // upgrade could not be resolved/verified, a failure must fail-closed only if a
        // prior provenance-bearing version is still present as a baseline; otherwise it
        // stays best-effort. This tests the decision `find_provenance_regression_baseline`
        // drives in `auto_lock_new_versions`.
        let platform = Platform::current().to_key();

        let mut baseline = basic_tool("0.11.0", "github:owner/repo");
        baseline.platforms.insert(
            platform.clone(),
            PlatformInfo {
                url: Some("https://example.com/repo-0.11.0.tar.gz".to_string()),
                provenance: Some(ProvenanceType::GithubAttestations),
                ..Default::default()
            },
        );
        // The deferred, still-unverified new version (no url/provenance yet).
        let pending = basic_tool("0.12.0", "github:owner/repo");
        let tools = vec![baseline, pending];

        // A resolution failure for 0.12.0 must become the caller's blocking error.
        let err = deferred_provenance_resolution_error(
            Some(&tools),
            "repo",
            "0.12.0",
            "github:owner/repo",
            &platform,
            "network unavailable",
        )
        .expect("failure must fail-closed while the provenance baseline is present");
        assert!(err.contains("could not verify provenance for repo@0.12.0"));
        assert!(err.contains("network unavailable"));

        // Without a provenance-bearing prior version, a failure stays best-effort.
        let no_baseline = vec![
            basic_tool("0.11.0", "github:owner/repo"),
            basic_tool("0.12.0", "github:owner/repo"),
        ];
        assert!(
            deferred_provenance_resolution_error(
                Some(&no_baseline),
                "repo",
                "0.12.0",
                "github:owner/repo",
                &platform,
                "network unavailable",
            )
            .is_none(),
            "no baseline means a resolution failure should remain non-blocking"
        );
    }

    #[test]
    fn test_provenance_baseline_requires_exact_github_backend() {
        let platform = Platform::current().to_key();
        let mut prior = basic_tool("1.0.0", "github:owner/original");
        prior.platforms.insert(
            platform.clone(),
            PlatformInfo {
                provenance: Some(ProvenanceType::GithubAttestations),
                ..Default::default()
            },
        );
        let existing = vec![prior];

        assert!(
            check_single_tool_provenance(
                Some(&existing),
                "tool",
                "2.0.0",
                "github:owner/fork",
                &platform,
                None,
            )
            .is_none(),
            "an unrelated github repository must not supply the provenance baseline"
        );
    }

    #[test]
    fn test_provenance_regression_only_flags_known_semver_upgrades() {
        let platform = Platform::current().to_key();
        let mut prior = basic_tool("nightly", "github:owner/repo");
        prior.platforms.insert(
            platform.clone(),
            PlatformInfo {
                provenance: Some(ProvenanceType::GithubAttestations),
                ..Default::default()
            },
        );
        let existing = vec![prior];

        assert!(
            check_single_tool_provenance(
                Some(&existing),
                "tool",
                "v1.0.0",
                "github:owner/repo",
                &platform,
                None,
            )
            .is_none()
        );

        let mut prior = basic_tool("v1.0.0-rc.1", "github:owner/repo");
        prior.platforms.insert(
            platform.clone(),
            PlatformInfo {
                provenance: Some(ProvenanceType::GithubAttestations),
                ..Default::default()
            },
        );
        let existing = vec![prior];

        let err = check_single_tool_provenance(
            Some(&existing),
            "tool",
            "v1.0.0",
            "github:owner/repo",
            &platform,
            None,
        )
        .unwrap();
        assert!(err.contains("has no provenance verification"));
    }

    #[test]
    fn test_parse_provenance_version_strips_single_semver_v_prefix() {
        assert!(parse_provenance_version("v1.2.3").is_some());
        assert!(parse_provenance_version("V1.2.3").is_some());
        assert!(parse_provenance_version("vv1.2.3").is_none());
        assert!(parse_provenance_version("v").is_none());
    }

    #[test]
    fn test_set_platform_info_all_platforms_get_slsa_url() {
        // Regression guard: when all platforms have Slsa { url: Some(...) },
        // the lockfile should serialize ALL entries with the expanded form.
        let mut lockfile = Lockfile::default();
        let platforms = vec!["linux-x64", "linux-arm64", "macos-x64", "macos-arm64"];
        for platform in &platforms {
            lockfile.set_platform_info(
                "sops",
                "3.12.1",
                Some("aqua:getsops/sops"),
                &BTreeMap::new(),
                platform,
                PlatformInfo {
                    checksum: Some("sha256:abc123".to_string()),
                    url: Some(format!("https://example.com/sops-{platform}.tar.gz")),
                    provenance: Some(ProvenanceType::Slsa {
                        url: Some(format!("https://example.com/sops-{platform}.intoto.jsonl")),
                    }),
                    ..Default::default()
                },
            );
        }
        let temp_dir = std::env::temp_dir();
        let test_lockfile = temp_dir.join("test_provenance_all_platforms.lock");
        lockfile.save(&test_lockfile).unwrap();
        let serialized = std::fs::read_to_string(&test_lockfile).unwrap();
        let _ = std::fs::remove_file(&test_lockfile);
        // ALL platform entries should have the expanded provenance.slsa form
        for platform in &platforms {
            assert!(
                serialized.contains(&format!("\"platforms.{platform}\".provenance.slsa")),
                "platform {platform} should have expanded provenance.slsa form, got:\n{serialized}"
            );
        }
        // No short-form provenance should appear
        assert!(
            !serialized.contains("provenance = \"slsa\""),
            "no short-form provenance should appear, got:\n{serialized}"
        );
    }

    #[test]
    fn test_set_platform_info_none_provenance_preserves_existing_url() {
        // When new PlatformInfo has provenance=None, existing Slsa URL should be preserved
        let mut lockfile = Lockfile::default();
        // First: set platform info with Slsa URL
        lockfile.set_platform_info(
            "sops",
            "3.12.1",
            Some("aqua:getsops/sops"),
            &BTreeMap::new(),
            "linux-x64",
            PlatformInfo {
                checksum: Some("sha256:abc123".to_string()),
                url: Some("https://example.com/sops.tar.gz".to_string()),
                provenance: Some(ProvenanceType::Slsa {
                    url: Some("https://example.com/sops.intoto.jsonl".to_string()),
                }),
                ..Default::default()
            },
        );
        // Second: set same platform with provenance=None (simulates verification failure)
        lockfile.set_platform_info(
            "sops",
            "3.12.1",
            Some("aqua:getsops/sops"),
            &BTreeMap::new(),
            "linux-x64",
            PlatformInfo {
                checksum: Some("sha256:abc123".to_string()),
                url: Some("https://example.com/sops.tar.gz".to_string()),
                provenance: None,
                ..Default::default()
            },
        );
        // The existing Slsa URL should be preserved by merge
        let tool = &lockfile.tools["sops"][0];
        let info = &tool.platforms["linux-x64"];
        match &info.provenance {
            Some(ProvenanceType::Slsa { url }) => {
                assert_eq!(
                    url.as_deref(),
                    Some("https://example.com/sops.intoto.jsonl")
                );
            }
            other => panic!("expected Slsa provenance with URL, got: {other:?}"),
        }
    }

    #[test]
    fn test_set_platform_info_replaces_artifact_with_source_outcome() {
        let mut lockfile = Lockfile::default();
        lockfile.set_platform_info(
            "node",
            "24.0.0",
            Some("core:node"),
            &BTreeMap::new(),
            "linux-x64",
            PlatformInfo {
                checksum: Some("sha256:OLD".to_string()),
                size: Some(123),
                url: Some("https://example.com/binary.tar.gz".to_string()),
                url_api: Some("https://api.example.com/binary".to_string()),
                ..Default::default()
            },
        );
        lockfile.set_platform_info(
            "node",
            "24.0.0",
            Some("core:node"),
            &BTreeMap::new(),
            "linux-x64",
            PlatformInfo {
                install: Some("source".to_string()),
                checksum: Some("sha256:NEW".to_string()),
                url: Some("https://example.com/source.tar.gz".to_string()),
                ..Default::default()
            },
        );

        let info = &lockfile.tools["node"][0].platforms["linux-x64"];
        assert_eq!(info.install.as_deref(), Some("source"));
        assert_eq!(info.checksum.as_deref(), Some("sha256:NEW"));
        assert_eq!(info.size, None);
        assert_eq!(
            info.url.as_deref(),
            Some("https://example.com/source.tar.gz")
        );
        assert_eq!(info.url_api, None);
    }

    #[test]
    fn test_set_platform_info_replaces_source_outcome_with_artifact() {
        let mut lockfile = Lockfile::default();
        lockfile.set_platform_info(
            "node",
            "24.0.0",
            Some("core:node"),
            &BTreeMap::new(),
            "linux-x64",
            PlatformInfo {
                install: Some("source".to_string()),
                checksum: Some("sha256:OLD".to_string()),
                url: Some("https://example.com/source.tar.gz".to_string()),
                ..Default::default()
            },
        );
        lockfile.set_platform_info(
            "node",
            "24.0.0",
            Some("core:node"),
            &BTreeMap::new(),
            "linux-x64",
            PlatformInfo {
                checksum: Some("sha256:NEW".to_string()),
                url: Some("https://example.com/binary.tar.gz".to_string()),
                ..Default::default()
            },
        );

        let info = &lockfile.tools["node"][0].platforms["linux-x64"];
        assert_eq!(info.install, None);
        assert_eq!(info.checksum.as_deref(), Some("sha256:NEW"));
        assert_eq!(
            info.url.as_deref(),
            Some("https://example.com/binary.tar.gz")
        );
    }

    #[test]
    fn test_set_platform_info_keeps_platform_specific_option_variants_separate() {
        let mut lockfile = Lockfile::default();
        lockfile.set_platform_info(
            "ruby",
            "3.4.2",
            Some("core:ruby"),
            &BTreeMap::new(),
            "windows-x64",
            PlatformInfo {
                url: Some("https://example.com/ruby-windows.7z".to_string()),
                ..Default::default()
            },
        );
        let unix_options = BTreeMap::from([("compile".to_string(), "false".to_string())]);
        lockfile.set_platform_info(
            "ruby",
            "3.4.2",
            Some("core:ruby"),
            &unix_options,
            "linux-x64",
            PlatformInfo {
                url: Some("https://example.com/ruby-linux.tar.gz".to_string()),
                ..Default::default()
            },
        );

        let tools = &lockfile.tools["ruby"];
        assert_eq!(tools.len(), 2, "option variants were conflated: {tools:?}");
        assert!(
            tools.iter().any(|tool| {
                tool.options.is_empty() && tool.platforms.contains_key("windows-x64")
            })
        );
        assert!(tools.iter().any(|tool| {
            tool.options == unix_options && tool.platforms.contains_key("linux-x64")
        }));
    }

    #[test]
    fn test_set_platform_info_keeps_same_platform_sibling_variants() {
        let mut lockfile = Lockfile::default();
        let empty_options = BTreeMap::new();
        let options_a = BTreeMap::from([("flavor".to_string(), "a".to_string())]);
        let options_b = BTreeMap::from([("flavor".to_string(), "b".to_string())]);
        for options in [&empty_options, &options_a, &options_b] {
            lockfile.set_platform_info(
                "example",
                "1.0.0",
                Some("example:tool"),
                options,
                "linux-x64",
                PlatformInfo {
                    checksum: Some(format!(
                        "sha256:{}",
                        options.get("flavor").map(String::as_str).unwrap_or("empty")
                    )),
                    ..Default::default()
                },
            );
        }

        let tools = &lockfile.tools["example"];
        assert_eq!(tools.len(), 3);
        assert!(
            tools
                .iter()
                .all(|tool| tool.platforms.contains_key("linux-x64"))
        );
    }

    #[test]
    fn test_source_platform_info_serializes_as_lock_marker() {
        let mut lockfile = Lockfile::default();
        lockfile.set_platform_info(
            "node",
            "24.0.0",
            Some("core:node"),
            &BTreeMap::new(),
            "linux-riscv64",
            PlatformInfo {
                install: Some("source".to_string()),
                checksum: Some("sha256:SOURCE".to_string()),
                url: Some("https://example.com/node-v24.0.0.tar.gz".to_string()),
                ..Default::default()
            },
        );

        let temp_dir = tempfile::tempdir().unwrap();
        let test_lockfile = temp_dir.path().join("source-platform-marker.lock");
        lockfile.write(&test_lockfile).unwrap();
        let serialized = file::read_to_string(&test_lockfile).unwrap();

        assert!(serialized.contains("platforms.linux-riscv64"));
        assert!(serialized.contains("install = \"source\""));
        assert!(serialized.contains("checksum = \"sha256:SOURCE\""));
        assert!(serialized.contains("url = \"https://example.com/node-v24.0.0.tar.gz\""));
        assert!(!serialized.contains("node-v24.0.0-linux-riscv64.tar.gz"));

        let reloaded = Lockfile::read(&test_lockfile).unwrap();
        let info = &reloaded.tools["node"][0].platforms["linux-riscv64"];
        assert_eq!(info.install.as_deref(), Some("source"));
        assert_eq!(info.checksum.as_deref(), Some("sha256:SOURCE"));
        assert_eq!(
            info.url.as_deref(),
            Some("https://example.com/node-v24.0.0.tar.gz")
        );
    }

    #[test]
    fn test_determine_target_platforms_respects_pre_install_keys() {
        // Regression: auto-lock must not silently re-add platforms (e.g. macos-x64) that
        // a user has dropped. The pre-install snapshot is authoritative when non-empty.
        let pre: BTreeSet<String> = ["linux-arm64", "windows-x64"]
            .iter()
            .map(|s| s.to_string())
            .collect();

        let platforms = determine_target_platforms_from_lockfile(&pre).unwrap();
        let keys: BTreeSet<String> = platforms.iter().map(|p| p.to_key()).collect();

        assert!(keys.contains("linux-arm64"));
        assert!(keys.contains("windows-x64"));
        assert!(keys.contains(&Platform::current().to_key()));
        // macos-x64 / macos-arm64 / linux-x64-musl etc. must not leak in unless current.
        for extra in ["macos-x64", "macos-arm64"] {
            if extra != Platform::current().to_key() {
                assert!(
                    !keys.contains(extra),
                    "{extra} leaked into target platforms: {keys:?}"
                );
            }
        }
    }

    #[test]
    fn test_determine_target_platforms_one_platform_lockfile_is_authoritative() {
        // A user can intentionally maintain a single-platform lockfile. As long as the
        // pre-install snapshot was non-empty, that intent must be respected — even if
        // the only entry happens to be the current platform.
        let current = Platform::current().to_key();
        let pre: BTreeSet<String> = std::iter::once(current.clone()).collect();

        let platforms = determine_target_platforms_from_lockfile(&pre).unwrap();
        let keys: BTreeSet<String> = platforms.iter().map(|p| p.to_key()).collect();

        assert_eq!(keys.len(), 1, "expected only {current}, got {keys:?}");
        assert!(keys.contains(&current));
    }

    #[test]
    fn test_determine_target_platforms_expands_for_fresh_lockfile() {
        // A fresh lockfile (no entries before this install) must expand to the common
        // platform set so first-install gets cross-platform-locked.
        let pre: BTreeSet<String> = BTreeSet::new();

        let platforms = determine_target_platforms_from_lockfile(&pre).unwrap();
        let keys: BTreeSet<String> = platforms.iter().map(|p| p.to_key()).collect();

        for common in Platform::common_platforms() {
            assert!(
                keys.contains(&common.to_key()),
                "{} missing from fresh-lockfile target set: {keys:?}",
                common.to_key()
            );
        }
    }
}
