use std::fmt::{Display, Formatter};
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Component, Path, PathBuf};
use std::{cmp::Ordering, sync::LazyLock};
use std::{collections::BTreeMap, sync::Arc};

use crate::backend::{ABackend, VersionInfo};
use crate::cli::args::BackendArg;
use crate::config::env_directive::EnvValue;
use crate::config::{Config, Settings};
#[cfg(windows)]
use crate::file;
use crate::hash::hash_to_str;
use crate::install_before::{BeforeDateSource, resolve_before_date_for_tool_with_source};
use crate::lockfile::{CondaPackageInfo, LockfileTool, PkgxPackageInfo, PlatformInfo};
use crate::runtime_symlinks::is_runtime_symlink;
use crate::toolset::{ToolRequest, ToolSource, ToolVersionOptions, tool_request};
use crate::{dirs, env};
use console::style;
use dashmap::DashMap;
use eyre::{Result, bail};
use indexmap::IndexMap;
use jiff::Timestamp;
#[cfg(windows)]
use path_absolutize::Absolutize;

static INSTALL_PATH_CACHE: LazyLock<DashMap<ToolVersion, PathBuf>> = LazyLock::new(DashMap::new);

/// Clear the install_path cache. Called when install state is reset
/// to avoid stale paths (e.g. shared dir paths after a new install).
pub fn reset_install_path_cache() {
    INSTALL_PATH_CACHE.clear();
}

/// represents a single version of a tool for a particular plugin
#[derive(Debug, Clone)]
pub struct ToolVersion {
    pub request: ToolRequest,
    pub version: String,
    /// Effective install-before cutoff used to resolve this version.
    pub before_date: Option<Timestamp>,
    locked: bool,
    resolved_from_lockfile: bool,
    pub lock_platforms: BTreeMap<String, PlatformInfo>,
    pub install_path: Option<PathBuf>,
    /// The HTTP backend should treat `install_path` as the final destination
    /// rather than a `<tool>/<version>` path. Used by install-into.
    pub(crate) install_path_is_exact: bool,
    /// The HTTP backend should place files directly in `install_path` instead
    /// of linking to its shared extraction cache. Set for system, shared, and
    /// install-into destinations.
    pub(crate) install_path_is_explicit: bool,
    /// Conda packages resolved during installation: (platform, basename) -> CondaPackageInfo
    pub conda_packages: BTreeMap<(String, String), CondaPackageInfo>,
    /// pkgx packages resolved during installation: (platform, package@version) -> PkgxPackageInfo
    pub pkgx_packages: BTreeMap<(String, String), PkgxPackageInfo>,
    /// Install satisfaction computed during dry-run installs.
    pub install_satisfied: Option<bool>,
}

impl ToolVersion {
    fn no_versions_found(backend: &ABackend, before_date: Option<Timestamp>) -> eyre::Report {
        let id = backend.id();
        // The version list is also empty when fetching it failed, in which case
        // no filter was ever applied — blaming the date filter sends users
        // looking for a cutoff that isn't the problem.
        if let Some(cause) = crate::backend::version_listing_failure(backend.ba()) {
            return eyre::eyre!("unable to fetch versions for {id}: {cause}");
        }
        let msg = if before_date.is_some() {
            format!("no versions found for {id} matching date filter")
        } else {
            format!("no versions found for {id}")
        };
        eyre::eyre!(msg)
    }

    pub fn new(request: ToolRequest, version: String) -> Self {
        ToolVersion {
            request,
            version,
            before_date: None,
            locked: false,
            resolved_from_lockfile: false,
            lock_platforms: Default::default(),
            install_path: None,
            install_path_is_exact: false,
            install_path_is_explicit: false,
            conda_packages: Default::default(),
            pkgx_packages: Default::default(),
            install_satisfied: None,
        }
    }

    pub async fn resolve(
        config: &Arc<Config>,
        request: ToolRequest,
        opts: &ResolveOptions,
    ) -> Result<Self> {
        let minimum_release_age = request.options().minimum_release_age().map(str::to_string);
        let mut opts = opts.clone();
        opts.apply_before_date_for_tool(request.ba(), minimum_release_age.as_deref())?;

        trace!("resolving {} {}", &request, opts);
        if opts.use_locked_version
            && !has_linked_version(request.ba())
            && let Some(lt) = request.lockfile_resolve(config)?
        {
            return Ok(Self::from_lockfile(request.clone(), lt).with_before_date(opts.before_date));
        }
        let backend = request.ba().backend()?;
        if let Some(plugin) = backend.plugin()
            && !plugin.is_installed()
        {
            let tv = Self::new(request.clone(), request.version());
            return Ok(tv.with_before_date(opts.before_date));
        }
        let tv = match request.clone() {
            ToolRequest::Version { version: v, .. } => {
                Self::resolve_version(config, request, &v, &opts).await?
            }
            ToolRequest::Prefix { prefix, .. } => {
                Self::resolve_prefix(config, request, &prefix, &opts).await?
            }
            ToolRequest::Sub {
                sub, orig_version, ..
            } => Self::resolve_sub(config, request, &sub, &orig_version, &opts).await?,
            _ => {
                let version = request.version();
                Self::new(request, version)
            }
        };
        let tv = tv.with_before_date(opts.before_date);
        trace!("resolved: {tv}");
        Ok(tv)
    }

    fn with_before_date(mut self, before_date: Option<Timestamp>) -> Self {
        self.before_date = before_date;
        self
    }

    /// Returns a copy locked to the exact resolved version, so `runtime_path()`
    /// resolves to `install_path()` rather than a fuzzy runtime symlink (e.g.
    /// `installs/python/3`). Used during postinstall so the hook sees the version
    /// just installed, not a stale symlink target (#10347).
    pub(crate) fn with_locked(mut self) -> Self {
        self.locked = true;
        self
    }

    fn from_lockfile(mut request: ToolRequest, lt: LockfileTool) -> Self {
        if let Some(backend_full) = &lt.backend {
            let backend = Arc::new(BackendArg::new(
                request.ba().short.clone(),
                Some(backend_full.clone()),
            ));
            match &mut request {
                ToolRequest::Version { backend: b, .. }
                | ToolRequest::Prefix { backend: b, .. }
                | ToolRequest::Ref { backend: b, .. }
                | ToolRequest::Sub { backend: b, .. }
                | ToolRequest::Path { backend: b, .. }
                | ToolRequest::System { backend: b, .. } => *b = backend,
            }
        }
        // Lockfile options have already selected the matching lock entry; keep
        // the original request options so config/core install behavior survives.
        let mut tv = Self::new(request, lt.version);
        tv.locked = true;
        tv.resolved_from_lockfile = true;
        tv.lock_platforms = lt.platforms;
        tv
    }

    pub(crate) fn resolved_from_lockfile(&self) -> bool {
        self.resolved_from_lockfile
    }

    pub fn ba(&self) -> &BackendArg {
        self.request.ba()
    }

    pub fn backend(&self) -> Result<ABackend> {
        self.ba().backend()
    }

    pub fn short(&self) -> &str {
        &self.ba().short
    }

    pub fn install_path(&self) -> PathBuf {
        if let Some(p) = &self.install_path {
            return p.clone();
        }
        if let Some(p) = INSTALL_PATH_CACHE.get(self) {
            return p.clone();
        }
        let pathname = match &self.request {
            ToolRequest::Path { path: p, .. } => p.to_string_lossy().to_string(),
            _ => self.tv_pathname(),
        };
        let path = self.ba().installs_path.join(&pathname);

        // handle non-symlinks on windows
        // TODO: make this a utility function in xx
        #[cfg(windows)]
        if path.is_file()
            && let Ok(p) = file::read_to_string(&path).map(PathBuf::from)
        {
            let path = self.ba().installs_path.join(p);
            if path.exists() {
                return path
                    .absolutize()
                    .expect("failed to absolutize path")
                    .to_path_buf();
            }
        }

        // Check shared install directories if the primary path doesn't exist
        let path = if matches!(&self.request, ToolRequest::Path { .. }) {
            path
        } else {
            env::find_in_shared_installs(path, &self.ba().tool_dir_name(), &pathname)
        };

        // Only cache the resolved path if it actually exists on disk. Otherwise
        // the answer may change once the tool installs into a different
        // location (e.g. a shared install dir created mid-run by `--system` /
        // `--shared`), and the stale cache entry would be returned to callers
        // like core go's `exec_env`, sending wrong values for GOROOT/GOPATH.
        if path.exists() {
            INSTALL_PATH_CACHE.insert(self.clone(), path.clone());
        }
        path
    }

    pub fn install_env(&self) -> IndexMap<String, EnvValue> {
        self.request.options().core.install_env
    }

    pub fn runtime_path(&self) -> PathBuf {
        if self.locked {
            return self.install_path();
        }
        let Some(pathname) = self.runtime_pathname() else {
            return self.install_path();
        };
        let path = self.ba().installs_path.join(&pathname);
        let path = env::find_in_shared_installs(path, &self.ba().tool_dir_name(), &pathname);
        if path.is_dir() && is_runtime_symlink(&path) {
            return path;
        }

        #[cfg(windows)]
        if path.is_file()
            && is_runtime_symlink(&path)
            && let Ok(Some(target)) = file::resolve_symlink(&path)
            && let Some(parent) = path.parent()
        {
            let target = parent.join(target);
            if target.is_dir() {
                return target
                    .absolutize()
                    .expect("failed to absolutize path")
                    .to_path_buf();
            }
        }

        self.install_path()
    }
    pub fn cache_path(&self) -> PathBuf {
        self.ba().cache_path.join(self.tv_pathname())
    }
    pub fn download_path(&self) -> PathBuf {
        self.request.ba().downloads_path.join(self.tv_pathname())
    }
    pub async fn latest_version(&self, config: &Arc<Config>) -> Result<String> {
        self.latest_version_with_opts(config, &ResolveOptions::default())
            .await
    }

    pub async fn latest_version_with_opts(
        &self,
        config: &Arc<Config>,
        base_opts: &ResolveOptions,
    ) -> Result<String> {
        // Note: We always use latest_versions=true and use_locked_version=false for latest version lookup,
        // but we preserve before_date from base_opts to respect date-based filtering
        let opts = ResolveOptions {
            latest_versions: true,
            use_locked_version: false,
            resolve_rolling_channels: false,
            prefer_exact_version: false,
            before_date: base_opts.before_date,
            before_date_from_default: base_opts.before_date_from_default,
            filter_installed_versions_by_release_date: base_opts
                .filter_installed_versions_by_release_date,
            offline: base_opts.offline,
            refresh_remote_versions: base_opts.refresh_remote_versions,
            inactive: base_opts.inactive,
        };
        let tv = self.request.resolve(config, &opts).await?;
        Ok(tv.version)
    }
    pub fn style(&self) -> String {
        format!(
            "{}{}",
            style(&self.ba().short).blue().for_stderr(),
            style(&format!("@{}", self.version)).for_stderr()
        )
    }
    pub fn tv_pathname(&self) -> String {
        match &self.request {
            ToolRequest::Version { .. } => self.version.to_string(),
            ToolRequest::Prefix { .. } => self.version.to_string(),
            ToolRequest::Sub { .. } => self.version.to_string(),
            ToolRequest::Ref {
                ref_: r, ref_type, ..
            } => format!("{ref_type}-{r}"),
            ToolRequest::Path { path: p, .. } => format!("path-{}", hash_to_str(p)),
            ToolRequest::System { .. } => {
                // Only show deprecation warning if not from .tool-versions file
                if !matches!(
                    self.request.source(),
                    crate::toolset::ToolSource::ToolVersions(_)
                ) {
                    deprecated!(
                        "system_tool_version",
                        "@system is deprecated, use MISE_DISABLE_TOOLS instead"
                    );
                }
                "system".to_string()
            }
        }
        .replace([':', '/'], "-")
    }
    pub(crate) fn runtime_pathname(&self) -> Option<String> {
        let pathname = match &self.request {
            ToolRequest::Version { version, .. } if version != &self.version => version.clone(),
            ToolRequest::Prefix { prefix, .. } => prefix.clone(),
            ToolRequest::Sub { .. } if self.request.version() != self.version => {
                self.request.version()
            }
            _ => return None,
        };
        Some(pathname.replace([':', '/'], "-"))
    }
    async fn resolve_version(
        config: &Arc<Config>,
        request: ToolRequest,
        v: &str,
        opts: &ResolveOptions,
    ) -> Result<ToolVersion> {
        let backend = request.backend()?;
        let v = config.resolve_alias(&backend, v).await?;

        // Re-check the lockfile after alias resolution (e.g., "lts" → "24")
        // The initial lockfile check in resolve() uses the unresolved alias which
        // won't match lockfile entries like "24.13.0".starts_with("lts")
        // If the alias resolved to a `prefix:` selector, match on the bare
        // prefix with separator-boundary matching — the lockfile stores
        // concrete versions (#5781). Config-level `prefix:...` strings always
        // parse into ToolRequest::Prefix, so a Version request only carries
        // this scheme via an alias.
        let (lock_query, lock_prefix_boundary) = match v.strip_prefix("prefix:") {
            Some(p) => (p, true),
            None => (v.as_str(), false),
        };
        if opts.use_locked_version
            && !has_linked_version(request.ba())
            && let Some(lt) =
                request.lockfile_resolve_with_prefix(config, lock_query, lock_prefix_boundary)?
        {
            return Ok(Self::from_lockfile(request.clone(), lt));
        }
        let settings = Settings::get();
        let tool_config_locked = config.tool_config_locked(request.source());
        if (settings.locked || tool_config_locked)
            && opts.use_locked_version
            && settings.lockfile_enabled()
            && !has_linked_version(request.ba())
            && request.source().path().is_some()
        {
            let hint = if tool_config_locked && !settings.locked {
                "Run `mise lock` to update the lockfile, or disable `tool_config.locked`"
            } else {
                "Run `mise install` without --locked to update the lockfile"
            };
            bail!(
                "{}@{} is not in the lockfile\nhint: {hint}",
                request.ba().short,
                request.version()
            );
        }

        match v.split_once(':') {
            Some((ref_type @ ("ref" | "tag" | "branch" | "rev"), r)) => {
                return Ok(Self::resolve_ref(
                    r.to_string(),
                    ref_type.to_string(),
                    request.options(),
                    &request,
                ));
            }
            Some(("path", p)) => {
                return Self::resolve_path(PathBuf::from(p), &request);
            }
            Some(("prefix", p)) => {
                return Self::resolve_prefix(config, request, p, opts).await;
            }
            Some((part, v)) if part.starts_with("sub-") => {
                let sub = part.split_once('-').unwrap().1;
                return Self::resolve_sub(config, request, sub, v, opts).await;
            }
            _ => (),
        }

        let build = |v| Ok(Self::new(request.clone(), v));

        if v.matches('.').count() >= 2 {
            // Fully-qualified requests can return through several offline and
            // backend-specific shortcuts, so validate backend-gated options
            // before any of those paths bypass version listing.
            backend.version_order(&request.options())?;
        }

        let settings = Settings::get();
        let is_offline = settings.offline() || opts.offline;
        let prefer_offline =
            settings.prefer_offline() && !matches!(request.source(), ToolSource::Argument);
        let should_filter_installed_versions = opts.filter_installed_versions_by_release_date
            && opts.before_date.is_some()
            && !opts.before_date_from_default
            && !is_offline
            && !prefer_offline;
        // Rolling release channels (e.g. zig's "master") are moving pointers that
        // mise must resolve before the plugin-installed shortcut can preserve their
        // symbolic name as an install identity.
        if backend.is_rolling_channel(&v) {
            if !opts.latest_versions
                && !opts.resolve_rolling_channels
                && !should_filter_installed_versions
                && let Some(installed) = backend.latest_installed_channel_version(&v)
            {
                return build(installed);
            }
            if !is_offline {
                match backend.resolve_channel_version(config, &v).await {
                    Ok(Some(concrete)) => return build(concrete),
                    Ok(None) => {}
                    Err(source) if backend.requires_concrete_channel_version(&v) => {
                        return Err(source.wrap_err(
                            crate::errors::Error::RequiredChannelResolution {
                                backend: Box::new(backend.ba().as_ref().clone()),
                                version: v.clone(),
                            },
                        ));
                    }
                    Err(source) => {
                        debug!(
                            "{backend}: failed to resolve optional rolling channel {v}: {source:#}"
                        );
                    }
                }
            }
            if opts.offline {
                return build(v);
            }
            if is_offline && backend.requires_concrete_channel_version(&v) {
                let err = crate::errors::Error::RequiredChannelResolution {
                    backend: Box::new(backend.ba().as_ref().clone()),
                    version: v.clone(),
                };
                return Err(eyre::Report::new(err).wrap_err(format!(
                    "cannot resolve rolling channel {backend}@{v} while offline; create a lockfile or install a concrete channel version first"
                )));
            }
            // Backends whose channel resolver is best-effort can still fall through
            // to their legacy symbolic-channel resolution.
        }

        if let Some(plugin) = backend.plugin()
            && !plugin.is_installed()
        {
            return build(v);
        }

        if v == "latest" {
            if !opts.latest_versions
                && !should_filter_installed_versions
                && let Some(v) = backend.latest_installed_version(None)?
            {
                return build(v);
            }
            if !is_offline
                && let Some(v) = backend
                    .latest_version_with_refresh(
                        config,
                        None,
                        opts.before_date,
                        opts.refresh_remote_versions,
                    )
                    .await?
            {
                return build(v);
            }
            if !is_offline {
                let versions = backend
                    .list_remote_versions_with_refresh(config, opts.refresh_remote_versions)
                    .await?;
                if versions.is_empty()
                    && let Some(v) = backend.unresolved_latest_version()
                {
                    return build(v);
                }
            }
            // Prune-style offline (opts.offline) wants a non-erroring no-op
            // when nothing is installed — the literal "latest" can't match
            // any installed pathname so it's safe. Global MISE_OFFLINE keeps
            // the original error to avoid surprising upgrade/outdated callers.
            if opts.offline {
                return build(v);
            }
            return Err(Self::no_versions_found(&backend, opts.before_date));
        }
        let installed_matches =
            (!opts.latest_versions).then(|| backend.list_installed_versions_matching(&v));
        if installed_matches
            .as_ref()
            .is_some_and(|matches| matches.contains(&v))
        {
            return build(v);
        }

        let mut pin_remote_matches = None;
        if opts.prefer_exact_version && !is_offline && !crate::semver::is_npm_semver_range_query(&v)
        {
            // Pinning must prove that the exact release exists before it
            // bypasses an installed fuzzy match. Some backend exact resolvers
            // only validate syntax and defer existence checks to installation.
            let matches = backend
                .list_versions_matching_with_opts(config, &v, None, opts.refresh_remote_versions)
                .await?;
            if matches.contains(&v) {
                return build(v);
            }
            if opts.before_date.is_none() {
                pin_remote_matches = Some(matches);
            }
        }
        if !should_filter_installed_versions
            && let Some(v) = installed_matches
                .as_ref()
                .and_then(|matches| matches.last())
        {
            return build(v.clone());
        }
        if matches!(
            request.source(),
            ToolSource::IdiomaticVersionFile(path)
                if crate::config::config_file::idiomatic_version::package_json::is_package_json(path)
        ) && crate::semver::is_npm_semver_range_query(&v)
        {
            if !opts.latest_versions && !should_filter_installed_versions {
                let installed_versions = backend.list_installed_versions();
                if let Some(matches) =
                    crate::semver::npm_semver_range_filter(&installed_versions, &v)
                    && let Some(v) = matches.last()
                {
                    return build(v.clone());
                }
            }
            if !is_offline {
                let versions = match opts.before_date {
                    Some(before) => {
                        let versions_with_info = backend
                            .list_remote_versions_with_info_with_refresh(
                                config,
                                opts.refresh_remote_versions,
                            )
                            .await?;
                        VersionInfo::filter_by_date(versions_with_info, before)
                            .into_iter()
                            .map(|v| v.version)
                            .collect()
                    }
                    None => {
                        backend
                            .list_remote_versions_with_refresh(config, opts.refresh_remote_versions)
                            .await?
                    }
                };
                if let Some(matches) = crate::semver::npm_semver_range_filter(&versions, &v)
                    && let Some(v) = matches.last()
                {
                    return build(v.clone());
                }
            }
        }
        // When OFFLINE, skip ALL remote version fetching regardless of version format
        if is_offline {
            return build(v);
        }
        // In prefer-offline mode (hook-env, activate, exec), skip remote version
        // fetching for fully-qualified versions (e.g. "2.3.2") that aren't installed.
        // Prefix versions like "2" still need remote resolution to find e.g. "2.1.0".
        // "latest" also needs remote resolution but is handled in the block above.
        if prefer_offline && !opts.latest_versions && v.matches('.').count() >= 2 {
            return build(v);
        }
        // Exact pinned versions do not need the full versions list when the
        // backend can validate them directly or defer validation to its
        // installer. This also keeps explicit pins outside release-age
        // filtering. If the backend returns None, fall through to normal
        // prefix resolution so requests like "1.2.3" can still resolve to
        // "1.2.3.4" when that is the latest matching version.
        if (v.matches('.').count() >= 2 || backend.is_exact_version(&v))
            && let Some(v) = backend.resolve_exact_version(config, &v).await?
        {
            return build(v);
        }
        // First try with date filter (common case)
        let matches = match pin_remote_matches {
            Some(matches) => matches,
            None => {
                backend
                    .list_versions_matching_with_opts(
                        config,
                        &v,
                        opts.before_date,
                        opts.refresh_remote_versions,
                    )
                    .await?
            }
        };
        if matches.contains(&v) {
            return build(v);
        }
        if let Some(v) = matches.last() {
            return build(v.clone());
        }
        // If date filter is active and exact version not found, check without filter.
        // Explicit pinned versions like "22.5.0" should not be filtered by date.
        if opts.before_date.is_some() {
            let all_versions = backend.list_versions_matching(config, &v).await?;
            if all_versions.contains(&v) {
                // Exact match exists but was filtered by date - use it anyway
                return build(v);
            }
        }
        build(v)
    }

    /// resolve a version like `sub-1:12.0.0` which becomes `11.0.0`, `sub-0.1:12.1.0` becomes `12.0.0`
    async fn resolve_sub(
        config: &Arc<Config>,
        request: ToolRequest,
        sub: &str,
        v: &str,
        opts: &ResolveOptions,
    ) -> Result<Self> {
        let backend = request.backend()?;
        if v == "latest" && opts.offline {
            let pathname = request.version().replace([':', '/'], "-");
            let path = backend.ba().installs_path.join(&pathname);
            let path = env::find_in_shared_installs(path, &backend.ba().tool_dir_name(), &pathname);
            if let Ok(Some(target)) = crate::file::resolve_symlink(&path)
                && target.starts_with("./")
                && let Some(version) = target.file_name().map(|v| v.to_string_lossy().to_string())
            {
                return Box::pin(Self::resolve_version(config, request, &version, opts)).await;
            }
            // Can't resolve sub-N:latest offline (no remote latest, and
            // applying version_sub to latest_installed_version would shift
            // one step too low). Return the raw spec; callers that care
            // (`get_versions_needed_by_tracked_configs`) over-protect by
            // keeping all installed versions of this backend.
            let version = request.version();
            return Ok(Self::new(request, version));
        }
        let v = resolve_sub_base(
            config,
            &backend,
            sub,
            v,
            opts.before_date,
            opts.refresh_remote_versions,
        )
        .await?;
        Box::pin(Self::resolve_version(config, request, &v, opts)).await
    }

    async fn resolve_prefix(
        config: &Arc<Config>,
        request: ToolRequest,
        prefix: &str,
        opts: &ResolveOptions,
    ) -> Result<Self> {
        let backend = request.backend()?;
        let settings = Settings::get();
        let is_offline = settings.offline() || opts.offline;
        let prefer_offline =
            settings.prefer_offline() && !matches!(request.source(), ToolSource::Argument);
        let should_filter_installed_versions = opts.filter_installed_versions_by_release_date
            && opts.before_date.is_some()
            && !opts.before_date_from_default
            && !is_offline
            && !prefer_offline;
        if !opts.latest_versions
            && !should_filter_installed_versions
            && let Some(v) = backend.list_installed_versions_matching(prefix).last()
        {
            return Ok(Self::new(request, v.to_string()));
        }
        if opts.offline {
            return Ok(Self::new(request, prefix.to_string()));
        }
        let matches = backend
            .list_versions_matching_with_opts(
                config,
                prefix,
                opts.before_date,
                opts.refresh_remote_versions,
            )
            .await?;
        let v = matches
            .last()
            .ok_or_else(|| Self::no_versions_found(&backend, opts.before_date))?;
        Ok(Self::new(request, v.to_string()))
    }

    fn resolve_ref(
        ref_: String,
        ref_type: String,
        opts: ToolVersionOptions,
        tr: &ToolRequest,
    ) -> Self {
        let request = ToolRequest::Ref {
            backend: tr.ba().clone(),
            ref_,
            ref_type,
            options: opts.clone(),
            source: tr.source().clone(),
        };
        let version = request.version();
        Self::new(request, version)
    }

    fn resolve_path(path: PathBuf, tr: &ToolRequest) -> Result<ToolVersion> {
        let path = fs::canonicalize(path)?;
        let request = ToolRequest::Path {
            backend: tr.ba().clone(),
            path,
            source: tr.source().clone(),
            options: tr.options().clone(),
        };
        let version = request.version();
        Ok(Self::new(request, version))
    }
}

/// Resolve the base of a `sub-N:<base>` request and subtract from it.
///
/// `base` may be `latest` or an alias such as `lts`, neither of which
/// `tool_request::version_sub` can subtract from — it needs a concrete version. Every
/// command that accepts `sub-N:` must go through here; skipping this step is what made
/// `mise ls-remote <tool>@sub-2:lts` panic.
pub(crate) async fn resolve_sub_base(
    config: &Arc<Config>,
    backend: &ABackend,
    sub: &str,
    base: &str,
    before_date: Option<Timestamp>,
    refresh_remote_versions: bool,
) -> Result<String> {
    // An alias can point at `latest`, so the alias is resolved first and the result is
    // then treated exactly like a literal `latest` — otherwise the string `latest`
    // reaches the subtraction, which cannot do anything with it.
    let base = if base == "latest" {
        base.to_string()
    } else {
        config.resolve_alias(backend, base).await?
    };
    let v = if base == "latest" {
        backend
            .latest_version_with_refresh(config, None, before_date, refresh_remote_versions)
            .await?
            .ok_or_else(|| ToolVersion::no_versions_found(backend, before_date))?
    } else {
        base
    };
    tool_request::version_sub(&v, sub)
}

impl Display for ToolVersion {
    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
        write!(f, "{}@{}", self.ba().full(), self.version)
    }
}

impl PartialEq for ToolVersion {
    fn eq(&self, other: &Self) -> bool {
        self.ba() == other.ba() && self.version == other.version
    }
}

impl Eq for ToolVersion {}

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

impl Ord for ToolVersion {
    fn cmp(&self, other: &Self) -> Ordering {
        match self.request.ba().as_ref().cmp(other.ba()) {
            Ordering::Equal => self.version.cmp(&other.version),
            o => o,
        }
    }
}

impl Hash for ToolVersion {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.ba().hash(state);
        self.version.hash(state);
    }
}

#[derive(Debug, Clone)]
pub struct ResolveOptions {
    pub latest_versions: bool,
    pub use_locked_version: bool,
    /// Resolve rolling channels to their current concrete version even when
    /// ordinary version requests may reuse installed versions.
    pub resolve_rolling_channels: bool,
    /// Prefer an exact remote release over a fuzzy installed match.
    pub prefer_exact_version: bool,
    /// Only consider versions released before this timestamp
    pub before_date: Option<Timestamp>,
    /// `before_date` came from the built-in default release age rather than
    /// explicit configuration (CLI flag, per-tool option, or the
    /// `minimum_release_age` setting). This provenance is included in trace
    /// output to distinguish the implicit policy from a user-selected cutoff.
    pub before_date_from_default: bool,
    /// Resolve fuzzy installed matches against release metadata when a cutoff
    /// is active. This is reserved for discovery flows such as lockfile
    /// generation; ordinary resolution treats installed versions as eligible.
    pub filter_installed_versions_by_release_date: bool,
    /// Additive to `Settings::offline()` — either being true skips remote version listing.
    pub offline: bool,
    /// Ignore cached remote version lists while resolving this request.
    pub refresh_remote_versions: bool,
    /// Include installed-but-inactive versions that do not have a known config source
    /// (for example `ToolSource::Unknown`) when resolving tools for flows like
    /// outdated/upgrade checks.
    pub inactive: bool,
}

impl Default for ResolveOptions {
    fn default() -> Self {
        Self {
            latest_versions: false,
            use_locked_version: true,
            resolve_rolling_channels: false,
            prefer_exact_version: false,
            before_date: None,
            before_date_from_default: false,
            filter_installed_versions_by_release_date: false,
            offline: false,
            refresh_remote_versions: false,
            inactive: false,
        }
    }
}

impl ResolveOptions {
    /// Merge the effective release-age cutoff for a tool into these options.
    /// A cutoff pre-resolved by the caller keeps its provenance flag; cutoffs
    /// resolved here are flagged by source so installed-version fast paths
    /// can ignore the built-in default.
    pub fn apply_before_date_for_tool(
        &mut self,
        backend_arg: &BackendArg,
        minimum_release_age: Option<&str>,
    ) -> Result<()> {
        match resolve_before_date_for_tool_with_source(
            backend_arg,
            self.before_date,
            minimum_release_age,
        )? {
            Some((ts, source)) => {
                self.before_date = Some(ts);
                match source {
                    BeforeDateSource::Provided => {}
                    BeforeDateSource::Explicit => self.before_date_from_default = false,
                    BeforeDateSource::Default => self.before_date_from_default = true,
                }
            }
            None => {
                self.before_date = None;
                self.before_date_from_default = false;
            }
        }
        Ok(())
    }
}

/// Check if a tool has any user-linked versions (created by `mise link`).
/// A linked version is an installed version whose path is a symlink to an external
/// absolute path, as opposed to runtime symlinks or mise-managed install/cache links.
fn has_linked_version(ba: &BackendArg) -> bool {
    let installs_dir = &ba.installs_path;
    let Ok(entries) = std::fs::read_dir(installs_dir) else {
        return false;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if let Ok(Some(target)) = crate::file::resolve_symlink(&path) {
            // Runtime symlinks start with "./" (e.g., latest -> ./1.35.0)
            // User-linked symlinks point to absolute paths (e.g., brew -> /opt/homebrew/opt/hk)
            if target.is_absolute() && !is_mise_managed_symlink_target(&target) {
                return true;
            }
        }
    }
    false
}

fn is_mise_managed_symlink_target(target: &Path) -> bool {
    debug_assert!(target.is_absolute(), "caller filters relative targets");
    let target = normalize_path_components(target);

    [*dirs::DATA, *dirs::CACHE, *dirs::DOWNLOADS, *dirs::INSTALLS]
        .into_iter()
        .any(|root| target.starts_with(normalize_path_components(root)))
        || env::shared_install_dirs()
            .iter()
            .any(|root| target.starts_with(normalize_path_components(root)))
}

fn normalize_path_components(path: &Path) -> PathBuf {
    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::ParentDir => {
                if !normalized.pop() {
                    normalized.push("..");
                }
            }
            Component::CurDir => {}
            _ => normalized.push(component.as_os_str()),
        }
    }
    normalized
}

impl Display for ResolveOptions {
    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
        let mut opts = vec![];
        if self.latest_versions {
            opts.push("latest_versions".to_string());
        }
        if self.use_locked_version {
            opts.push("use_locked_version".to_string());
        }
        if self.resolve_rolling_channels {
            opts.push("resolve_rolling_channels".to_string());
        }
        if self.prefer_exact_version {
            opts.push("prefer_exact_version".to_string());
        }
        if let Some(ts) = &self.before_date {
            if self.before_date_from_default {
                opts.push(format!("before_date={ts} (default)"));
            } else {
                opts.push(format!("before_date={ts}"));
            }
        }
        if self.offline {
            opts.push("offline".to_string());
        }
        if self.refresh_remote_versions {
            opts.push("refresh_remote_versions".to_string());
        }
        write!(f, "({})", opts.join(", "))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::args::BackendResolution;
    use crate::toolset::CoreToolOptions;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn unique_name(prefix: &str) -> String {
        format!(
            "{}-{}",
            prefix,
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        )
    }

    fn test_backend(installs_path: PathBuf) -> BackendArg {
        let short = unique_name("dummy-linked");
        let mut backend = BackendArg::new_raw(
            short.clone(),
            None,
            short,
            None,
            BackendResolution::new(false),
        );
        backend.installs_path = installs_path;
        backend
    }

    #[test]
    fn ref_pathname_preserves_selector_type() {
        let temp_dir = tempfile::tempdir().unwrap();
        let backend = Arc::new(test_backend(temp_dir.path().join("installs")));

        for (version, pathname) in [
            ("ref:main", "ref-main"),
            ("rev:abc123", "rev-abc123"),
            ("tag:v1.0.0", "tag-v1.0.0"),
            ("branch:main", "branch-main"),
        ] {
            let (ref_type, ref_) = version.split_once(':').unwrap();
            let request = ToolRequest::Ref {
                backend: backend.clone(),
                ref_: ref_.to_string(),
                ref_type: ref_type.to_string(),
                options: ToolVersionOptions::default(),
                source: ToolSource::Argument,
            };
            let tv = ToolVersion::new(request, version.to_string());
            assert_eq!(tv.tv_pathname(), pathname);
        }
    }

    #[test]
    fn from_lockfile_applies_backend_and_preserves_request_options() {
        let backend = Arc::new(BackendArg::new("npm".to_string(), None));
        let mut options = ToolVersionOptions {
            core: CoreToolOptions {
                depends: Some(vec!["node".to_string()]),
                ..Default::default()
            },
            ..Default::default()
        };
        options.install_env.insert(
            "NODE_OPTIONS".to_string(),
            EnvValue::from("--enable-source-maps"),
        );
        options.opts.insert(
            "registry".to_string(),
            toml::Value::String("https://registry.example.test".to_string()),
        );
        let request = ToolRequest::Version {
            backend,
            version: "latest".to_string(),
            options,
            source: ToolSource::Argument,
        };
        let lt = LockfileTool {
            version: "11.17.0".to_string(),
            backend: Some("npm:npm".to_string()),
            options: BTreeMap::from([("registry".to_string(), "lockfile-value".to_string())]),
            platforms: Default::default(),
        };

        let tv = ToolVersion::from_lockfile(request, lt);
        let options = tv.request.options();

        assert_eq!(tv.version, "11.17.0");
        assert!(tv.locked);
        assert!(tv.resolved_from_lockfile());
        assert_eq!(tv.ba().full_without_opts(), "npm:npm");
        assert_eq!(options.depends, Some(vec!["node".to_string()]));
        assert_eq!(
            options.install_env.get("NODE_OPTIONS"),
            Some(&EnvValue::String("--enable-source-maps".to_string()))
        );
        assert_eq!(
            options.opts.get("registry"),
            Some(&toml::Value::String(
                "https://registry.example.test".to_string()
            ))
        );
    }

    #[test]
    fn has_linked_version_detects_external_absolute_targets() -> Result<()> {
        let temp_dir = tempfile::tempdir()?;
        let backend = test_backend(temp_dir.path().join("installs").join("dummy"));
        fs::create_dir_all(&backend.installs_path)?;

        let external_target = temp_dir.path().join("external").join("tool");
        fs::create_dir_all(&external_target)?;
        crate::file::make_symlink_or_file(&external_target, &backend.installs_path.join("brew"))?;

        assert!(has_linked_version(&backend));

        Ok(())
    }

    #[test]
    fn has_linked_version_normalizes_absolute_targets_before_managed_check() -> Result<()> {
        let temp_dir = tempfile::tempdir()?;
        let backend = test_backend(temp_dir.path().join("installs").join("dummy"));
        fs::create_dir_all(&backend.installs_path)?;

        let escaped_target = dirs::DATA
            .join("..")
            .join("has-linked-version-external")
            .join("tool");
        if let Some(parent) = escaped_target.parent() {
            fs::create_dir_all(parent)?;
        }
        crate::file::make_symlink_or_file(&escaped_target, &backend.installs_path.join("escaped"))?;

        assert!(has_linked_version(&backend));

        Ok(())
    }

    #[test]
    fn has_linked_version_ignores_mise_managed_absolute_targets() -> Result<()> {
        let temp_dir = tempfile::tempdir()?;
        let backend = test_backend(temp_dir.path().join("installs").join("dummy"));
        fs::create_dir_all(&backend.installs_path)?;

        let mut managed_targets = vec![];
        let mut roots = vec![
            ("data".to_string(), dirs::DATA.to_path_buf()),
            ("cache".to_string(), dirs::CACHE.to_path_buf()),
            ("downloads".to_string(), dirs::DOWNLOADS.to_path_buf()),
            ("installs".to_string(), dirs::INSTALLS.to_path_buf()),
        ];
        roots.extend(
            env::shared_install_dirs()
                .into_iter()
                .enumerate()
                .map(|(i, root)| (format!("shared-{i}"), root)),
        );

        for (name, root) in roots {
            fs::create_dir_all(&root)?;
            let target = tempfile::Builder::new()
                .prefix(&format!("has-linked-version-{name}-"))
                .tempdir_in(&root)?;
            crate::file::make_symlink_or_file(
                target.path(),
                &backend.installs_path.join(format!("{name}-target")),
            )?;
            managed_targets.push(target);
        }

        assert!(!has_linked_version(&backend));

        Ok(())
    }

    #[test]
    fn has_linked_version_ignores_runtime_relative_targets() -> Result<()> {
        let temp_dir = tempfile::tempdir()?;
        let backend = test_backend(temp_dir.path().join("installs").join("dummy"));
        fs::create_dir_all(&backend.installs_path)?;

        crate::file::make_symlink_or_file(
            Path::new("./1.0.0"),
            &backend.installs_path.join("latest"),
        )?;

        assert!(!has_linked_version(&backend));

        Ok(())
    }

    #[test]
    fn runtime_path_does_not_return_file_based_runtime_symlink() -> Result<()> {
        reset_install_path_cache();

        let temp_dir = tempfile::tempdir()?;
        let short = format!(
            "dummy-{}",
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        );
        let mut backend = BackendArg::new_raw(
            short.clone(),
            None,
            short,
            None,
            BackendResolution::new(false),
        );
        backend.installs_path = temp_dir.path().join("installs").join("dummy");

        let install_path = backend.installs_path.join("1.0.1");
        fs::create_dir_all(install_path.join("bin"))?;
        fs::write(backend.installs_path.join("1.0"), "./1.0.1")?;

        let request = ToolRequest::Version {
            backend: Arc::new(backend),
            version: "1.0".into(),
            options: ToolVersionOptions::default(),
            source: ToolSource::Argument,
        };
        let tv = ToolVersion::new(request, "1.0.1".into());

        let runtime_path = tv.runtime_path();
        assert_eq!(runtime_path, install_path);
        assert!(runtime_path.is_dir());

        Ok(())
    }

    #[test]
    fn with_locked_runtime_path_uses_install_path_for_fuzzy_request() -> Result<()> {
        reset_install_path_cache();

        let temp_dir = tempfile::tempdir()?;
        let short = format!(
            "dummy-locked-{}",
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        );
        let mut backend = BackendArg::new_raw(
            short.clone(),
            None,
            short,
            None,
            BackendResolution::new(false),
        );
        backend.installs_path = temp_dir.path().join("installs").join("dummy");

        let install_path = backend.installs_path.join("3.14.6");
        fs::create_dir_all(install_path.join("bin"))?;

        // Reproduce the stale state during `mise up` (#10347): the fuzzy runtime
        // symlink installs/dummy/3 still points at a previous version (3.13.9) while
        // 3.14.6 is the version just installed -- runtime symlinks are only rebuilt
        // after all installs finish. is_runtime_symlink() requires a "./" target; on
        // Windows runtime symlinks are stored as a file containing the target.
        let old_path = backend.installs_path.join("3.13.9");
        fs::create_dir_all(old_path.join("bin"))?;
        let runtime_link = backend.installs_path.join("3");
        #[cfg(unix)]
        std::os::unix::fs::symlink("./3.13.9", &runtime_link)?;
        #[cfg(windows)]
        fs::write(&runtime_link, "./3.13.9")?;

        // Fuzzy request ("3") resolved to a concrete version ("3.14.6").
        let request = ToolRequest::Version {
            backend: Arc::new(backend),
            version: "3".into(),
            options: ToolVersionOptions::default(),
            source: ToolSource::Argument,
        };
        let tv = ToolVersion::new(request, "3.14.6".into());
        assert!(!tv.resolved_from_lockfile());

        // Without locking, runtime_path() follows the stale fuzzy runtime symlink, so
        // it does NOT resolve to the version just installed -- the bug behavior. (This
        // also self-checks the stale-symlink setup above.)
        assert_ne!(tv.runtime_path(), install_path);

        // with_locked() pins runtime_path() to the exact install just made, not the
        // fuzzy runtime symlink, so the postinstall hook sees 3.14.6 (#10347).
        assert_eq!(tv.clone().with_locked().runtime_path(), install_path);
        assert_eq!(tv.clone().with_locked().runtime_path(), tv.install_path());
        assert!(!tv.with_locked().resolved_from_lockfile());

        Ok(())
    }

    /// Regression test for https://github.com/jdx/mise/discussions/9526
    ///
    /// `install_path()` must not cache a path that does not yet exist. If it
    /// did, a tool that first asked for its install path before the install
    /// completed would receive that cached path forever — even after the tool
    /// installed somewhere else (e.g. via `--system` into a shared install
    /// dir). Subsequent callers like core go's `exec_env` would then export
    /// the wrong GOROOT/GOPATH, breaking go-backend tools that depend on go.
    #[test]
    fn install_path_does_not_cache_nonexistent_paths() -> Result<()> {
        reset_install_path_cache();

        let temp_dir = tempfile::tempdir()?;
        let short = format!(
            "dummy-cache-{}",
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        );
        let mut backend = BackendArg::new_raw(
            short.clone(),
            None,
            short,
            None,
            BackendResolution::new(false),
        );
        backend.installs_path = temp_dir.path().join("installs").join("dummy-cache");

        let request = ToolRequest::Version {
            backend: Arc::new(backend),
            version: "1.0.0".into(),
            options: ToolVersionOptions::default(),
            source: ToolSource::Argument,
        };
        let tv = ToolVersion::new(request, "1.0.0".into());

        // First call: nothing exists yet. Should return the primary path but
        // must NOT populate the cache.
        let p1 = tv.install_path();
        assert!(!p1.exists());
        assert!(INSTALL_PATH_CACHE.get(&tv).is_none());

        // Now "install" the tool by creating the dir.
        fs::create_dir_all(&p1)?;

        // Second call should still return the same path; this time it exists
        // so the cache is populated.
        let p2 = tv.install_path();
        assert_eq!(p1, p2);
        assert!(p2.exists());
        assert!(INSTALL_PATH_CACHE.get(&tv).is_some());

        Ok(())
    }
}
