// packages/lockfile.rs --- Lockfile generation, parsing, verification (T M7.6).

//! Lockfile (T M7.6, spec §sec:packages-future).
//!
//! The lockfile (`pmacs.lock`) is the matching manifest-of-fact for a
//! [`ResolvePlan`]: exact commit hashes, resolved versions, transitive
//! closure, and per-commit content hashes. Two users with the same
//! lockfile install identical bytes; a tampered or hijacked Git host
//! produces a verifiable hash mismatch.
//!
//! # Wire format
//!
//! TOML, alphabetical-by-name `[[package]]` entries. Choosing
//! alphabetical (rather than topological) on disk: lockfile diffs in
//! source-control reviews stay readable when only one package's
//! version changes. Topological order, when needed, is recomputed by
//! consumers from the per-entry `dependencies` list.
//!
//! Schema version is `1`. Bumps are reserved for incompatible format
//! changes; additive fields can land without a bump if older readers
//! tolerate `#[serde(default)]`.
//!
//! # Content hashing
//!
//! The hash is SHA-256 over the bytes of `git archive --format=tar
//! <commit>`. `git archive` is deterministic for a given commit (it
//! emits canonical tar with sorted entries and the commit timestamp
//! as mtime), so the same commit always produces the same hash on any
//! machine. The hash is encoded `sha256:<64 lowercase hex>`.
//!
//! ## Why SHA-256 over `git archive`, not Git's own SHA-1
//!
//! Git's content addressing already verifies that a fetched commit's
//! tree matches the SHA-1 it was advertised as. But SHA-1 collisions
//! are computationally feasible in 2026; an attacker who controls the
//! upstream host can serve a different tree under the same SHA-1. The
//! spec's threat model is "tampered or hijacked Git host produces a
//! verifiable mismatch," which requires a stronger hash than SHA-1.
//! SHA-256 over the materialized tree closes that gap.
//!
//! ## Why archive bytes, not a recursive directory walk
//!
//! `git archive --format=tar` already canonicalizes (sorted entries,
//! file modes, paths, contents) and is part of every Git
//! installation. Reusing it costs one extra subprocess but avoids us
//! reimplementing canonical-tree hashing in Rust, which is a footgun
//! (mode handling, symlink targets, submodules — none of these have
//! obvious right-answers).
//!
//! # Lockfile-constrained resolution
//!
//! [`UpdatePolicy`] lets callers control how a fresh resolve interacts
//! with an existing lockfile:
//!
//! - [`UpdatePolicy::Frozen`] — no upstream re-resolution; the plan
//!   comes straight from the lockfile entries. Hash verification still
//!   runs at install time. This is the default "subsequent install"
//!   path.
//! - [`UpdatePolicy::UpdateAll`] — ignore the lockfile entirely;
//!   resolve fresh against current upstream state. Maps to
//!   `pmacs.packages.update()` (no argument).
//! - [`UpdatePolicy::UpdateOne(name)`] — re-resolve `name` fresh; for
//!   every other package, prefer the lockfile's recorded version. Maps
//!   to `pmacs.packages.update("name")`. The "prefer" semantic
//!   (rather than "force") means a constraint cascade triggered by
//!   `name`'s update can still bump unrelated packages — that's the
//!   user-visible behavior they expect from a partial-update.

// `LockfileError` carries diagnostic context (full hashes, URLs,
// commits, lists of participants) by design — the user-facing
// `Display` for `ContentHashMismatch` alone needs every field. Boxing
// the error to placate `result_large_err` would only move the cost
// without changing the user-visible surface, mirroring the same
// posture the resolver module takes for `ResolveError`.
#![allow(clippy::result_large_err)]

use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

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

use super::address::Address;
use super::fetcher::{FetchError, Fetcher, RefSpec};
use super::installer::InstallPin;
use super::manifest::PackageName;
use super::resolver::{
    ResolvePlan, ResolvedPackage, find_basename_collision, format_package_name_list,
};

/// Schema version emitted by [`Lockfile::to_bytes`]. Bumped only on
/// incompatible format changes.
pub const LOCKFILE_SCHEMA_VERSION: u32 = 1;

/// Default filename for the lockfile relative to a project root.
pub const LOCKFILE_FILENAME: &str = "pmacs.lock";

// ---------------------------------------------------------------------------
// Lockfile types
// ---------------------------------------------------------------------------

/// On-disk lockfile.
///
/// Construct from a [`ResolvePlan`] via [`Lockfile::from_plan`], parse
/// from bytes via [`Lockfile::parse`], emit deterministic bytes via
/// [`Lockfile::to_bytes`].
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct Lockfile {
    /// Schema version. See [`LOCKFILE_SCHEMA_VERSION`].
    pub schema_version: u32,
    /// Generator string, e.g. `"pmacs 0.1.0"`. Informational only;
    /// readers do not branch on it.
    pub generator: String,
    /// Package entries, sorted alphabetically by [`PackageName`].
    #[serde(rename = "package", default)]
    pub packages: Vec<LockfileEntry>,
}

/// One entry in a [`Lockfile`].
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct LockfileEntry {
    /// Canonical package name from the resolved manifest.
    pub name: PackageName,
    /// Canonical Git clone URL (the result of [`Address::to_git_url`]
    /// at resolution time). Stored as a string so the lockfile is
    /// independent of any future `Address` enum-shape changes.
    pub url: String,
    /// 40-character commit hash. Always a real SHA, never a tag string.
    pub commit: String,
    /// Version parsed from the manifest at `commit`.
    pub version: Version,
    /// Content hash in `sha256:<hex>` form. Verified at install time.
    pub content_hash: ContentHash,
    /// `Some` for top-level packages, `None` for transitive deps.
    /// Tells [`UpdatePolicy::UpdateOne`] which lockfile entries
    /// originated from a top-level user request and which were pulled
    /// in transitively.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub top_level_pin: Option<LockfilePin>,
    /// Immediate dependency names (sorted). The closure is implied by
    /// walking these edges across all entries.
    #[serde(default)]
    pub dependencies: Vec<PackageName>,
}

/// Mirror of [`InstallPin`] with a flat TOML representation.
///
/// Serializes as `{ kind = "version" | "branch" | "commit", value =
/// "<string>" }`. The wider [`InstallPin`] enum carries a parsed
/// [`VersionReq`] internally; the lockfile stores its `Display` form
/// so the file is independent of `semver` crate-version drift.
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "lowercase")]
pub enum LockfilePin {
    /// Version constraint, e.g. `"^1.0.0"`.
    Version(String),
    /// Branch name.
    Branch(String),
    /// Specific commit (the SHA the branch / commit pin originally
    /// resolved to).
    Commit(String),
}

impl LockfilePin {
    /// Convert from a runtime [`InstallPin`].
    ///
    /// Returns `None` for [`InstallPin::Local`]: `install_local`
    /// (T M8.1c) installs a working-tree symlink and is explicitly
    /// ephemeral --- it doesn't belong in the lockfile because the
    /// lockfile's contract is reproducibility across machines, and
    /// a symlink to `/srv/dev/pmacs-dired` doesn't reproduce
    /// anywhere else. Callers building lockfile entries skip the
    /// `top_level_pin` slot when this returns `None`; the entry
    /// itself isn't built at all because `install_local` doesn't
    /// go through the resolver.
    #[must_use]
    pub fn from_install_pin(pin: &InstallPin) -> Option<Self> {
        match pin {
            InstallPin::Version(req) => Some(Self::Version(req.to_string())),
            InstallPin::Branch(b) => Some(Self::Branch(b.clone())),
            InstallPin::Commit(c) => Some(Self::Commit(c.clone())),
            InstallPin::Local { .. } => None,
        }
    }

    /// Convert back to a runtime [`InstallPin`]. The stored
    /// `Version(_)` string is re-parsed; an unparseable value
    /// surfaces as [`LockfileError::InvalidVersionConstraint`].
    pub fn to_install_pin(&self) -> Result<InstallPin, LockfileError> {
        match self {
            Self::Version(s) => {
                let req = VersionReq::parse(s).map_err(|cause| {
                    LockfileError::InvalidVersionConstraint {
                        value: s.clone(),
                        cause: cause.to_string(),
                    }
                })?;
                Ok(InstallPin::Version(req))
            }
            Self::Branch(b) => Ok(InstallPin::Branch(b.clone())),
            Self::Commit(c) => Ok(InstallPin::Commit(c.clone())),
        }
    }
}

fn lockfile_save_error(path: &Path, err: crate::file_io::SaveError) -> LockfileError {
    let source = match err {
        crate::file_io::SaveError::Io(source) => source,
        crate::file_io::SaveError::NoParent(target) => io::Error::new(
            io::ErrorKind::InvalidInput,
            format!(
                "lockfile path has no parent directory: {}",
                target.display()
            ),
        ),
    };
    LockfileError::Io {
        path: path.to_path_buf(),
        source,
    }
}

/// Content hash carried by every [`LockfileEntry`].
///
/// Serialized as a single string `<algorithm>:<hex>` (e.g.
/// `"sha256:abc..."`). The algorithm field exists for future-proofing
/// — v1.0 only emits and accepts `sha256`.
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ContentHash {
    /// Hash algorithm name. v1.0: always `"sha256"`.
    pub algorithm: String,
    /// Lowercase hex digest.
    pub hex: String,
}

impl ContentHash {
    /// Construct a SHA-256 hash from raw bytes.
    #[must_use]
    pub fn sha256_of(bytes: &[u8]) -> Self {
        let mut hasher = Sha256::new();
        hasher.update(bytes);
        let digest = hasher.finalize();
        Self {
            algorithm: "sha256".to_string(),
            hex: hex_encode(&digest),
        }
    }

    /// Render as the on-disk wire string: `"<algorithm>:<hex>"`.
    #[must_use]
    pub fn to_wire_string(&self) -> String {
        format!("{}:{}", self.algorithm, self.hex)
    }

    /// Parse a wire string back to a [`ContentHash`].
    pub fn parse(s: &str) -> Result<Self, LockfileError> {
        let (algo, hex) = s
            .split_once(':')
            .ok_or_else(|| LockfileError::MalformedContentHash {
                value: s.to_string(),
                reason: "expected `<algorithm>:<hex>`".into(),
            })?;
        if algo != "sha256" {
            return Err(LockfileError::MalformedContentHash {
                value: s.to_string(),
                reason: format!("unsupported hash algorithm `{algo}`"),
            });
        }
        if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
            return Err(LockfileError::MalformedContentHash {
                value: s.to_string(),
                reason: "sha256 hex must be 64 lowercase hex chars".into(),
            });
        }
        // Normalize to lowercase to keep round-tripping byte-stable.
        Ok(Self {
            algorithm: algo.to_string(),
            hex: hex.to_ascii_lowercase(),
        })
    }
}

impl Serialize for ContentHash {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&self.to_wire_string())
    }
}

impl<'de> Deserialize<'de> for ContentHash {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        Self::parse(&s).map_err(serde::de::Error::custom)
    }
}

fn hex_encode(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(bytes.len() * 2);
    for &b in bytes {
        out.push(HEX[(b >> 4) as usize] as char);
        out.push(HEX[(b & 0x0f) as usize] as char);
    }
    out
}

// ---------------------------------------------------------------------------
// UpdatePolicy
// ---------------------------------------------------------------------------

/// How a lockfile-aware resolve interacts with the lockfile.
///
/// See module docs for semantics.
#[derive(Debug, Clone)]
pub enum UpdatePolicy {
    /// Use the lockfile as a hard pin: each top-level request resolves
    /// to its lockfile entry's commit; transitive entries also come
    /// straight from the lockfile. Effectively zero-network
    /// (post-fetch).
    Frozen,
    /// Ignore the lockfile; fully re-resolve against current upstream.
    UpdateAll,
    /// Re-resolve only the named package (and any cascade its update
    /// triggers); for every other package, prefer the lockfile's
    /// recorded version when current constraints still allow it.
    UpdateOne(PackageName),
}

// ---------------------------------------------------------------------------
// Lockfile construction & I/O
// ---------------------------------------------------------------------------

impl Lockfile {
    /// Generate a fresh lockfile from a [`ResolvePlan`].
    ///
    /// For each plan entry, resolves the chosen commit-ish to a 40-char
    /// SHA (so tag-pinned entries become commit-pinned in the lock)
    /// and computes its content hash. The entries are sorted
    /// alphabetically by [`PackageName`] for diff stability.
    pub fn from_plan(plan: &ResolvePlan, fetcher: &Fetcher) -> Result<Self, LockfileError> {
        // Map each plan entry by canonical URL so transitive `dependencies`
        // edges (which carry an address string, not a name) can be
        // canonicalized to package names for the lockfile entry.
        let mut url_to_name: BTreeMap<String, PackageName> = BTreeMap::new();
        for rp in &plan.packages {
            url_to_name.insert(rp.address.to_git_url(), rp.name.clone());
        }

        let mut entries: Vec<LockfileEntry> = Vec::with_capacity(plan.packages.len());

        for rp in &plan.packages {
            entries.push(build_entry(rp, &url_to_name, fetcher)?);
        }

        // Sort alphabetically by name for diff stability.
        entries.sort_by(|a, b| a.name.cmp(&b.name));

        Ok(Self {
            schema_version: LOCKFILE_SCHEMA_VERSION,
            generator: format!("pmacs {}", env!("CARGO_PKG_VERSION")),
            packages: entries,
        })
    }

    /// Serialize to canonical TOML bytes.
    ///
    /// Deterministic for a given `Lockfile` instance: entry order is
    /// fixed by [`Lockfile::from_plan`], dependency lists are sorted,
    /// and all maps go through `BTreeMap`. Two `from_plan` calls
    /// against the same plan produce byte-identical output.
    pub fn to_bytes(&self) -> Result<Vec<u8>, LockfileError> {
        let s = toml::to_string(self).map_err(LockfileError::Serialize)?;
        Ok(s.into_bytes())
    }

    /// Parse from on-disk TOML bytes.
    pub fn parse(bytes: &[u8]) -> Result<Self, LockfileError> {
        let text = std::str::from_utf8(bytes).map_err(|_| LockfileError::NotUtf8)?;
        let lock: Self = toml::from_str(text).map_err(LockfileError::Parse)?;
        if lock.schema_version != LOCKFILE_SCHEMA_VERSION {
            return Err(LockfileError::SchemaVersion {
                expected: LOCKFILE_SCHEMA_VERSION,
                found: lock.schema_version,
            });
        }
        Ok(lock)
    }

    /// Read a lockfile from disk.
    pub fn read_from(path: &Path) -> Result<Self, LockfileError> {
        let bytes = fs::read(path).map_err(|source| LockfileError::Io {
            path: path.to_path_buf(),
            source,
        })?;
        Self::parse(&bytes)
    }

    /// Write to disk, creating parent dirs as needed.
    ///
    /// The final file replacement is atomic: bytes are written to a
    /// sibling temp file and renamed over `path`, so a failed write
    /// leaves the previous lockfile intact rather than truncated.
    pub fn write_to(&self, path: &Path) -> Result<(), LockfileError> {
        let bytes = self.to_bytes()?;
        Self::write_bytes_to(path, &bytes)
    }

    /// Write pre-serialized lockfile bytes to disk atomically.
    pub fn write_bytes_to(path: &Path, bytes: &[u8]) -> Result<(), LockfileError> {
        if let Some(parent) = path.parent()
            && !parent.as_os_str().is_empty()
        {
            fs::create_dir_all(parent).map_err(|source| LockfileError::Io {
                path: parent.to_path_buf(),
                source,
            })?;
        }
        crate::file_io::save_atomic(path, bytes)
            .map(|_| ())
            .map_err(|err| lockfile_save_error(path, err))?;
        Ok(())
    }

    /// Look up a lockfile entry by package name.
    #[must_use]
    pub fn entry(&self, name: &PackageName) -> Option<&LockfileEntry> {
        self.packages.iter().find(|e| &e.name == name)
    }

    /// Look up a lockfile entry by canonical clone URL.
    #[must_use]
    pub fn entry_by_url(&self, url: &str) -> Option<&LockfileEntry> {
        self.packages.iter().find(|e| e.url == url)
    }

    /// Verify that fetching `entry.commit` from the upstream and
    /// hashing the resulting tree produces `entry.content_hash`.
    ///
    /// Caller's responsibility to have already invoked [`Fetcher::fetch`]
    /// for the entry's URL; this function operates on the local cache.
    pub fn verify_entry(
        &self,
        entry: &LockfileEntry,
        fetcher: &Fetcher,
    ) -> Result<(), LockfileError> {
        let repo = fetcher
            .fetch(&entry.url)
            .map_err(|source| LockfileError::Fetch {
                url: entry.url.clone(),
                source,
            })?;
        let archive = fetcher
            .archive_commit(&repo, &entry.commit)
            .map_err(|source| LockfileError::Fetch {
                url: entry.url.clone(),
                source,
            })?;
        let observed = ContentHash::sha256_of(&archive);
        if observed == entry.content_hash {
            Ok(())
        } else {
            Err(LockfileError::ContentHashMismatch {
                name: entry.name.clone(),
                url: entry.url.clone(),
                commit: entry.commit.clone(),
                expected: entry.content_hash.clone(),
                observed,
            })
        }
    }

    /// Convert a (verified) lockfile directly into a [`ResolvePlan`],
    /// without any upstream re-resolution.
    ///
    /// Used by [`UpdatePolicy::Frozen`]. Reads each entry's manifest
    /// from the local cache (the manifest is needed downstream by the
    /// loader to compute exports and `entry`); no tag enumeration, no
    /// `pmacs_required` re-check (the resolver that produced this
    /// lockfile already verified compatibility).
    ///
    /// Each entry's content hash is verified against the upstream
    /// before its manifest is read. This is how a tampered or
    /// drift-prone cache is detected: if the bytes the upstream now
    /// serves at the recorded commit don't hash to the recorded
    /// content hash, the call fails with
    /// [`LockfileError::ContentHashMismatch`] and the install
    /// aborts. The check matches the lockfile's value proposition
    /// (reproducible installs across machines) on the install path
    /// itself, not just at lockfile-write time.
    pub fn to_resolve_plan(&self, fetcher: &Fetcher) -> Result<ResolvePlan, LockfileError> {
        // Reject basename collisions up front (audit F-005), before any
        // fetch: a hand-edited or pre-existing lockfile with two distinct
        // packages sharing a basename (e.g. `owner/magit` + `other/magit`)
        // would otherwise install both to `<root>/<basename>`. The
        // fresh-resolve path guards this identically in `into_plan`.
        if let Some((basename, names)) =
            find_basename_collision(self.packages.iter().map(|e| e.name.clone()))
        {
            return Err(LockfileError::BasenameCollision { basename, names });
        }
        let mut packages = Vec::with_capacity(self.packages.len());
        for entry in &self.packages {
            // Verify before reading the manifest. `verify_entry`
            // calls `fetcher.fetch` itself, so the cache is warm
            // for the subsequent `show_blob`.
            self.verify_entry(entry, fetcher)?;
            let repo = fetcher
                .fetch(&entry.url)
                .map_err(|source| LockfileError::Fetch {
                    url: entry.url.clone(),
                    source,
                })?;
            let bytes = fetcher
                .show_blob(&repo, &entry.commit, "pmacs.toml")
                .map_err(|source| LockfileError::Fetch {
                    url: entry.url.clone(),
                    source,
                })?;
            let text = std::str::from_utf8(&bytes).map_err(|_| LockfileError::ManifestNotUtf8 {
                url: entry.url.clone(),
                commit: entry.commit.clone(),
            })?;
            let manifest = super::manifest::PackageManifest::from_toml(text).map_err(|source| {
                LockfileError::Manifest {
                    url: entry.url.clone(),
                    commit: entry.commit.clone(),
                    message: source.to_string(),
                }
            })?;
            // The lockfile stores a canonical clone URL (the result of
            // `Address::to_git_url`). Re-parsing it via `Address::parse`
            // is wrong: `file://` and similar schemes that Address only
            // accepts behind a `git:` prefix would fail. Wrap the URL
            // directly as `Address::Url` — this preserves the
            // round-trip identity for downstream fetcher calls.
            let address = Address::Url(entry.url.clone());
            let top_level_pin = entry
                .top_level_pin
                .as_ref()
                .map(LockfilePin::to_install_pin)
                .transpose()?;

            packages.push(ResolvedPackage {
                name: entry.name.clone(),
                address,
                // A lockfile entry's `commit` is a real SHA, a valid
                // commit-ish for the renamed field (audit F-011).
                revision: entry.commit.clone(),
                version: entry.version.clone(),
                manifest,
                top_level_pin,
            });
        }
        // Lockfile entries are alphabetical on disk; downstream
        // consumers want topological order. Sort using each entry's
        // recorded `dependencies` list as the edges.
        let order = topo_sort(&self.packages)?;
        let mut by_name: BTreeMap<PackageName, ResolvedPackage> =
            packages.into_iter().map(|p| (p.name.clone(), p)).collect();
        let topo: Vec<ResolvedPackage> = order
            .into_iter()
            .map(|name| {
                by_name
                    .remove(&name)
                    .expect("topo_sort returns only known names")
            })
            .collect();
        Ok(ResolvePlan { packages: topo })
    }
}

fn build_entry(
    rp: &ResolvedPackage,
    url_to_name: &BTreeMap<String, PackageName>,
    fetcher: &Fetcher,
) -> Result<LockfileEntry, LockfileError> {
    let url = rp.address.to_git_url();

    let repo = fetcher.fetch(&url).map_err(|source| LockfileError::Fetch {
        url: url.clone(),
        source,
    })?;

    // Resolve the chosen commit-ish (which may be a tag string for
    // version pins) to a 40-char SHA.
    let sha = fetcher
        .resolve(&repo, &RefSpec::Commit(rp.revision.clone()))
        .map_err(|source| LockfileError::Fetch {
            url: url.clone(),
            source,
        })?;

    // Hash the canonical archive at the resolved SHA.
    let archive = fetcher
        .archive_commit(&repo, &sha)
        .map_err(|source| LockfileError::Fetch {
            url: url.clone(),
            source,
        })?;
    let content_hash = ContentHash::sha256_of(&archive);

    // Immediate dependency names, sorted. Resolve manifest dep
    // addresses to canonical clone URLs, then look up the chosen name.
    let mut deps: BTreeSet<PackageName> = BTreeSet::new();
    for d in &rp.manifest.dependencies {
        // Stale manifest with malformed address: the resolver would
        // have errored earlier, so silently skip rather than fail
        // lockfile generation on a defensive path.
        let Ok(dep_addr) = Address::parse(&d.address) else {
            continue;
        };
        let dep_url = dep_addr.to_git_url();
        if let Some(name) = url_to_name.get(&dep_url) {
            deps.insert(name.clone());
        }
    }

    Ok(LockfileEntry {
        name: rp.name.clone(),
        url,
        commit: sha,
        version: rp.version.clone(),
        content_hash,
        top_level_pin: rp
            .top_level_pin
            .as_ref()
            .and_then(LockfilePin::from_install_pin),
        dependencies: deps.into_iter().collect(),
    })
}

/// Kahn's topological sort over lockfile entries' `dependencies`
/// lists. Deterministic via `BTreeSet` ready set.
fn topo_sort(entries: &[LockfileEntry]) -> Result<Vec<PackageName>, LockfileError> {
    let names: BTreeSet<PackageName> = entries.iter().map(|e| e.name.clone()).collect();
    let mut indegree: BTreeMap<PackageName, usize> = entries
        .iter()
        .map(|e| {
            let count = e.dependencies.iter().filter(|d| names.contains(*d)).count();
            (e.name.clone(), count)
        })
        .collect();

    let mut dependents: BTreeMap<PackageName, BTreeSet<PackageName>> = BTreeMap::new();
    for e in entries {
        for d in &e.dependencies {
            if names.contains(d) {
                dependents
                    .entry(d.clone())
                    .or_default()
                    .insert(e.name.clone());
            }
        }
    }

    let mut ready: BTreeSet<PackageName> = indegree
        .iter()
        .filter(|&(_, d)| *d == 0)
        .map(|(n, _)| n.clone())
        .collect();
    let mut order: Vec<PackageName> = Vec::with_capacity(entries.len());
    let mut q: VecDeque<PackageName> = ready.iter().cloned().collect();
    ready.clear();

    while let Some(next) = q.pop_front() {
        order.push(next.clone());
        if let Some(succs) = dependents.get(&next) {
            // Buffer succs into ready (BTreeSet) for deterministic order.
            for x in succs {
                if let Some(d) = indegree.get_mut(x) {
                    *d = d.saturating_sub(1);
                    if *d == 0 {
                        ready.insert(x.clone());
                    }
                }
            }
        }
        // Drain ready into queue in alphabetical order.
        for next_ready in std::mem::take(&mut ready) {
            q.push_back(next_ready);
        }
    }

    if order.len() != entries.len() {
        let cycle: Vec<PackageName> = entries
            .iter()
            .map(|e| e.name.clone())
            .filter(|n| !order.contains(n))
            .collect();
        return Err(LockfileError::DependencyCycle {
            participants: cycle,
        });
    }
    Ok(order)
}

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

/// Errors produced by lockfile generation, parsing, and verification.
#[derive(Debug, Error)]
pub enum LockfileError {
    /// File I/O failed.
    #[error("lockfile I/O at {}: {source}", path.display())]
    Io {
        /// Path being read or written.
        path: PathBuf,
        /// Underlying I/O error.
        #[source]
        source: io::Error,
    },
    /// Lockfile bytes were not valid UTF-8.
    #[error("lockfile is not valid UTF-8")]
    NotUtf8,
    /// TOML parse failed.
    #[error("lockfile parse error: {0}")]
    Parse(toml::de::Error),
    /// TOML serialize failed.
    #[error("lockfile serialize error: {0}")]
    Serialize(toml::ser::Error),
    /// Lockfile schema version is incompatible with this pmacs.
    #[error(
        "lockfile schema version {found} is incompatible with this pmacs (expects {expected}). \
         Upgrade pmacs, or regenerate the lockfile with `pmacs.packages.update()`."
    )]
    SchemaVersion {
        /// Schema version this build expects.
        expected: u32,
        /// Schema version found in the file.
        found: u32,
    },
    /// A `content_hash` field was not in the form `<algorithm>:<hex>`.
    #[error("malformed content_hash `{value}`: {reason}")]
    MalformedContentHash {
        /// The offending string.
        value: String,
        /// Why parsing failed.
        reason: String,
    },
    /// A `top_level_pin` of `kind = "version"` had a `value` that does
    /// not parse as a [`VersionReq`].
    #[error("invalid version constraint `{value}`: {cause}")]
    InvalidVersionConstraint {
        /// The offending value.
        value: String,
        /// Underlying parse error.
        cause: String,
    },
    /// Fetcher (clone, archive, rev-parse) failed during lockfile
    /// generation or verification.
    #[error("fetch error for {url}: {source}")]
    Fetch {
        /// Clone URL being fetched.
        url: String,
        /// Underlying fetch error.
        #[source]
        source: FetchError,
    },
    /// Computed content hash does not match the lockfile's recorded
    /// hash. Tampered upstream or stale lockfile.
    #[error(
        "content_hash mismatch for `{name_str}` ({url}@{commit_short}):\n\
         expected: {expected_wire}\n\
         observed: {observed_wire}\n\
         The upstream's content at this commit differs from what was recorded \
         when the lockfile was generated. Either the upstream has been \
         tampered with, or your local cache is corrupt. Refusing to install. \
         If this is intentional (e.g. you regenerated the upstream), run \
         `pmacs.packages.update(\"{name_str}\")` to refresh the lockfile.",
        name_str = name.as_str(),
        commit_short = &commit[..commit.len().min(12)],
        expected_wire = expected.to_wire_string(),
        observed_wire = observed.to_wire_string(),
    )]
    ContentHashMismatch {
        /// Package whose content didn't match.
        name: PackageName,
        /// URL that was fetched.
        url: String,
        /// Commit hash that was hashed.
        commit: String,
        /// Hash recorded in the lockfile.
        expected: ContentHash,
        /// Hash computed against the (possibly tampered) upstream.
        observed: ContentHash,
    },
    /// Manifest at a lockfile entry's commit failed to parse.
    #[error("manifest at {url}@{commit}: {message}")]
    Manifest {
        /// URL.
        url: String,
        /// Commit.
        commit: String,
        /// Underlying parse error (stringified to keep this error type
        /// free of cycles back into resolver / manifest internals).
        message: String,
    },
    /// Manifest at a lockfile entry's commit was not valid UTF-8.
    #[error("manifest at {url}@{commit}: not valid UTF-8")]
    ManifestNotUtf8 {
        /// URL.
        url: String,
        /// Commit.
        commit: String,
    },
    /// `to_resolve_plan` found a cycle in the recorded dependency
    /// edges. Indicates a hand-corrupted lockfile.
    #[error(
        "lockfile dependency graph contains a cycle: {participants:?}. \
         Regenerate with `pmacs.packages.update()`."
    )]
    DependencyCycle {
        /// Names involved in the cycle.
        participants: Vec<PackageName>,
    },
    /// A lockfile entry referenced by an `UpdatePolicy::UpdateOne`
    /// is absent.
    #[error(
        "package `{}` is not in the lockfile; cannot update it. \
         Use `pmacs.packages.update()` to regenerate the full lockfile.",
        name.as_str(),
    )]
    UpdateOneMissing {
        /// The name that was passed to `UpdateOne`.
        name: PackageName,
    },
    /// Two or more distinct lockfile entries share an install basename
    /// (audit F-005). A hand-edited or pre-existing lockfile with e.g.
    /// `owner/magit` and `other/magit` would install both to
    /// `<root>/magit`. The frozen/lockfile plan path must reject this just
    /// like the fresh-resolve path does.
    #[error(
        "lockfile has packages [{names}] sharing the install basename \
         `{basename}`: pmacs installs and requires by basename, so these \
         distinct packages would collide on disk. Regenerate the lockfile \
         after renaming one, or install them in separate roots.",
        names = format_package_name_list(.names)
    )]
    BasenameCollision {
        /// The colliding last-path-segment.
        basename: String,
        /// The distinct package names that map to it.
        names: Vec<PackageName>,
    },
}

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

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

    #[test]
    fn to_resolve_plan_rejects_basename_collision_before_fetching() {
        // A hand-edited / pre-existing lockfile with two distinct packages
        // that share an install basename (audit F-005). The frozen path
        // must reject it just like the fresh-resolve path, and it does so
        // *before* any fetch — so the throwaway cache dir is never touched.
        let entry = |name: &str, url: &str| LockfileEntry {
            name: PackageName::new(name).unwrap(),
            url: url.into(),
            commit: "0".repeat(40),
            version: Version::new(1, 0, 0),
            content_hash: ContentHash::sha256_of(name.as_bytes()),
            top_level_pin: None,
            dependencies: vec![],
        };
        let lock = Lockfile {
            schema_version: LOCKFILE_SCHEMA_VERSION,
            generator: "pmacs test".into(),
            packages: vec![
                entry("owner/magit", "https://example.com/owner/magit.git"),
                entry("other/magit", "https://example.com/other/magit.git"),
            ],
        };
        let fetcher = Fetcher::with_cache_dir(std::env::temp_dir());
        let err = lock
            .to_resolve_plan(&fetcher)
            .expect_err("colliding lockfile must be rejected");
        match err {
            LockfileError::BasenameCollision { basename, names } => {
                assert_eq!(basename, "magit");
                assert_eq!(names.len(), 2);
            }
            other => panic!("expected BasenameCollision, got {other:?}"),
        }
    }

    #[test]
    fn content_hash_sha256_known_vector() {
        // RFC 6234 §8.5: SHA-256 of "abc"
        let h = ContentHash::sha256_of(b"abc");
        assert_eq!(h.algorithm, "sha256");
        assert_eq!(
            h.hex,
            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
        );
        assert_eq!(
            h.to_wire_string(),
            "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
        );
    }

    #[test]
    fn content_hash_round_trip_via_wire() {
        let h = ContentHash::sha256_of(b"hello");
        let parsed = ContentHash::parse(&h.to_wire_string()).unwrap();
        assert_eq!(parsed, h);
    }

    #[test]
    fn content_hash_parse_rejects_unsupported_algorithm() {
        let err =
            ContentHash::parse("md5:0123456789abcdef0123456789abcdef").expect_err("md5 rejected");
        match err {
            LockfileError::MalformedContentHash { reason, .. } => {
                assert!(reason.contains("md5"), "reason = {reason}");
            }
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn content_hash_parse_rejects_short_hex() {
        let err = ContentHash::parse("sha256:abc").expect_err("short hex rejected");
        assert!(matches!(err, LockfileError::MalformedContentHash { .. }));
    }

    #[test]
    fn content_hash_parse_rejects_non_hex_chars() {
        let err = ContentHash::parse(&format!("sha256:{}", "z".repeat(64))).expect_err("non-hex");
        assert!(matches!(err, LockfileError::MalformedContentHash { .. }));
    }

    #[test]
    fn content_hash_parse_normalizes_uppercase() {
        let upper = format!("sha256:{}", "A".repeat(64));
        let parsed = ContentHash::parse(&upper).unwrap();
        assert_eq!(parsed.hex, "a".repeat(64));
    }

    #[test]
    fn lockfile_pin_round_trip() {
        for pin in [
            InstallPin::Version(VersionReq::parse("^1.0.0").unwrap()),
            InstallPin::Branch("main".into()),
            InstallPin::Commit("abc123".into()),
        ] {
            let lp = LockfilePin::from_install_pin(&pin)
                .expect("non-Local pins always convert to LockfilePin");
            let back = lp.to_install_pin().unwrap();
            assert_eq!(back, pin);
        }
    }

    #[test]
    fn lockfile_pin_invalid_version_constraint_errors() {
        let bad = LockfilePin::Version("not-a-constraint".into());
        let err = bad.to_install_pin().expect_err("bad VersionReq rejected");
        assert!(
            matches!(err, LockfileError::InvalidVersionConstraint { .. }),
            "got {err:?}",
        );
    }

    #[test]
    fn lockfile_serializes_alphabetically_by_name() {
        // Synthesize a lockfile by hand (no fetcher needed) and
        // verify the serialized form lists packages in name order.
        let pkg_b = LockfileEntry {
            name: PackageName::new("b-pkg").unwrap(),
            url: "https://example.com/b.git".into(),
            commit: "0".repeat(40),
            version: Version::new(1, 0, 0),
            content_hash: ContentHash::sha256_of(b"b"),
            top_level_pin: None,
            dependencies: vec![],
        };
        let pkg_a = LockfileEntry {
            name: PackageName::new("a-pkg").unwrap(),
            url: "https://example.com/a.git".into(),
            commit: "1".repeat(40),
            version: Version::new(1, 0, 0),
            content_hash: ContentHash::sha256_of(b"a"),
            top_level_pin: Some(LockfilePin::Version("^1".into())),
            dependencies: vec![PackageName::new("b-pkg").unwrap()],
        };
        let mut lock = Lockfile {
            schema_version: LOCKFILE_SCHEMA_VERSION,
            generator: "pmacs test".into(),
            packages: vec![pkg_b, pkg_a],
        };
        // Manual sort to mirror what `from_plan` does.
        lock.packages.sort_by(|a, b| a.name.cmp(&b.name));
        let text = String::from_utf8(lock.to_bytes().unwrap()).unwrap();
        let pos_a = text.find("name = \"a-pkg\"").expect("a-pkg present");
        let pos_b = text.find("name = \"b-pkg\"").expect("b-pkg present");
        assert!(pos_a < pos_b, "expected a-pkg before b-pkg in:\n{text}");
    }

    #[test]
    fn lockfile_round_trip_via_bytes() {
        let entry = LockfileEntry {
            name: PackageName::new("a-pkg").unwrap(),
            url: "https://example.com/a.git".into(),
            commit: "1".repeat(40),
            version: Version::new(1, 2, 3),
            content_hash: ContentHash::sha256_of(b"hello"),
            top_level_pin: Some(LockfilePin::Version("^1".into())),
            dependencies: vec![PackageName::new("b-pkg").unwrap()],
        };
        let lock = Lockfile {
            schema_version: LOCKFILE_SCHEMA_VERSION,
            generator: "pmacs test".into(),
            packages: vec![entry],
        };
        let bytes = lock.to_bytes().unwrap();
        let parsed = Lockfile::parse(&bytes).unwrap();
        assert_eq!(parsed, lock);
    }

    #[test]
    fn lockfile_parse_rejects_wrong_schema_version() {
        let bytes = Lockfile {
            schema_version: 99,
            generator: "x".into(),
            packages: vec![],
        }
        .to_bytes()
        .unwrap();
        let err = Lockfile::parse(&bytes).expect_err("wrong version rejected");
        assert!(
            matches!(
                err,
                LockfileError::SchemaVersion {
                    expected: 1,
                    found: 99
                }
            ),
            "got {err:?}",
        );
    }

    #[test]
    fn topo_sort_orders_dependencies_first() {
        // a depends on b, b depends on c.
        let mk = |name: &str, deps: &[&str]| LockfileEntry {
            name: PackageName::new(name).unwrap(),
            url: format!("https://example.com/{name}.git"),
            commit: "0".repeat(40),
            version: Version::new(1, 0, 0),
            content_hash: ContentHash::sha256_of(name.as_bytes()),
            top_level_pin: None,
            dependencies: deps.iter().map(|d| PackageName::new(*d).unwrap()).collect(),
        };
        let entries = vec![mk("a", &["b"]), mk("b", &["c"]), mk("c", &[])];
        let order = topo_sort(&entries).unwrap();
        let names: Vec<&str> = order.iter().map(PackageName::as_str).collect();
        assert_eq!(names, vec!["c", "b", "a"]);
    }

    #[test]
    fn topo_sort_detects_cycle() {
        let mk = |name: &str, deps: &[&str]| LockfileEntry {
            name: PackageName::new(name).unwrap(),
            url: format!("https://example.com/{name}.git"),
            commit: "0".repeat(40),
            version: Version::new(1, 0, 0),
            content_hash: ContentHash::sha256_of(name.as_bytes()),
            top_level_pin: None,
            dependencies: deps.iter().map(|d| PackageName::new(*d).unwrap()).collect(),
        };
        // a → b → a
        let entries = vec![mk("a", &["b"]), mk("b", &["a"])];
        let err = topo_sort(&entries).expect_err("cycle detected");
        match err {
            LockfileError::DependencyCycle { participants } => {
                let names: Vec<&str> = participants.iter().map(PackageName::as_str).collect();
                assert_eq!(names.len(), 2);
                assert!(names.contains(&"a"));
                assert!(names.contains(&"b"));
            }
            other => panic!("unexpected: {other:?}"),
        }
    }
}
