// packages/installer.rs --- Synchronous package installer.

//! Package installer (T M7.3, spec §sec:packages-future).
//!
//! Given an [`InstallSpec`] (address + version constraint) and an
//! install root, performs:
//!
//! 1. **Fetch** via [`Fetcher::fetch`] (mirror clone or update).
//! 2. **Tag enumeration** via [`Fetcher::list_tags`] and best-match
//!    selection against the user's [`VersionReq`].
//! 3. **Resolve** the chosen tag to a 40-char commit hash.
//! 4. **Manifest read** via [`Fetcher::show_blob`] so we can derive the
//!    install directory name from `manifest.name`'s basename without
//!    materializing the whole tree first.
//! 5. **Archive + extract** via [`Fetcher::archive_commit`] piped into
//!    `tar -x`. The install dir is self-contained; no `.git` clutter,
//!    no link back to the bare cache.
//!
//! v0.1 design notes:
//!
//! - **Synchronous**: errors surface back at the call site (typically
//!   a Lua traceback inside `pmacs.packages.install{...}`). The
//!   deferred-dispatcher pattern used for `pmacs.attach` does not
//!   apply --- attach is a transport handoff, install is just I/O,
//!   and a synchronous failure pins the offending `init.lua` line.
//! - **Standalone vs resolver-driven**. The installer has two
//!   entry points. [`Installer::install`] is standalone: it
//!   independently picks a tag for [`InstallPin::Version`] via
//!   [`best_match`] and refuses to overwrite an existing install
//!   at a different commit. [`Installer::install_at_commit`] /
//!   [`Installer::replace_at_commit`] are the resolver-driven
//!   paths: they trust the supplied commit and (for replace)
//!   stage-and-swap an existing install. The Lua surface
//!   (`pmacs.packages.install` / `update`) goes through the
//!   resolver-driven paths so the resolver's revision choice is
//!   authoritative; transitive resolution and lockfile writes
//!   live in the surrounding orchestration code.
//! - **All three pin kinds supported**. [`InstallPin::Version`]
//!   maps to `best_match` over upstream tags;
//!   [`InstallPin::Branch`] resolves to the named branch's HEAD;
//!   [`InstallPin::Commit`] resolves to a specific revision. The
//!   user surface accepts all three via the table form (`{ ...,
//!   branch = ... }` / `{ ..., commit = ... }`).
//! - **Install dir naming**: `<install_root>/<basename>/`, where
//!   `basename` is the package name's last `/`-segment. Two
//!   packages with the same basename collide on disk; for v0.1 we
//!   accept the collision (the resolver / caller can
//!   `pmacs.packages.installed()` to spot conflicts before they
//!   bite).

use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use thiserror::Error;

use super::address::{Address, AddressError};
use super::fetcher::{FetchError, Fetcher, RefSpec};
use super::manifest::{ManifestError, PackageManifest};

// ---------------------------------------------------------------------------
// InstallScope
// ---------------------------------------------------------------------------

/// Where a package should be installed.
///
/// `User` resolves to `$XDG_DATA_HOME/pmacs/packages/` (or
/// `$HOME/.local/share/pmacs/packages/` if `XDG_DATA_HOME` is unset).
/// `Project` resolves to `<project_root>/.pmacs/packages/`.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum InstallScope {
    /// User-config install (per-user, persistent across projects).
    User,
    /// Project-scoped install. The project root is conventionally the
    /// editor's working directory at startup.
    Project {
        /// Project root directory; the install dir is `<root>/.pmacs/packages/`.
        project_root: PathBuf,
    },
}

impl InstallScope {
    /// Resolve to the install root directory. Does not create it.
    pub fn resolve_root(&self) -> Result<PathBuf, InstallError> {
        match self {
            Self::User => xdg_data_root(),
            Self::Project { project_root } => Ok(project_root.join(".pmacs").join("packages")),
        }
    }
}

fn xdg_data_root() -> Result<PathBuf, InstallError> {
    if let Some(dir) = std::env::var_os("XDG_DATA_HOME") {
        let p: PathBuf = dir.into();
        if !p.as_os_str().is_empty() {
            return Ok(p.join("pmacs").join("packages"));
        }
    }
    if let Some(home) = std::env::var_os("HOME") {
        let p: PathBuf = home.into();
        if !p.as_os_str().is_empty() {
            return Ok(p
                .join(".local")
                .join("share")
                .join("pmacs")
                .join("packages"));
        }
    }
    Err(InstallError::NoDataHome)
}

// ---------------------------------------------------------------------------
// InstallSpec
// ---------------------------------------------------------------------------

/// What the user pinned the install to. Mutually exclusive on the
/// Lua side: a spec table may carry exactly one of `version`,
/// `branch`, or `commit`.
///
/// # Why three kinds
///
/// - [`Self::Version`] is the default, recommended path: the
///   installer picks the highest semver tag matching the constraint
///   and validates the manifest's declared version against the same
///   constraint. Lockfile reproduction (M7.6) records the resolved
///   commit so a later install at the same constraint yields the
///   same revision.
/// - [`Self::Branch`] follows a moving target. Each install
///   re-resolves the branch's HEAD; the install is *not*
///   reproducible across time. Useful for development against an
///   upstream's `main` or for a private package whose semver
///   discipline is not yet established.
/// - [`Self::Commit`] freezes the install at a specific revision.
///   Useful for pinning to a known-good state before the upstream
///   has tagged a release, or for reproducing a colleague's
///   environment exactly without semver drift.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub enum InstallPin {
    /// Highest semver tag satisfying the constraint.
    Version(VersionReq),
    /// HEAD of the named branch at install time.
    Branch(String),
    /// Specific commit (full or partial SHA; the fetcher accepts
    /// either via `git rev-parse`).
    Commit(String),
    /// Working-tree symlink installed via
    /// [`pmacs.packages.install_local`] (T M8.1c). Carries the
    /// source path so the Lua-visible roster entry can show where
    /// the live tree lives. Local-pinned packages are *ephemeral*:
    /// they never enter the lockfile and aren't reproducible across
    /// machines --- they exist for the M8 dev loop ("edit a
    /// package's source on disk and reload without restarting") and
    /// nothing else.
    Local {
        /// Source path the install dir symlinks to.
        source_path: PathBuf,
    },
}

impl InstallPin {
    /// Stable string discriminator used at the Lua boundary
    /// (`installed_package_to_lua`) and in error messages.
    #[must_use]
    pub fn kind(&self) -> &'static str {
        match self {
            Self::Version(_) => "version",
            Self::Branch(_) => "branch",
            Self::Commit(_) => "commit",
            Self::Local { .. } => "local",
        }
    }

    /// User-supplied value as a string: the constraint for
    /// [`Self::Version`], the branch name for [`Self::Branch`], the
    /// SHA for [`Self::Commit`], the source path for [`Self::Local`].
    #[must_use]
    pub fn value(&self) -> String {
        match self {
            Self::Version(req) => req.to_string(),
            Self::Branch(b) => b.clone(),
            Self::Commit(c) => c.clone(),
            Self::Local { source_path } => source_path.display().to_string(),
        }
    }
}

/// Result of [`Installer::plan_local`]: everything needed to
/// commit a working-tree symlink install, with no disk changes
/// yet performed (T M8.1c).
///
/// The plan/commit split lets the Lua binding layer interleave
/// `on_unload`-hook execution between validation and the disk
/// swap. If a hook fails, the caller can drop the plan and the
/// disk is unchanged.
#[derive(Debug, Clone)]
pub struct LocalInstallPlan {
    /// Parsed manifest from `<source_path>/pmacs.toml`.
    pub manifest: PackageManifest,
    /// Where the symlink will be placed:
    /// `<install_root>/<basename>`.
    pub install_path: PathBuf,
    /// Canonicalized source path the symlink will point at. Holds
    /// an absolute path so the symlink resolves regardless of
    /// where the editor's CWD ends up.
    pub canonical_source: PathBuf,
    /// Install basename (the last `/`-segment of `manifest.name`).
    /// Useful to the binding layer for keying registry slots
    /// (`PackageUnloadHooks`, etc.) without re-deriving from the
    /// manifest.
    pub basename: String,
    /// Scope (user / project) this install will land in.
    pub scope: InstallScope,
}

/// A local install whose new symlink has already been created at a
/// sibling staging path, but has not yet been published over
/// [`LocalInstallPlan::install_path`].
///
/// The Lua binding uses this to front-load the fallible symlink
/// creation before it runs the prior package's `on_unload` hooks.
/// After hooks complete, publishing is a same-directory `rename(2)`.
#[derive(Debug, Clone)]
pub struct StagedLocalInstall {
    plan: LocalInstallPlan,
    staging_path: PathBuf,
}

/// A normalized install request: where to fetch and how to pin the
/// revision.
#[derive(Debug, Clone)]
pub struct InstallSpec {
    /// Address (resolved via `Address::parse`).
    pub address: Address,
    /// What the user pinned the install to. See [`InstallPin`].
    pub pin: InstallPin,
}

impl InstallSpec {
    /// Parse the `<address>@<constraint>` shorthand. Examples:
    ///
    /// - `github:user/repo@^1.0.0`
    /// - `git:https://x.y/z.git@=2.3.4`
    /// - `https://x.y/z.git@~0.5`
    ///
    /// The `@` separator is always the **last** `@` in the string so
    /// addresses containing an `@` (SSH shorthand: `git@host:path`) work.
    /// Returns [`InstallError::ShorthandMissingVersion`] when the input
    /// has no `@` separator and [`InstallError::Address`] /
    /// [`InstallError::InvalidVersionReq`] for the underlying parse
    /// failures.
    ///
    /// The shorthand string form is **version-pin only**. Branch and
    /// commit pins must use the Lua table form
    /// (`{ "addr", branch = "..." }` / `{ "addr", commit = "..." }`)
    /// because there is no concise sigil that disambiguates a
    /// branch/commit value from a semver constraint without
    /// surprising users (`@main` could be a branch named "main" or
    /// a malformed semver --- ambiguous).
    pub fn parse_shorthand(s: &str) -> Result<Self, InstallError> {
        let (addr, ver) =
            s.rsplit_once('@')
                .ok_or_else(|| InstallError::ShorthandMissingVersion {
                    input: s.to_string(),
                })?;
        if addr.is_empty() || ver.is_empty() {
            return Err(InstallError::ShorthandMissingVersion {
                input: s.to_string(),
            });
        }
        let address = Address::parse(addr).map_err(InstallError::Address)?;
        let version = VersionReq::parse(ver).map_err(|e| InstallError::InvalidVersionReq {
            value: ver.to_string(),
            cause: e.to_string(),
        })?;
        Ok(Self {
            address,
            pin: InstallPin::Version(version),
        })
    }
}

// ---------------------------------------------------------------------------
// InstalledPackage
// ---------------------------------------------------------------------------

/// Result of a successful install.
#[derive(Debug, Clone)]
pub struct InstalledPackage {
    /// Parsed `pmacs.toml` from the installed snapshot.
    pub manifest: PackageManifest,
    /// On-disk directory holding the installed snapshot.
    pub install_path: PathBuf,
    /// 40-char commit hash of the installed snapshot.
    pub commit: String,
    /// A descriptor of what was installed:
    /// - For [`InstallPin::Version`]: the matched tag, e.g. `"v1.0.0"`.
    /// - For [`InstallPin::Branch`]: `"branch:<name>"`.
    /// - For [`InstallPin::Commit`]: `"commit:<short-sha>"`.
    ///
    /// Always non-empty so Lua callers can use it as a stable
    /// "what got installed" label without nil-checking.
    pub tag: String,
    /// Semver version of the installed snapshot. For
    /// [`InstallPin::Version`] this is the version parsed from the
    /// matched tag; for [`InstallPin::Branch`] / [`InstallPin::Commit`]
    /// it falls back to `manifest.version` (the package's declared
    /// version at the resolved revision).
    pub version: Version,
    /// The install scope this package was installed under.
    pub scope: InstallScope,
    /// What the user originally pinned this install to. Useful for
    /// lockfile generation (M7.6) and for surfacing to the Lua
    /// `installed()` snapshot.
    pub pin: InstallPin,
}

impl InstalledPackage {
    /// The basename used for the on-disk install dir and for the
    /// Lua `require` name. Equal to `manifest.name`'s segment after
    /// the optional `/` namespace prefix.
    #[must_use]
    pub fn install_basename(&self) -> &str {
        package_basename(self.manifest.name.as_str())
    }

    /// Absolute path of the entry Lua module (`install_path` joined
    /// with `manifest.entry`).
    #[must_use]
    pub fn entry_path(&self) -> PathBuf {
        self.install_path.join(&self.manifest.entry)
    }
}

// ---------------------------------------------------------------------------
// Installer
// ---------------------------------------------------------------------------

/// Internal options passed through [`Installer::install_with`].
/// Public callers use [`Installer::install`] /
/// [`Installer::install_at_commit`] /
/// [`Installer::replace_at_commit`] which set these flags
/// appropriately.
#[derive(Debug, Default)]
struct InstallOptions {
    /// If `Some`, treat this commit as the resolver's choice and
    /// skip the installer's own tag/branch/commit lookup. The
    /// manifest is read at this commit.
    resolved_commit: Option<String>,
    /// If `true`, an existing install at the same path with a
    /// different commit is replaced rather than rejected with
    /// [`InstallError::AlreadyInstalled`]. The replacement is
    /// staged at `<install_path>.new` and only swapped in after
    /// successful extraction.
    replace_existing: bool,
}

/// Installer: pairs a [`Fetcher`] with an [`InstallScope`].
///
/// One `Installer` per scope; `LuaHost` constructs two (user-scoped and
/// project-scoped) at startup.
#[derive(Debug, Clone)]
pub struct Installer {
    fetcher: Fetcher,
    scope: InstallScope,
    /// Test override for the install root. Production callers leave
    /// this `None`; the test harness sets it (the project forbids
    /// `unsafe_code`, so mutating `XDG_DATA_HOME` directly is not an
    /// option).
    root_override: Option<PathBuf>,
}

impl Installer {
    /// Construct an installer with an explicit fetcher and scope.
    #[must_use]
    pub fn new(fetcher: Fetcher, scope: InstallScope) -> Self {
        Self {
            fetcher,
            scope,
            root_override: None,
        }
    }

    /// Builder: override the on-disk install root (test-facing).
    /// Production code does not call this; the resolved
    /// [`InstallScope`] determines the path.
    #[must_use]
    pub fn with_install_root_override(mut self, root: PathBuf) -> Self {
        self.root_override = Some(root);
        self
    }

    /// The scope this installer targets.
    #[must_use]
    pub fn scope(&self) -> &InstallScope {
        &self.scope
    }

    /// Resolve the install root (creating it on demand).
    pub fn install_root(&self) -> Result<PathBuf, InstallError> {
        let root = match &self.root_override {
            Some(p) => p.clone(),
            None => self.scope.resolve_root()?,
        };
        fs::create_dir_all(&root).map_err(|source| InstallError::Io {
            path: root.clone(),
            source,
        })?;
        Ok(root)
    }

    /// Install one package. See module docs for the step-by-step flow.
    ///
    /// `install()` is the standalone entry point: the installer
    /// independently picks the tag for `InstallPin::Version` via
    /// [`best_match`]. Resolver-driven flows
    /// (`pmacs.packages.install` / `pmacs.packages.update`) instead
    /// call [`Self::install_at_commit`] / [`Self::replace_at_commit`]
    /// so the installer honors the resolver's revision choice rather
    /// than re-deriving it.
    pub fn install(&self, spec: &InstallSpec) -> Result<InstalledPackage, InstallError> {
        self.install_with(spec, &InstallOptions::default())
    }

    /// Install at a commit pre-chosen by the resolver. The displayed
    /// `pin` field on the returned [`InstalledPackage`] still
    /// reflects `spec.pin` (so a `Version(^1.0.0)` request shows up
    /// as a version pin), but the installer skips its own tag
    /// enumeration and checks out the supplied commit directly.
    /// The displayed `tag` is synthesized from `manifest.version` for
    /// Version pins, or kept as `branch:<name>`/`commit:<short>` for
    /// the other variants.
    ///
    /// Refuses to overwrite an existing install at a different
    /// commit; for that path see [`Self::replace_at_commit`].
    pub fn install_at_commit(
        &self,
        spec: &InstallSpec,
        commit: &str,
    ) -> Result<InstalledPackage, InstallError> {
        self.install_with(
            spec,
            &InstallOptions {
                resolved_commit: Some(commit.to_string()),
                replace_existing: false,
            },
        )
    }

    /// Install or replace at a commit pre-chosen by the resolver.
    /// Differs from [`Self::install_at_commit`] only in that an
    /// existing install at the same path with a different commit is
    /// replaced rather than erroring. The replacement is staged at
    /// `<install_path>.new` and only swapped in after extraction
    /// succeeds, so a failing update leaves the prior install intact.
    ///
    /// Used by `pmacs.packages.update`, which by definition expects
    /// to overwrite a prior install when upstream has moved.
    pub fn replace_at_commit(
        &self,
        spec: &InstallSpec,
        commit: &str,
    ) -> Result<InstalledPackage, InstallError> {
        self.install_with(
            spec,
            &InstallOptions {
                resolved_commit: Some(commit.to_string()),
                replace_existing: true,
            },
        )
    }

    /// Install from a local working-tree path by symlinking it
    /// into the install root (T M8.1c). The dev-loop counterpart
    /// to [`Self::install`]: edits to files under `source_path`
    /// become live in the editor without re-running the package
    /// pipeline; `pmacs.packages.reload(name)` (M8.1d) picks them
    /// up without restarting the session.
    ///
    /// Semantics:
    ///
    /// - `source_path` must contain a readable `pmacs.toml`.
    ///   Anything else fails with [`InstallError::LocalManifestMissing`].
    /// - The install dir is `<install_root>/<basename>`. If a
    ///   symlink already lives there, it is replaced by staging a
    ///   sibling symlink and atomically renaming it into place.
    /// - If a *real* directory lives at the install path,
    ///   [`InstallError::LocalRealInstallInWay`] surfaces. The user
    ///   removes that install first (manually or via a future
    ///   uninstall API).
    /// - The returned [`InstalledPackage`] carries
    ///   [`InstallPin::Local`] so the Lua-visible roster entry
    ///   names the source. No lockfile work is done; `install_local`
    ///   is explicitly ephemeral.
    pub fn install_local(&self, source_path: &Path) -> Result<InstalledPackage, InstallError> {
        let plan = self.plan_local(source_path)?;
        self.commit_local(plan)
    }

    /// Validate `source_path` and compute where its symlink should
    /// land, **without making any disk changes**. The returned
    /// [`LocalInstallPlan`] is consumed by [`Self::commit_local`],
    /// which performs the symlink swap.
    ///
    /// The plan/commit split exists so the Lua binding layer can
    /// run prior-install `on_unload` hooks between the two steps:
    /// if any hook fails, the disk symlink hasn't moved, so disk
    /// and runtime state remain in sync. Without the split, a
    /// failing hook leaves the symlink at the new source while the
    /// roster / `package.loaded` / per-package env still track the
    /// old one --- a desync the user can only resolve by
    /// restarting.
    pub fn plan_local(&self, source_path: &Path) -> Result<LocalInstallPlan, InstallError> {
        // Manifest must exist and parse. A friendly error here
        // beats a surprising error later when the searcher tries
        // to load a non-existent entry.
        let manifest_path = source_path.join("pmacs.toml");
        let manifest_str = std::fs::read_to_string(&manifest_path).map_err(|e| {
            InstallError::LocalManifestMissing {
                source_path: source_path.to_path_buf(),
                cause: e.to_string(),
            }
        })?;
        let manifest = PackageManifest::from_toml(&manifest_str).map_err(|e| {
            InstallError::LocalManifestMissing {
                source_path: source_path.to_path_buf(),
                cause: e.to_string(),
            }
        })?;

        // pmacs_required check, identical to the fetched-install
        // path. install_local doesn't bypass any compatibility
        // gate; the dev-loop story doesn't extend to "ignore the
        // version constraint."
        let running_pmacs = running_pmacs_version();
        if !manifest.pmacs_required.matches(&running_pmacs) {
            return Err(InstallError::PmacsVersionIncompatible {
                address: source_path.display().to_string(),
                tag: format!("local:{}", source_path.display()),
                required: manifest.pmacs_required.to_string(),
                running: running_pmacs.to_string(),
            });
        }

        let install_root = self.install_root()?;
        let basename = package_basename(manifest.name.as_str()).to_string();
        let install_path = install_root.join(&basename);

        // Probe the install path. We don't mutate it here --- the
        // commit step does. We do reject the real-directory case
        // up front so the Lua binding layer can refuse before
        // running any unload hooks (a hook running and then the
        // commit failing because of a real-dir collision would be
        // worse than refusing immediately).
        match std::fs::symlink_metadata(&install_path) {
            Ok(meta) if meta.file_type().is_symlink() => { /* ok, we'll replace */ }
            Ok(_) => {
                return Err(InstallError::LocalRealInstallInWay { install_path });
            }
            Err(e) if e.kind() == io::ErrorKind::NotFound => { /* ok, we'll create */ }
            Err(source) => {
                return Err(InstallError::Io {
                    path: install_path.clone(),
                    source,
                });
            }
        }

        // Canonicalize the source so the symlink points at an
        // absolute path. Without this, a relative source resolves
        // against the install dir's parent rather than the user's
        // CWD, and the user's CWD is the contract here.
        let canonical_source =
            std::fs::canonicalize(source_path).map_err(|source| InstallError::Io {
                path: source_path.to_path_buf(),
                source,
            })?;

        Ok(LocalInstallPlan {
            manifest,
            install_path,
            canonical_source,
            basename,
            scope: self.scope.clone(),
        })
    }

    /// Commit a [`LocalInstallPlan`] in one step: stage a new symlink
    /// at a sibling temp path, then atomically rename it over
    /// `plan.install_path`. After this returns, the plan's bytes are
    /// live on disk; the caller is responsible for cache invalidation.
    ///
    /// Callers that need to interleave package teardown hooks between
    /// staging and publishing should use [`Self::stage_local`] followed
    /// by [`Self::publish_local`].
    ///
    /// **Atomicity.** The replacement uses `rename(2)` to swap the
    /// staged symlink over the existing one. On the same
    /// filesystem (which it is by construction --- the staging
    /// path is in the same directory as `install_path`),
    /// `rename(2)` is atomic with respect to other observers: at
    /// any moment, `install_path` either holds the old symlink or
    /// the new one, never neither. This is the upgrade from the
    /// prior remove-then-create shape, where a `symlink(2)` failure
    /// after the `unlink(2)` left the install path missing while
    /// the runtime still tracked the old install.
    ///
    /// Re-checks the install path's symlink-vs-real-dir state at
    /// commit time: belt-and-braces against a TOCTOU between plan
    /// and commit (the dev-loop is single-user, so a real
    /// race is unlikely, but a `LocalRealInstallInWay` returned
    /// here keeps the contract symmetric with [`Self::plan_local`]).
    pub fn commit_local(&self, plan: LocalInstallPlan) -> Result<InstalledPackage, InstallError> {
        let staged = self.stage_local(plan)?;
        self.publish_local(staged)
    }

    /// Stage a [`LocalInstallPlan`] by creating the new symlink at a
    /// hidden sibling path, but do not publish it over the live
    /// install path yet.
    ///
    /// This performs the fallible symlink-creation work before the
    /// binding layer runs `on_unload` hooks. If staging fails, the old
    /// package is still live and no teardown hooks have fired.
    pub fn stage_local(&self, plan: LocalInstallPlan) -> Result<StagedLocalInstall, InstallError> {
        // Re-check the install path. A real dir surfacing here
        // would indicate either a TOCTOU race or a bug in the
        // plan/commit caller; either way refuse rather than
        // silently overwrite. Symlinks and missing paths are both
        // valid commit destinations; the atomic rename below
        // handles both shapes uniformly.
        match std::fs::symlink_metadata(&plan.install_path) {
            Ok(meta) if meta.file_type().is_symlink() => { /* ok, atomic swap */ }
            Ok(_) => {
                return Err(InstallError::LocalRealInstallInWay {
                    install_path: plan.install_path,
                });
            }
            Err(e) if e.kind() == io::ErrorKind::NotFound => { /* ok, fresh create */ }
            Err(source) => {
                return Err(InstallError::Io {
                    path: plan.install_path.clone(),
                    source,
                });
            }
        }

        // Stage the new symlink at a sibling path. Same directory
        // as the install path means rename(2) is atomic. The
        // sentinel-prefix (`.<basename>.swap.tmp`) is hidden in
        // ls(1) output and namespaced so concurrent commits for
        // different basenames don't collide. A leftover from a
        // prior crashed commit would be unlinked here before we
        // re-stage.
        let staging_path = plan
            .install_path
            .with_file_name(format!(".{}.swap.tmp", plan.basename));
        if let Err(e) = std::fs::remove_file(&staging_path)
            && e.kind() != io::ErrorKind::NotFound
        {
            return Err(InstallError::Io {
                path: staging_path,
                source: e,
            });
        }
        symlink_create(&plan.canonical_source, &staging_path)?;

        Ok(StagedLocalInstall { plan, staging_path })
    }

    /// Best-effort cleanup for a staged local install that will not be
    /// published, typically because an `on_unload` hook failed.
    pub fn discard_staged_local(&self, staged: StagedLocalInstall) {
        let _ = std::fs::remove_file(staged.staging_path);
    }

    /// Publish a staged local install with a same-directory atomic
    /// rename, returning the Lua-visible package record.
    pub fn publish_local(
        &self,
        staged: StagedLocalInstall,
    ) -> Result<InstalledPackage, InstallError> {
        let StagedLocalInstall { plan, staging_path } = staged;

        // Atomic swap. rename(2) replaces install_path
        // (whether or not it currently exists) in a single
        // observable step. On failure the staged symlink is
        // unlinked so we don't leave a dangling .swap.tmp file
        // behind; the original install_path is untouched.
        if let Err(source) = std::fs::rename(&staging_path, &plan.install_path) {
            let _ = std::fs::remove_file(&staging_path);
            return Err(InstallError::Io {
                path: plan.install_path.clone(),
                source,
            });
        }

        Ok(InstalledPackage {
            version: plan.manifest.version.clone(),
            manifest: plan.manifest,
            install_path: plan.install_path,
            // No commit. The synthetic `local` token marks this as
            // an ephemeral install in the Lua-visible roster
            // (callers compare the `pin.kind` field, not commit).
            commit: "local".to_string(),
            tag: format!("local:{}", plan.canonical_source.display()),
            scope: plan.scope,
            pin: InstallPin::Local {
                source_path: plan.canonical_source,
            },
        })
    }

    /// Unified install flow used by [`Self::install`],
    /// [`Self::install_at_commit`], and [`Self::replace_at_commit`].
    /// The three differ only in `opts`.
    #[allow(clippy::too_many_lines)]
    fn install_with(
        &self,
        spec: &InstallSpec,
        opts: &InstallOptions,
    ) -> Result<InstalledPackage, InstallError> {
        // Reject Local pins early: the fetched-install path needs a
        // clone URL, and Local pins don't have one. install_local()
        // owns the working-tree symlink path. T M8.1c.
        if let InstallPin::Local { source_path } = &spec.pin {
            return Err(InstallError::LocalPinNotSupported {
                source_path: source_path.clone(),
            });
        }
        let url = spec.address.to_git_url();
        let bare = self.fetcher.fetch(&url).map_err(InstallError::Fetch)?;

        // Resolve the user's pin to a concrete (commit, tag-descriptor)
        // pair. When the resolver has supplied a commit, we use it
        // directly: this keeps the installer aligned with the
        // resolver's choice for InstallPin::Version (where re-running
        // best_match() could otherwise diverge if upstream tagged a
        // newer version that the resolver rejected for compatibility
        // reasons). The descriptor for Version pins is synthesized
        // from manifest.version after we read the manifest.
        let (commit, tag_descriptor) = if let Some(forced) = opts.resolved_commit.as_deref() {
            let commit = self
                .fetcher
                .resolve(&bare, &RefSpec::Commit(forced.to_string()))
                .map_err(InstallError::Fetch)?;
            let descriptor = match &spec.pin {
                // Replaced post-manifest-read below.
                InstallPin::Version(_) => String::new(),
                InstallPin::Branch(name) => format!("branch:{name}"),
                InstallPin::Commit(_) => {
                    let short = commit.get(..7).unwrap_or(commit.as_str()).to_string();
                    format!("commit:{short}")
                }
                InstallPin::Local { .. } => {
                    unreachable!("Local pins refused at install_with entry")
                }
            };
            (commit, descriptor)
        } else {
            match &spec.pin {
                InstallPin::Version(req) => {
                    let tags = self.fetcher.list_tags(&bare).map_err(InstallError::Fetch)?;
                    let chosen =
                        best_match(&tags, req).ok_or_else(|| InstallError::NoMatchingVersion {
                            address: url.clone(),
                            req: req.to_string(),
                            available: tags.clone(),
                        })?;
                    let commit = self
                        .fetcher
                        .resolve(&bare, &RefSpec::Tag(chosen.tag.clone()))
                        .map_err(InstallError::Fetch)?;
                    (commit, chosen.tag)
                }
                InstallPin::Branch(name) => {
                    let commit = self
                        .fetcher
                        .resolve(&bare, &RefSpec::Branch(name.clone()))
                        .map_err(InstallError::Fetch)?;
                    (commit, format!("branch:{name}"))
                }
                InstallPin::Commit(sha) => {
                    let commit = self
                        .fetcher
                        .resolve(&bare, &RefSpec::Commit(sha.clone()))
                        .map_err(InstallError::Fetch)?;
                    let short = commit.get(..7).unwrap_or(commit.as_str()).to_string();
                    (commit, format!("commit:{short}"))
                }
                InstallPin::Local { .. } => {
                    unreachable!("Local pins refused at install_with entry")
                }
            }
        };

        // Read the manifest at this commit so we know the install dir name.
        let manifest_bytes = self
            .fetcher
            .show_blob(&bare, &commit, "pmacs.toml")
            .map_err(|e| match e {
                FetchError::GitInvocation { stderr, .. } => InstallError::ManifestMissing {
                    address: url.clone(),
                    tag: tag_descriptor.clone(),
                    cause: stderr,
                },
                other => InstallError::Fetch(other),
            })?;
        let manifest_str =
            std::str::from_utf8(&manifest_bytes).map_err(|_| InstallError::ManifestNotUtf8 {
                address: url.clone(),
                tag: tag_descriptor.clone(),
            })?;
        let manifest = PackageManifest::from_toml(manifest_str).map_err(InstallError::Manifest)?;

        // Synthesize the Version-pin descriptor now that we know the
        // manifest version. Mirrors the conventional `v{X.Y.Z}` form
        // produced by the standalone tag-matching path.
        let tag_descriptor =
            if opts.resolved_commit.is_some() && matches!(spec.pin, InstallPin::Version(_)) {
                format!("v{}", manifest.version)
            } else {
                tag_descriptor
            };

        // Refuse to install a package whose `pmacs_required` constraint
        // does not match the running pmacs version. Applies to every
        // pin kind: a package's declared API requirements are
        // independent of how the user pinned the revision.
        let running_pmacs = running_pmacs_version();
        if !manifest.pmacs_required.matches(&running_pmacs) {
            return Err(InstallError::PmacsVersionIncompatible {
                address: url.clone(),
                tag: tag_descriptor.clone(),
                required: manifest.pmacs_required.to_string(),
                running: running_pmacs.to_string(),
            });
        }

        // For version pins only: cross-check that the manifest's
        // declared version satisfies the constraint. The matched tag
        // already satisfies it (we chose it that way); the strict
        // check is on the manifest, which catches packages whose tag
        // and pmacs.toml version disagree. Branch and commit pins
        // skip this check --- the user explicitly asked for that
        // revision regardless of what the manifest says.
        if let InstallPin::Version(req) = &spec.pin
            && !req.matches(&manifest.version)
        {
            return Err(InstallError::ManifestVersionMismatch {
                address: url.clone(),
                tag: tag_descriptor.clone(),
                manifest_version: manifest.version.to_string(),
                req: req.to_string(),
            });
        }

        // Archive + extract.
        let install_root = self.install_root()?;
        let basename = package_basename(manifest.name.as_str());
        let install_path = install_root.join(basename);

        // If the install path already exists with the same commit,
        // treat as idempotent. With a different commit, behavior
        // depends on `opts.replace_existing`: the standalone install
        // path refuses (`pmacs.packages.install` callers reach this
        // when re-running install with a moved upstream and should
        // be told to use `update`); the resolver-driven update path
        // proceeds to a staged replacement.
        let mut needs_replace = false;
        if install_path.exists() {
            let existing = read_install_marker(&install_path).ok();
            match existing {
                Some(prev) if prev == commit => {
                    return Ok(InstalledPackage {
                        version: manifest.version.clone(),
                        manifest,
                        install_path,
                        commit,
                        tag: tag_descriptor,
                        scope: self.scope.clone(),
                        pin: spec.pin.clone(),
                    });
                }
                _ if opts.replace_existing => {
                    needs_replace = true;
                }
                _ => {
                    return Err(InstallError::AlreadyInstalled {
                        path: install_path,
                        existing_commit: existing,
                        requested_commit: commit,
                    });
                }
            }
        }

        let archive = self
            .fetcher
            .archive_commit(&bare, &commit)
            .map_err(InstallError::Fetch)?;

        // Staging path: when replacing, extract to a sibling dir and
        // only rename into place after success, so a failing replace
        // leaves the prior install untouched. Same-filesystem rename
        // makes the swap visible atomically; a crash between removing
        // the old dir and renaming the staged dir leaves the staged
        // dir in place, which is recoverable on next run.
        let extract_target = if needs_replace {
            let staged = install_path.with_extension("new");
            // A leftover staging dir from a prior crash would
            // confuse `create_dir_all` semantics; clear it first.
            if staged.exists() {
                fs::remove_dir_all(&staged).map_err(|source| InstallError::Io {
                    path: staged.clone(),
                    source,
                })?;
            }
            staged
        } else {
            install_path.clone()
        };

        fs::create_dir_all(&extract_target).map_err(|source| InstallError::Io {
            path: extract_target.clone(),
            source,
        })?;
        if let Err(e) = extract_tar(&archive, &extract_target) {
            // Roll back partial extraction. For the staging path
            // this leaves the prior install untouched; for the
            // direct path this leaves the install root clean.
            let _ = fs::remove_dir_all(&extract_target);
            return Err(e);
        }
        write_install_marker(&extract_target, &commit)?;

        if needs_replace {
            // Swap with rollback: rename the old install aside,
            // rename the staged dir into place, then remove the
            // backup. If the second rename fails, restore from the
            // backup so the prior install survives the failed
            // update. The two renames are individually atomic on the
            // same filesystem; the only window where neither dir
            // sits at `install_path` is between them, and a crash in
            // that window leaves both `.old` and `.new` siblings
            // for manual recovery.
            let backup = install_path.with_extension("old");
            // Clear any leftover backup from a prior crash.
            if backup.exists() {
                fs::remove_dir_all(&backup).map_err(|source| InstallError::Io {
                    path: backup.clone(),
                    source,
                })?;
            }
            fs::rename(&install_path, &backup).map_err(|source| InstallError::Io {
                path: install_path.clone(),
                source,
            })?;
            if let Err(source) = fs::rename(&extract_target, &install_path) {
                // Restore. If even the restore fails, surface the
                // original error --- the operator now needs to
                // manually swap `<path>.old` back into place, but
                // we've at least preserved the bytes.
                let _ = fs::rename(&backup, &install_path);
                return Err(InstallError::Io {
                    path: install_path.clone(),
                    source,
                });
            }
            // Both renames succeeded --- safe to drop the backup.
            // A failure here leaves `<path>.old` behind (best-
            // effort): the new install is correct on disk, just a
            // disk-space leak.
            let _ = fs::remove_dir_all(&backup);
        }

        Ok(InstalledPackage {
            version: manifest.version.clone(),
            manifest,
            install_path,
            commit,
            tag: tag_descriptor,
            scope: self.scope.clone(),
            pin: spec.pin.clone(),
        })
    }
}

// ---------------------------------------------------------------------------
// Tag selection
// ---------------------------------------------------------------------------

#[derive(Debug, Clone)]
struct TagMatch {
    tag: String,
    version: Version,
}

/// Pick the highest-numbered tag satisfying `req`.
///
/// Tags are filtered through [`parse_tag_as_semver`]: tags that don't
/// parse as semver are silently skipped (release notes, internal markers,
/// etc.). Tags with a leading `v` are accepted (the `v1.2.3` convention
/// is universal); the returned `tag` field preserves the original form.
fn best_match(tags: &[String], req: &VersionReq) -> Option<TagMatch> {
    let mut best: Option<TagMatch> = None;
    for tag in tags {
        let Some(version) = parse_tag_as_semver(tag) else {
            continue;
        };
        if !req.matches(&version) {
            continue;
        }
        match &best {
            None => {
                best = Some(TagMatch {
                    tag: tag.clone(),
                    version,
                });
            }
            Some(current) if version > current.version => {
                best = Some(TagMatch {
                    tag: tag.clone(),
                    version,
                });
            }
            _ => {}
        }
    }
    best
}

fn parse_tag_as_semver(tag: &str) -> Option<Version> {
    let stripped = tag.strip_prefix('v').unwrap_or(tag);
    Version::parse(stripped).ok()
}

/// Strip a `<owner>/` namespace prefix from a manifest name and
/// return the trailing segment used for the on-disk install dir
/// and for `require()` lookup. `"magit"` → `"magit"`,
/// `"user/magit"` → `"magit"`. The `pub(crate)` exposure lets
/// `lua_bindings::do_update` derive the basename for a lockfile
/// entry without re-implementing the rule.
pub(crate) fn package_basename(name: &str) -> &str {
    match name.rsplit_once('/') {
        Some((_, last)) => last,
        None => name,
    }
}

// ---------------------------------------------------------------------------
// tar extraction
// ---------------------------------------------------------------------------

/// Pipe `archive` into `tar -xC <dest>` and wait for completion.
///
/// `tar` is invoked with explicit options so it works the same on GNU
/// tar and BSD tar (macOS): `-x` extracts, `-f -` reads from stdin,
/// `-C <dest>` changes directory before extracting.
fn extract_tar(archive: &[u8], dest: &Path) -> Result<(), InstallError> {
    let mut cmd = Command::new("tar");
    cmd.arg("-x").arg("-f").arg("-").arg("-C").arg(dest);
    cmd.stdin(Stdio::piped());
    cmd.stdout(Stdio::piped());
    cmd.stderr(Stdio::piped());
    let mut child = cmd.spawn().map_err(|e| match e.kind() {
        io::ErrorKind::NotFound => InstallError::TarNotFound,
        _ => InstallError::TarSpawn { source: e },
    })?;
    {
        let mut stdin = child
            .stdin
            .take()
            .expect("tar stdin handle present after Stdio::piped");
        stdin
            .write_all(archive)
            .map_err(|source| InstallError::TarSpawn { source })?;
        // stdin drops here, closing the pipe so tar can finish.
    }
    let output = child
        .wait_with_output()
        .map_err(|source| InstallError::TarSpawn { source })?;
    if !output.status.success() {
        return Err(InstallError::TarFailed {
            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
        });
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Install marker
// ---------------------------------------------------------------------------

const MARKER_NAME: &str = ".pmacs-install";

/// Create a symlink at `link` pointing at `target`. Unix-only in
/// v0.1; pmacs doesn't ship Windows builds and `std::os::unix`'s
/// symlink semantics are what dired/wdired need (the link is the
/// thing being managed; the target is data).
fn symlink_create(target: &Path, link: &Path) -> Result<(), InstallError> {
    #[cfg(unix)]
    {
        std::os::unix::fs::symlink(target, link).map_err(|source| InstallError::Io {
            path: link.to_path_buf(),
            source,
        })
    }
    #[cfg(not(unix))]
    {
        let _ = (target, link);
        Err(InstallError::Io {
            path: link.to_path_buf(),
            source: io::Error::new(
                io::ErrorKind::Unsupported,
                "install_local requires Unix symlink support",
            ),
        })
    }
}

fn write_install_marker(install_path: &Path, commit: &str) -> Result<(), InstallError> {
    let p = install_path.join(MARKER_NAME);
    fs::write(&p, format!("{commit}\n")).map_err(|source| InstallError::Io { path: p, source })
}

fn read_install_marker(install_path: &Path) -> Result<String, io::Error> {
    let p = install_path.join(MARKER_NAME);
    let s = fs::read_to_string(&p)?;
    Ok(s.trim().to_string())
}

// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------

/// Errors raised during package install.
#[derive(Debug, Error)]
pub enum InstallError {
    /// `$XDG_DATA_HOME` and `$HOME` were both unset.
    #[error("cannot resolve XDG data directory: HOME and XDG_DATA_HOME are both unset")]
    NoDataHome,
    /// [`Installer::install`] / [`Installer::install_at_commit`] /
    /// [`Installer::replace_at_commit`] received an
    /// [`InstallPin::Local`]. Local pins must go through
    /// [`Installer::install_local`] (T M8.1c); routing them to the
    /// fetched-install path would require a clone URL that doesn't
    /// exist for working-tree installs.
    #[error(
        "InstallPin::Local cannot be installed via the fetched-install path; \
         use Installer::install_local for source path `{source_path}`"
    )]
    LocalPinNotSupported {
        /// The source path the Local pin named.
        source_path: PathBuf,
    },
    /// [`Installer::install_local`] was given a path that doesn't
    /// contain a readable `pmacs.toml`. The package layout
    /// requirements are documented in the package author guide; the
    /// user typically forgot to write the manifest or pointed at
    /// the wrong directory.
    #[error("install_local: no readable pmacs.toml at `{source_path}`: {cause}")]
    LocalManifestMissing {
        /// The source path the user passed.
        source_path: PathBuf,
        /// The underlying I/O or parse error message.
        cause: String,
    },
    /// [`Installer::install_local`] was asked to install at a name
    /// that already has a real (non-symlink) install. The user must
    /// uninstall the fetched copy first. We refuse rather than
    /// silently replace because losing a fetched-install tree is a
    /// real risk (it might contain manual edits the user made
    /// before discovering `install_local`).
    #[error(
        "install_local: `{install_path}` is a real install, not a symlink; \
         remove it first, then re-run install_local"
    )]
    LocalRealInstallInWay {
        /// The install dir that's blocking the new symlink.
        install_path: PathBuf,
    },
    /// Underlying fetch/clone/resolve operation failed.
    #[error(transparent)]
    Fetch(#[from] FetchError),
    /// Address parsing failed (typically only via `parse_shorthand`).
    #[error(transparent)]
    Address(AddressError),
    /// Manifest parsing failed.
    #[error(transparent)]
    Manifest(#[from] ManifestError),
    /// Generic I/O error during install.
    #[error("install I/O error at `{path}`: {source}")]
    Io {
        /// The path being acted on.
        path: PathBuf,
        /// The underlying I/O error.
        #[source]
        source: io::Error,
    },
    /// `pmacs.packages.install("addr@constraint")` lacked the `@`
    /// separator.
    #[error(
        "missing `@<version>` in shorthand `{input}`; \
         expected e.g. `github:user/repo@^1.0.0`"
    )]
    ShorthandMissingVersion {
        /// The offending input.
        input: String,
    },
    /// `parse_shorthand` got an `@<value>` that isn't a valid `VersionReq`.
    #[error("invalid version constraint `{value}`: {cause}")]
    InvalidVersionReq {
        /// The offending constraint string.
        value: String,
        /// The semver crate's parse-error message.
        cause: String,
    },
    /// No tag satisfied the user's [`VersionReq`].
    #[error(
        "no tag for `{address}` satisfies `{req}`. \
         Available tags: {available:?}"
    )]
    NoMatchingVersion {
        /// The clone URL the search ran against.
        address: String,
        /// The user's constraint, formatted for humans.
        req: String,
        /// The full tag list (filtered by the caller's eyes, not by
        /// semver-parseability).
        available: Vec<String>,
    },
    /// A repo was fetched and the tag resolved, but no `pmacs.toml`
    /// existed at that commit.
    #[error(
        "no pmacs.toml at tag `{tag}` of `{address}` \
         (this may not be a pmacs package): {cause}"
    )]
    ManifestMissing {
        /// The clone URL the search ran against.
        address: String,
        /// The tag the install would have used.
        tag: String,
        /// The git stderr message.
        cause: String,
    },
    /// `pmacs.toml` at the commit wasn't UTF-8.
    #[error("pmacs.toml at tag `{tag}` of `{address}` is not valid UTF-8")]
    ManifestNotUtf8 {
        /// The clone URL.
        address: String,
        /// The tag that was being installed.
        tag: String,
    },
    /// The manifest's `version` doesn't satisfy the user's constraint
    /// even though the tag did. Indicates a packaging bug upstream
    /// (tag and manifest disagree).
    #[error(
        "package at tag `{tag}` of `{address}` declares version \
         `{manifest_version}`, which does not satisfy `{req}` \
         (the upstream's tag and manifest disagree)"
    )]
    ManifestVersionMismatch {
        /// The clone URL.
        address: String,
        /// The tag that was matched.
        tag: String,
        /// The version declared in `pmacs.toml`.
        manifest_version: String,
        /// The user's constraint.
        req: String,
    },
    /// A different version of this package is already installed at the
    /// target path. Caller must `update` rather than `install`.
    #[error(
        "package already installed at `{path}` \
         (existing commit: {existing_commit:?}, requested: {requested_commit}); \
         use `pmacs.packages.update(\"...\")` to change versions"
    )]
    AlreadyInstalled {
        /// On-disk install dir.
        path: PathBuf,
        /// Commit hash recorded in the install marker, if readable.
        existing_commit: Option<String>,
        /// Commit hash that the new install would have used.
        requested_commit: String,
    },
    /// `tar` is not on PATH.
    #[error(
        "the `tar` binary was not found on PATH. \
         Pmacs's package install uses `git archive | tar -x` to \
         materialize a snapshot; install GNU tar or bsdtar."
    )]
    TarNotFound,
    /// `tar` failed to spawn or its stdin write failed.
    #[error("could not spawn or feed tar: {source}")]
    TarSpawn {
        /// The underlying I/O error.
        #[source]
        source: io::Error,
    },
    /// `tar` exited with a non-zero status.
    #[error("tar extraction failed: {stderr}")]
    TarFailed {
        /// Captured stderr from `tar`.
        stderr: String,
    },
    /// The package's `pmacs_required` constraint does not match the
    /// running pmacs version. The manifest field is a hard contract
    /// (the package may rely on APIs not present in this pmacs), so
    /// we refuse the install rather than letting the failure surface
    /// later as an opaque Lua runtime error.
    #[error(
        "package at tag `{tag}` of `{address}` requires pmacs `{required}`, \
         but this pmacs is `{running}`. \
         Upgrade pmacs, or pin a package version compatible with `{running}`."
    )]
    PmacsVersionIncompatible {
        /// The clone URL.
        address: String,
        /// The tag the install was about to use.
        tag: String,
        /// The constraint declared by the package's `pmacs_required`.
        required: String,
        /// The running pmacs version (`env!("CARGO_PKG_VERSION")`).
        running: String,
    },
}

// ---------------------------------------------------------------------------
// Running-pmacs-version probe
// ---------------------------------------------------------------------------

/// Return the running pmacs version as a [`semver::Version`].
///
/// The string comes from Cargo's `CARGO_PKG_VERSION` build-time env
/// var (so it tracks the workspace's `Cargo.toml` automatically).
/// `Cargo` enforces semver-shaped versions, so the parse cannot fail
/// in a release build; an `expect` here is a build-system invariant
/// guard, not user-reachable.
#[must_use]
pub fn running_pmacs_version() -> Version {
    Version::parse(env!("CARGO_PKG_VERSION"))
        .expect("CARGO_PKG_VERSION must be valid semver (Cargo guarantees this)")
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use std::ffi::OsStr;
    use std::process::Command as StdCommand;
    use tempfile::TempDir;

    // -- helpers ------------------------------------------------------------

    fn run_git(args: &[&OsStr], cwd: Option<&Path>) {
        let mut cmd = StdCommand::new("git");
        if let Some(d) = cwd {
            cmd.current_dir(d);
        }
        for a in args {
            cmd.arg(a);
        }
        cmd.env("GIT_TERMINAL_PROMPT", "0");
        cmd.env("LC_ALL", "C");
        let out = cmd.output().unwrap_or_else(|e| panic!("git spawn: {e}"));
        assert!(
            out.status.success(),
            "git {args:?} failed: {}",
            String::from_utf8_lossy(&out.stderr)
        );
    }

    /// Build a sample-package bare repo with two tagged versions and a
    /// `pmacs.toml` at each. Returns `(tempdir, bare_path)`.
    fn make_sample_package() -> (TempDir, PathBuf) {
        let td = tempfile::tempdir().unwrap();
        let work = td.path().join("work");
        let bare = td.path().join("upstream.git");

        init_git_workdir(&work);
        commit_version(&work, "samplepkg", "1.0.0", "v1.0.0");
        commit_version(&work, "samplepkg", "1.1.0", "v1.1.0");
        clone_bare(&work, &bare);

        (td, bare)
    }

    fn init_git_workdir(work: &Path) {
        run_git(
            &[
                OsStr::new("init"),
                OsStr::new("--initial-branch=main"),
                work.as_os_str(),
            ],
            None,
        );
        run_git_at(work, &["config", "user.email", "test@example.com"]);
        run_git_at(work, &["config", "user.name", "Tester"]);
    }

    fn commit_version(work: &Path, name: &str, version: &str, tag: &str) {
        write_manifest(work, name, version);
        std::fs::write(
            work.join("init.lua"),
            format!("return {{ version = '{version}' }}\n"),
        )
        .unwrap();
        run_git_at(work, &["add", "."]);
        run_git_at(work, &["commit", "-m", tag]);
        run_git_at(work, &["tag", tag]);
    }

    fn clone_bare(work: &Path, bare: &Path) {
        run_git(
            &[
                OsStr::new("clone"),
                OsStr::new("--bare"),
                work.as_os_str(),
                bare.as_os_str(),
            ],
            None,
        );
    }

    /// `git -C <work> <args...>` shorthand: prepends `-C` and the work
    /// path so each call site stays one line.
    fn run_git_at(work: &Path, args: &[&str]) {
        let mut full: Vec<&OsStr> = vec![OsStr::new("-C"), work.as_os_str()];
        full.extend(args.iter().map(OsStr::new));
        run_git(&full, None);
    }

    fn write_manifest(work: &Path, name: &str, version: &str) {
        let toml = format!(
            r#"name = "{name}"
version = "{version}"
summary = "test package"
pmacs_required = ">= 0.1.0"
entry = "init.lua"
exports = ["samplepkg"]
"#
        );
        std::fs::write(work.join("pmacs.toml"), toml).unwrap();
    }

    fn file_url(p: &Path) -> String {
        format!("file://{}", p.display())
    }

    fn make_installer(install_root: &Path) -> (TempDir, Installer) {
        let cache = tempfile::tempdir().unwrap();
        let fetcher = Fetcher::with_cache_dir(cache.path().to_path_buf());
        let scope = InstallScope::Project {
            project_root: install_root.to_path_buf(),
        };
        let installer = Installer::new(fetcher, scope);
        (cache, installer)
    }

    // -- shorthand ----------------------------------------------------------

    #[test]
    fn shorthand_parses_github_address_with_caret_constraint() {
        let s = InstallSpec::parse_shorthand("github:user/repo@^1.0.0").unwrap();
        assert!(matches!(s.address, Address::Github { .. }));
        match &s.pin {
            InstallPin::Version(req) => assert_eq!(req.to_string(), "^1.0.0"),
            other => panic!("expected Version pin, got {other:?}"),
        }
    }

    #[test]
    fn shorthand_handles_address_containing_at_sign() {
        // SSH shorthand: `git:git@host:path`. The `@` in `git@host`
        // must not be confused with the version separator.
        let s = InstallSpec::parse_shorthand("git:git@host:path/repo.git@=1.2.3").unwrap();
        match &s.pin {
            InstallPin::Version(req) => assert_eq!(req.to_string(), "=1.2.3"),
            other => panic!("expected Version pin, got {other:?}"),
        }
        if let Address::Url(u) = s.address {
            assert_eq!(u, "git@host:path/repo.git");
        } else {
            panic!("expected Url, got {:?}", s.address);
        }
    }

    #[test]
    fn shorthand_without_at_separator_errors() {
        let err = InstallSpec::parse_shorthand("github:user/repo").unwrap_err();
        assert!(matches!(err, InstallError::ShorthandMissingVersion { .. }));
    }

    #[test]
    fn shorthand_rejects_empty_version_after_at() {
        let err = InstallSpec::parse_shorthand("github:user/repo@").unwrap_err();
        assert!(matches!(err, InstallError::ShorthandMissingVersion { .. }));
    }

    #[test]
    fn shorthand_rejects_invalid_constraint() {
        let err = InstallSpec::parse_shorthand("github:user/repo@not-a-version").unwrap_err();
        assert!(matches!(err, InstallError::InvalidVersionReq { .. }));
    }

    // -- best_match ---------------------------------------------------------

    #[test]
    fn best_match_picks_highest_satisfying_caret() {
        let tags = vec![
            "v0.9.0".into(),
            "v1.0.0".into(),
            "v1.1.0".into(),
            "v2.0.0".into(),
        ];
        let req = VersionReq::parse("^1.0").unwrap();
        let m = best_match(&tags, &req).unwrap();
        assert_eq!(m.tag, "v1.1.0");
    }

    #[test]
    fn best_match_skips_non_semver_tags() {
        let tags = vec![
            "release-notes".into(),
            "v1.0.0".into(),
            "milestone-1".into(),
        ];
        let req = VersionReq::parse("*").unwrap();
        let m = best_match(&tags, &req).unwrap();
        assert_eq!(m.tag, "v1.0.0");
    }

    #[test]
    fn best_match_returns_none_when_no_tag_satisfies() {
        let tags = vec!["v1.0.0".into(), "v1.1.0".into()];
        let req = VersionReq::parse(">=2.0").unwrap();
        assert!(best_match(&tags, &req).is_none());
    }

    #[test]
    fn best_match_accepts_unprefixed_tags() {
        let tags = vec!["1.0.0".into(), "1.1.0".into()];
        let req = VersionReq::parse("^1.0").unwrap();
        let m = best_match(&tags, &req).unwrap();
        assert_eq!(m.tag, "1.1.0");
    }

    // -- package_basename ---------------------------------------------------

    #[test]
    fn package_basename_unwraps_namespace() {
        assert_eq!(package_basename("magit"), "magit");
        assert_eq!(package_basename("user/magit"), "magit");
    }

    // -- end-to-end install -------------------------------------------------

    #[test]
    fn install_picks_highest_tag_and_extracts_to_install_root() {
        let (_pkg_td, bare) = make_sample_package();
        let install_td = tempfile::tempdir().unwrap();
        let (_cache_td, installer) = make_installer(install_td.path());

        let spec = InstallSpec {
            address: Address::Url(file_url(&bare)),
            pin: InstallPin::Version(VersionReq::parse("^1.0").unwrap()),
        };
        let installed = installer.install(&spec).unwrap();

        // Picked the higher tag (1.1.0 over 1.0.0).
        assert_eq!(installed.tag, "v1.1.0");
        assert_eq!(installed.version.to_string(), "1.1.0");

        // Install dir uses the package basename.
        assert!(
            installed
                .install_path
                .ends_with(".pmacs/packages/samplepkg")
        );

        // Entry module exists at the expected path.
        let entry = installed.entry_path();
        assert!(
            entry.exists(),
            "entry file `{}` should exist",
            entry.display()
        );
        let entry_text = std::fs::read_to_string(&entry).unwrap();
        assert!(entry_text.contains("1.1.0"));

        // Manifest snapshot is present (extraction included pmacs.toml).
        assert!(installed.install_path.join("pmacs.toml").exists());

        // Marker file records the commit.
        let marker = installed.install_path.join(".pmacs-install");
        let recorded = std::fs::read_to_string(&marker).unwrap();
        assert_eq!(recorded.trim(), installed.commit);
    }

    #[test]
    fn install_resolves_exact_pin_to_lower_version() {
        let (_pkg_td, bare) = make_sample_package();
        let install_td = tempfile::tempdir().unwrap();
        let (_cache_td, installer) = make_installer(install_td.path());

        let spec = InstallSpec {
            address: Address::Url(file_url(&bare)),
            pin: InstallPin::Version(VersionReq::parse("=1.0.0").unwrap()),
        };
        let installed = installer.install(&spec).unwrap();
        assert_eq!(installed.tag, "v1.0.0");
        assert_eq!(installed.version.to_string(), "1.0.0");
    }

    #[test]
    fn install_no_matching_tag_errors_with_listing() {
        let (_pkg_td, bare) = make_sample_package();
        let install_td = tempfile::tempdir().unwrap();
        let (_cache_td, installer) = make_installer(install_td.path());

        let spec = InstallSpec {
            address: Address::Url(file_url(&bare)),
            pin: InstallPin::Version(VersionReq::parse(">=2.0.0").unwrap()),
        };
        let err = installer.install(&spec).unwrap_err();
        match err {
            InstallError::NoMatchingVersion { available, .. } => {
                assert!(available.iter().any(|t| t == "v1.0.0"));
                assert!(available.iter().any(|t| t == "v1.1.0"));
            }
            other => panic!("expected NoMatchingVersion, got {other:?}"),
        }
    }

    #[test]
    fn install_idempotent_for_same_commit() {
        let (_pkg_td, bare) = make_sample_package();
        let install_td = tempfile::tempdir().unwrap();
        let (_cache_td, installer) = make_installer(install_td.path());

        let spec = InstallSpec {
            address: Address::Url(file_url(&bare)),
            pin: InstallPin::Version(VersionReq::parse("=1.0.0").unwrap()),
        };
        let first = installer.install(&spec).unwrap();
        // Drop a sentinel; idempotent re-install should not blow it away.
        let sentinel = first.install_path.join("PMACS_TEST_SENTINEL");
        std::fs::write(&sentinel, b"x").unwrap();
        let second = installer.install(&spec).unwrap();
        assert_eq!(first.commit, second.commit);
        assert!(
            sentinel.exists(),
            "idempotent re-install must not re-extract"
        );
    }

    #[test]
    fn install_rejects_overwrite_when_commit_differs() {
        let (_pkg_td, bare) = make_sample_package();
        let install_td = tempfile::tempdir().unwrap();
        let (_cache_td, installer) = make_installer(install_td.path());

        // First install at 1.0.0.
        let spec_v1 = InstallSpec {
            address: Address::Url(file_url(&bare)),
            pin: InstallPin::Version(VersionReq::parse("=1.0.0").unwrap()),
        };
        installer.install(&spec_v1).unwrap();

        // Second install at 1.1.0 to the same install path: refuse.
        let spec_v2 = InstallSpec {
            address: Address::Url(file_url(&bare)),
            pin: InstallPin::Version(VersionReq::parse("=1.1.0").unwrap()),
        };
        let err = installer.install(&spec_v2).unwrap_err();
        assert!(matches!(err, InstallError::AlreadyInstalled { .. }));
    }

    #[test]
    fn project_and_user_scopes_have_distinct_install_roots() {
        // Sanity check: same Fetcher under two scopes resolves two
        // different roots, so user-config and project installs cannot
        // collide.
        let cache = tempfile::tempdir().unwrap();
        let proj = tempfile::tempdir().unwrap();
        let fetcher = Fetcher::with_cache_dir(cache.path().to_path_buf());

        let proj_inst = Installer::new(
            fetcher.clone(),
            InstallScope::Project {
                project_root: proj.path().to_path_buf(),
            },
        );
        let proj_root = proj_inst.scope.resolve_root().unwrap();
        assert!(proj_root.starts_with(proj.path()));

        // The user-scope path depends on the test's environment
        // (XDG_DATA_HOME / HOME). We just verify it differs from the
        // project root, not its specific layout.
        let user_inst = Installer::new(fetcher, InstallScope::User);
        if let Ok(user_root) = user_inst.scope.resolve_root() {
            assert_ne!(user_root, proj_root);
        }
    }

    /// Build a sample-package bare repo whose `pmacs_required` is
    /// pinned to `pmacs_required`. Used by the version-incompat test
    /// to mint a manifest the running pmacs cannot satisfy.
    fn make_pinned_pmacs_package(pmacs_required: &str) -> (TempDir, PathBuf) {
        let td = tempfile::tempdir().unwrap();
        let work = td.path().join("work");
        let bare = td.path().join("upstream.git");

        init_git_workdir(&work);
        let toml = format!(
            r#"name = "samplepkg"
version = "1.0.0"
summary = "test package"
pmacs_required = "{pmacs_required}"
entry = "init.lua"
exports = ["samplepkg"]
"#
        );
        std::fs::write(work.join("pmacs.toml"), toml).unwrap();
        std::fs::write(work.join("init.lua"), "return { version = '1.0.0' }\n").unwrap();
        run_git_at(&work, &["add", "."]);
        run_git_at(&work, &["commit", "-m", "v1.0.0"]);
        run_git_at(&work, &["tag", "v1.0.0"]);
        clone_bare(&work, &bare);

        (td, bare)
    }

    #[test]
    fn install_rejects_unsatisfiable_pmacs_required() {
        // `pmacs_required = ">= 99.0.0"` cannot be satisfied by any
        // 0.x or 1.x pmacs build, so this is a stable
        // running-version-independent assertion: the install path
        // surfaces a typed `PmacsVersionIncompatible` error rather
        // than letting the package's API mismatch bite at runtime.
        let (_pkg, bare) = make_pinned_pmacs_package(">= 99.0.0");
        let install_td = tempfile::tempdir().unwrap();
        let (_cache, installer) = make_installer(install_td.path());

        let spec = InstallSpec {
            address: Address::Url(file_url(&bare)),
            pin: InstallPin::Version(VersionReq::parse("^1.0.0").unwrap()),
        };

        match installer.install(&spec).unwrap_err() {
            InstallError::PmacsVersionIncompatible {
                required, running, ..
            } => {
                assert!(
                    required.contains("99"),
                    "error must echo the failing constraint: required={required}"
                );
                assert_eq!(running, env!("CARGO_PKG_VERSION"));
            }
            other => panic!("expected PmacsVersionIncompatible, got {other:?}"),
        }
    }

    #[test]
    fn install_accepts_satisfiable_pmacs_required() {
        // The other side of the gate: a constraint that *is*
        // satisfied by the running build proceeds normally. Pin the
        // constraint to the running version exactly so the check is
        // tight (matches `=X.Y.Z`).
        let (_pkg, bare) = make_pinned_pmacs_package(&format!("={}", env!("CARGO_PKG_VERSION")));
        let install_td = tempfile::tempdir().unwrap();
        let (_cache, installer) = make_installer(install_td.path());

        let spec = InstallSpec {
            address: Address::Url(file_url(&bare)),
            pin: InstallPin::Version(VersionReq::parse("^1.0.0").unwrap()),
        };
        installer
            .install(&spec)
            .expect("matching pmacs_required must install");
    }

    #[test]
    fn running_pmacs_version_matches_cargo_pkg_version() {
        // Self-test for the helper. If Cargo ever changes the env-var
        // shape or someone introduces a non-semver suffix, this fires
        // before the install path does.
        let v = running_pmacs_version();
        assert_eq!(v.to_string(), env!("CARGO_PKG_VERSION"));
    }
}
