use super::errors::PackageError;
use super::*;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct LockFile {
    pub(crate) version: u32,
    /// Harn CLI version that resolved this lockfile. Lets downstream
    /// automation flag stale checkouts when the project bumps Harn.
    #[serde(default = "current_generator_version")]
    pub(crate) generator_version: String,
    /// Protocol artifact contract version this resolver shipped against.
    /// Pinning it in the lock means a host can detect when bindings
    /// regenerated by a newer Harn would diverge from what is committed
    /// downstream without running its own generator.
    #[serde(default = "current_protocol_artifact_version")]
    pub(crate) protocol_artifact_version: String,
    #[serde(default, rename = "package")]
    pub(crate) packages: Vec<LockEntry>,
}

impl Default for LockFile {
    fn default() -> Self {
        Self {
            version: LOCK_FILE_VERSION,
            generator_version: current_generator_version(),
            protocol_artifact_version: current_protocol_artifact_version(),
            packages: Vec::new(),
        }
    }
}

pub(crate) fn current_generator_version() -> String {
    env!("CARGO_PKG_VERSION").to_string()
}

pub(crate) fn current_protocol_artifact_version() -> String {
    env!("CARGO_PKG_VERSION").to_string()
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct LockEntry {
    pub(crate) name: String,
    pub(crate) source: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) tag: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) rev_request: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) commit: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) content_hash: Option<String>,
    /// `[package].version` from the resolved package's manifest. Captured so
    /// `harn package outdated` and `harn package audit` can compare without
    /// reaching into `.harn/packages/`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) package_version: Option<String>,
    /// `[package].harn` compatibility range from the resolved package's
    /// manifest. Used by audit to flag packages that no longer support the
    /// current Harn line.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) harn_compat: Option<String>,
    /// Package-authored provenance URL or identifier from `[package]`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) provenance: Option<String>,
    /// SHA-256 digest (`sha256:<hex>`) of the resolved package's
    /// `harn.toml`, separate from the full-contents `content_hash`. Lets
    /// audit detect manifest tampering without re-hashing the entire tree.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) manifest_digest: Option<String>,
    /// Provenance for entries that were originally added through the
    /// package registry index and lowered to a git source. Preserved so
    /// `harn package outdated` can compare against the registry's latest
    /// version.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) registry: Option<RegistryProvenance>,
    #[serde(default, skip_serializing_if = "PackageLockExports::is_empty")]
    pub(crate) exports: PackageLockExports,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub(crate) permissions: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub(crate) host_requirements: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct RegistryProvenance {
    pub(crate) source: String,
    pub(crate) name: String,
    pub(crate) version: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub(crate) provenance_url: Option<String>,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackageLockExports {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub modules: Vec<PackageLockExport>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tools: Vec<PackageLockExport>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub skills: Vec<PackageLockExport>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub personas: Vec<String>,
}

impl PackageLockExports {
    pub(crate) fn is_empty(&self) -> bool {
        self.modules.is_empty()
            && self.tools.is_empty()
            && self.skills.is_empty()
            && self.personas.is_empty()
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackageLockExport {
    pub name: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub symbol: Option<String>,
}

impl LockFile {
    pub(crate) fn load(path: &Path) -> Result<Option<Self>, PackageError> {
        let content = match fs::read_to_string(path) {
            Ok(s) => s,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(error) => return Err(format!("failed to read {}: {error}", path.display()).into()),
        };

        // Peek at the version field so older lock formats migrate cleanly
        // even when their schema is otherwise compatible with the current
        // structs (e.g. v1 → v2 only added optional fields).
        let raw_version = toml::from_str::<RawVersionedFile>(&content)
            .ok()
            .map(|raw| raw.version);

        match raw_version {
            Some(LOCK_FILE_VERSION) => {
                let mut lock: Self = toml::from_str(&content)
                    .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
                lock.sort_entries();
                Ok(Some(lock))
            }
            Some(1..=3) => {
                // Older lockfile versions load through the current struct
                // because added fields are optional. Saving stamps the current
                // version and enriches provenance on the next install.
                let mut lock: Self = toml::from_str(&content)
                    .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
                lock.version = LOCK_FILE_VERSION;
                lock.sort_entries();
                Ok(Some(lock))
            }
            Some(other) => Err(format!(
                "unsupported {} version {} (expected {})",
                path.display(),
                other,
                LOCK_FILE_VERSION
            )
            .into()),
            None => {
                let legacy = toml::from_str::<LegacyLockFile>(&content)
                    .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
                let mut lock = Self {
                    version: LOCK_FILE_VERSION,
                    generator_version: current_generator_version(),
                    protocol_artifact_version: current_protocol_artifact_version(),
                    packages: legacy
                        .packages
                        .into_iter()
                        .map(|entry| LockEntry {
                            name: entry.name,
                            source: entry
                                .path
                                .map(|path| format!("path+{path}"))
                                .or_else(|| entry.git.map(|git| format!("git+{git}")))
                                .unwrap_or_default(),
                            tag: entry.tag.clone(),
                            rev_request: entry.rev_request.or(entry.tag),
                            commit: entry.commit,
                            content_hash: None,
                            package_version: None,
                            harn_compat: None,
                            provenance: None,
                            manifest_digest: None,
                            registry: None,
                            exports: PackageLockExports::default(),
                            permissions: Vec::new(),
                            host_requirements: Vec::new(),
                        })
                        .collect(),
                };
                lock.sort_entries();
                Ok(Some(lock))
            }
        }
    }

    fn save(&self, path: &Path) -> Result<(), PackageError> {
        let mut normalized = self.clone();
        normalized.version = LOCK_FILE_VERSION;
        normalized.generator_version = current_generator_version();
        normalized.protocol_artifact_version = current_protocol_artifact_version();
        normalized.sort_entries();
        let body = toml::to_string_pretty(&normalized)
            .map_err(|error| format!("failed to encode {}: {error}", path.display()))?;
        let mut out = String::from("# This file is auto-generated by Harn. Do not edit.\n\n");
        out.push_str(&body);
        harn_vm::atomic_io::atomic_write(path, out.as_bytes()).map_err(|error| {
            PackageError::Lockfile(format!("failed to write {}: {error}", path.display()))
        })
    }

    pub(crate) fn sort_entries(&mut self) {
        self.packages
            .sort_by(|left, right| left.name.cmp(&right.name));
    }

    pub(crate) fn find(&self, name: &str) -> Option<&LockEntry> {
        self.packages.iter().find(|entry| entry.name == name)
    }

    fn replace(&mut self, entry: LockEntry) {
        if let Some(existing) = self.packages.iter_mut().find(|pkg| pkg.name == entry.name) {
            *existing = entry;
        } else {
            self.packages.push(entry);
        }
        self.sort_entries();
    }

    fn remove(&mut self, name: &str) {
        self.packages.retain(|entry| entry.name != name);
    }
}

#[derive(Debug, Deserialize)]
struct RawVersionedFile {
    version: u32,
}

#[derive(Debug, Deserialize)]
pub(crate) struct LegacyLockFile {
    #[serde(default, rename = "package")]
    packages: Vec<LegacyLockEntry>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct LegacyLockEntry {
    pub(crate) name: String,
    #[serde(default)]
    git: Option<String>,
    #[serde(default)]
    tag: Option<String>,
    #[serde(default)]
    pub(crate) rev_request: Option<String>,
    #[serde(default)]
    pub(crate) commit: Option<String>,
    #[serde(default)]
    path: Option<String>,
}

pub(crate) fn compatible_locked_entry(
    workspace: &PackageWorkspace,
    alias: &str,
    dependency: &Dependency,
    lock: &LockEntry,
    manifest_dir: &Path,
) -> Result<bool, PackageError> {
    if lock.name != alias {
        return Ok(false);
    }
    if let Some(path) = dependency.local_path() {
        let source = path_source_uri(&resolve_path_dependency_source(manifest_dir, path)?)?;
        return Ok(lock.source == source);
    }
    if let Some(requirement) = dependency.version() {
        let Dependency::Table(table) = dependency else {
            return Ok(false);
        };
        let Some(registry) = lock.registry.as_ref() else {
            return Ok(false);
        };
        let registry_name = table.registry_name.as_deref().unwrap_or(alias);
        if registry.name != registry_name {
            return Ok(false);
        }
        let expected_source = workspace.resolve_registry_source(table.registry.as_deref())?;
        if registry.source != expected_source {
            return Ok(false);
        }
        let version = parse_registry_semver(&registry.version)?;
        let req = parse_registry_version_req(requirement)?;
        return Ok(req.matches(&version)
            && lock.source.starts_with("git+")
            && lock.commit.is_some()
            && lock.content_hash.is_some());
    }
    if let Some(url) = dependency.git_url() {
        let source = format!("git+{}", normalize_git_url(url)?);
        let requested = dependency_git_request(dependency).map(str::to_string);
        return Ok(lock.source == source
            && lock.rev_request == requested
            && lock.tag == dependency.tag().map(str::to_string)
            && lock.commit.is_some()
            && lock.content_hash.is_some());
    }
    Ok(false)
}

#[derive(Debug, Clone)]
pub(crate) struct PendingDependency {
    alias: String,
    dependency: Dependency,
    manifest_dir: PathBuf,
    parent: Option<String>,
    parent_is_git: bool,
}

pub(crate) fn git_rev_request(
    alias: &str,
    dependency: &Dependency,
) -> Result<String, PackageError> {
    dependency_git_request(dependency)
        .map(str::to_string)
        .ok_or_else(|| {
            PackageError::Lockfile(format!(
                "git dependency {alias} must specify `tag`, `rev`, or `branch`; use `harn add <url>@<tag-or-sha>` or add `tag = \"...\"` to {MANIFEST}"
            ))
        })
}

pub(crate) fn dependency_git_request(dependency: &Dependency) -> Option<&str> {
    dependency
        .branch()
        .or_else(|| dependency.rev())
        .or_else(|| dependency.tag())
}

pub(crate) fn dependency_manifest_dir(source: &Path) -> Option<PathBuf> {
    if source.is_dir() {
        return Some(source.to_path_buf());
    }
    source.parent().map(Path::to_path_buf)
}

pub(crate) fn read_package_manifest_from_dir(dir: &Path) -> Result<Option<Manifest>, PackageError> {
    let manifest_path = dir.join(MANIFEST);
    if !manifest_path.exists() {
        return Ok(None);
    }
    read_manifest_from_path(&manifest_path).map(Some)
}

/// Provenance pulled from a resolved package's manifest. Used to enrich a
/// `LockEntry` so audit/outdated reports stay self-contained.
#[derive(Debug, Clone, Default)]
pub(crate) struct LockEntryProvenance {
    pub(crate) package_version: Option<String>,
    pub(crate) harn_compat: Option<String>,
    pub(crate) provenance: Option<String>,
    pub(crate) manifest_digest: Option<String>,
    pub(crate) exports: PackageLockExports,
    pub(crate) permissions: Vec<String>,
    pub(crate) host_requirements: Vec<String>,
}

pub(crate) fn read_lock_entry_provenance(
    package_dir: &Path,
) -> Result<LockEntryProvenance, PackageError> {
    let manifest_path = package_dir.join(MANIFEST);
    if !manifest_path.exists() {
        return Ok(LockEntryProvenance::default());
    }
    let bytes = fs::read(&manifest_path)
        .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?;
    let digest = format!("sha256:{}", sha256_hex(&bytes));
    let manifest = read_manifest_from_path(&manifest_path)?;
    let (package_version, harn_compat, provenance, permissions, host_requirements) = manifest
        .package
        .as_ref()
        .map(|info| {
            (
                info.version.clone(),
                info.harn.clone(),
                info.provenance.clone(),
                info.permissions.clone(),
                info.host_requirements.clone(),
            )
        })
        .unwrap_or((None, None, None, Vec::new(), Vec::new()));
    Ok(LockEntryProvenance {
        package_version,
        harn_compat,
        provenance,
        manifest_digest: Some(digest),
        exports: package_lock_exports_from_manifest(&manifest),
        permissions: normalized_requirements(&permissions),
        host_requirements: normalized_requirements(&host_requirements),
    })
}

fn fill_provenance(entry: &mut LockEntry, provenance: LockEntryProvenance) {
    entry.package_version = provenance.package_version;
    entry.harn_compat = provenance.harn_compat;
    entry.provenance = provenance.provenance;
    entry.manifest_digest = provenance.manifest_digest;
    entry.exports = provenance.exports;
    entry.permissions = provenance.permissions;
    entry.host_requirements = provenance.host_requirements;
}

pub(crate) fn package_lock_exports_from_manifest(manifest: &Manifest) -> PackageLockExports {
    let mut modules: Vec<PackageLockExport> = manifest
        .exports
        .iter()
        .map(|(name, path)| PackageLockExport {
            name: name.clone(),
            path: Some(path.clone()),
            symbol: None,
        })
        .collect();
    modules.sort_by(|left, right| left.name.cmp(&right.name));

    let (mut tools, mut skills) = manifest
        .package
        .as_ref()
        .map(|package| {
            let tools = package
                .tools
                .iter()
                .map(|tool| PackageLockExport {
                    name: tool.name.clone(),
                    path: Some(tool.module.clone()),
                    symbol: Some(tool.symbol.clone()),
                })
                .collect::<Vec<_>>();
            let skills = package
                .skills
                .iter()
                .map(|skill| PackageLockExport {
                    name: skill.name.clone(),
                    path: Some(skill.path.clone()),
                    symbol: None,
                })
                .collect::<Vec<_>>();
            (tools, skills)
        })
        .unwrap_or_default();
    tools.sort_by(|left, right| left.name.cmp(&right.name));
    skills.sort_by(|left, right| left.name.cmp(&right.name));

    let mut personas: Vec<String> = manifest
        .personas
        .iter()
        .filter_map(|persona| persona.name.clone())
        .collect();
    personas.sort();
    personas.dedup();

    PackageLockExports {
        modules,
        tools,
        skills,
        personas,
    }
}

pub(crate) fn normalized_requirements(values: &[String]) -> Vec<String> {
    let mut out: Vec<String> = values
        .iter()
        .map(|value| value.trim())
        .filter(|value| !value.is_empty())
        .map(str::to_string)
        .collect();
    out.sort();
    out.dedup();
    out
}

pub(crate) fn dependency_conflict_message(
    existing: &LockEntry,
    candidate: &LockEntry,
) -> PackageError {
    PackageError::Lockfile(format!(
        "dependency alias '{}' resolves to multiple packages ({} and {}); use distinct aliases in {MANIFEST}",
        candidate.name, existing.source, candidate.source
    ))
}

pub(crate) fn replace_lock_entry(
    lock: &mut LockFile,
    candidate: LockEntry,
) -> Result<bool, PackageError> {
    validate_package_alias(&candidate.name)?;
    if let Some(existing) = lock.find(&candidate.name) {
        if existing == &candidate {
            return Ok(false);
        }
        return Err(dependency_conflict_message(existing, &candidate));
    }
    lock.replace(candidate);
    Ok(true)
}

pub(crate) fn enqueue_manifest_dependencies(
    pending: &mut Vec<PendingDependency>,
    manifest: Manifest,
    manifest_dir: PathBuf,
    parent: String,
    parent_is_git: bool,
) {
    let mut aliases: Vec<String> = manifest.dependencies.keys().cloned().collect();
    aliases.sort();
    for alias in aliases.into_iter().rev() {
        if let Some(dependency) = manifest.dependencies.get(&alias).cloned() {
            pending.push(PendingDependency {
                alias,
                dependency,
                manifest_dir: manifest_dir.clone(),
                parent: Some(parent.clone()),
                parent_is_git,
            });
        }
    }
}

fn resolve_registry_version_dependency(
    workspace: &PackageWorkspace,
    alias: &str,
    dependency: Dependency,
) -> Result<Dependency, PackageError> {
    let Dependency::Table(table) = &dependency else {
        return Ok(dependency);
    };
    if table.version.is_none() {
        return Ok(dependency);
    }
    if table.git.is_some()
        || table.path.is_some()
        || table.rev.is_some()
        || table.tag.is_some()
        || table.branch.is_some()
    {
        return Err(format!(
            "dependency {alias} uses `version`; do not combine registry version constraints with git, path, tag, rev, or branch"
        )
        .into());
    }
    registry_dependency_from_manifest_constraint_in(workspace, alias, table)
}

pub(crate) fn build_lockfile(
    workspace: &PackageWorkspace,
    ctx: &ManifestContext,
    existing: Option<&LockFile>,
    refresh_alias: Option<&str>,
    refresh_all: bool,
    allow_resolve: bool,
    offline: bool,
) -> Result<LockFile, PackageError> {
    if manifest_has_git_dependencies(&ctx.manifest) {
        ensure_git_available()?;
    }

    let mut lock = LockFile::default();
    let mut pending: Vec<PendingDependency> = Vec::new();
    let mut aliases: Vec<String> = ctx.manifest.dependencies.keys().cloned().collect();
    aliases.sort();
    for alias in aliases.into_iter().rev() {
        let dependency = ctx
            .manifest
            .dependencies
            .get(&alias)
            .ok_or_else(|| format!("dependency {alias} disappeared while locking"))?
            .clone();
        pending.push(PendingDependency {
            alias,
            dependency,
            manifest_dir: ctx.dir.clone(),
            parent: None,
            parent_is_git: false,
        });
    }

    while let Some(next) = pending.pop() {
        let alias = next.alias;
        validate_package_alias(&alias)?;
        let dependency = next.dependency;
        if dependency.local_path().is_some() && next.parent_is_git {
            let parent = next.parent.as_deref().unwrap_or("a git package");
            return Err(format!(
                "package {parent} declares local path dependency {alias}, but path dependencies are not supported inside git-installed packages; publish {alias} as a git dependency with `tag`, `rev`, or `version`"
            ).into());
        }
        if dependency.requires_git() {
            ensure_git_available()?;
            if dependency.git_url().is_some() {
                git_rev_request(&alias, &dependency)?;
            }
        }
        let refresh = refresh_all || refresh_alias == Some(alias.as_str());
        if let Some(existing_lock) = existing.and_then(|lock| lock.find(&alias)) {
            if !refresh
                && compatible_locked_entry(
                    workspace,
                    &alias,
                    &dependency,
                    existing_lock,
                    &next.manifest_dir,
                )?
            {
                let mut entry = existing_lock.clone();
                if entry.source.starts_with("git+") && entry.content_hash.is_none() {
                    let url = entry.source.trim_start_matches("git+");
                    let commit = entry
                        .commit
                        .as_deref()
                        .ok_or_else(|| format!("missing locked commit for {alias}"))?;
                    entry.content_hash = Some(ensure_git_cache_populated_in(
                        workspace,
                        url,
                        &entry.source,
                        commit,
                        None,
                        false,
                        offline,
                    )?);
                }
                if entry.source.starts_with("git+") {
                    let url = entry.source.trim_start_matches("git+");
                    let commit = entry
                        .commit
                        .as_deref()
                        .ok_or_else(|| format!("missing locked commit for {alias}"))?;
                    let expected_hash = entry
                        .content_hash
                        .as_deref()
                        .ok_or_else(|| format!("missing content hash for {alias}"))?;
                    ensure_git_cache_populated_in(
                        workspace,
                        url,
                        &entry.source,
                        commit,
                        Some(expected_hash),
                        false,
                        offline,
                    )?;
                    let cache_dir = git_cache_dir_in(workspace, &entry.source, commit)?;
                    if entry.manifest_digest.is_none()
                        || entry.package_version.is_none()
                        || entry.provenance.is_none()
                    {
                        fill_provenance(&mut entry, read_lock_entry_provenance(&cache_dir)?);
                    }
                    if entry.registry.is_none() {
                        entry.registry = dependency.registry_provenance();
                    }
                    let inserted = replace_lock_entry(&mut lock, entry.clone())?;
                    if inserted {
                        if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
                            enqueue_manifest_dependencies(
                                &mut pending,
                                manifest,
                                cache_dir,
                                alias,
                                true,
                            );
                        }
                    }
                } else if entry.source.starts_with("path+") {
                    let source = path_from_source_uri(&entry.source)?;
                    let manifest_dir = dependency_manifest_dir(&source);
                    if entry.manifest_digest.is_none()
                        || entry.package_version.is_none()
                        || entry.provenance.is_none()
                    {
                        if let Some(dir) = manifest_dir.as_deref() {
                            fill_provenance(&mut entry, read_lock_entry_provenance(dir)?);
                        }
                    }
                    let inserted = replace_lock_entry(&mut lock, entry.clone())?;
                    if inserted {
                        if let Some(manifest_dir) = manifest_dir {
                            if let Some(manifest) = read_package_manifest_from_dir(&manifest_dir)? {
                                enqueue_manifest_dependencies(
                                    &mut pending,
                                    manifest,
                                    manifest_dir,
                                    alias,
                                    false,
                                );
                            }
                        }
                    }
                } else {
                    replace_lock_entry(&mut lock, entry)?;
                }
                continue;
            }
        }

        if !allow_resolve {
            return Err(format!("{} would need to change", ctx.lock_path().display()).into());
        }

        let dependency = resolve_registry_version_dependency(workspace, &alias, dependency)?;

        if let Some(path) = dependency.local_path() {
            let source = resolve_path_dependency_source(&next.manifest_dir, path)?;
            let package_alias = alias.clone();
            let manifest_dir = dependency_manifest_dir(&source);
            let provenance = manifest_dir
                .as_deref()
                .map(read_lock_entry_provenance)
                .transpose()?
                .unwrap_or_default();
            let mut entry = LockEntry {
                name: alias.clone(),
                source: path_source_uri(&source)?,
                tag: None,
                rev_request: None,
                commit: None,
                content_hash: None,
                package_version: None,
                harn_compat: None,
                provenance: None,
                manifest_digest: None,
                registry: None,
                exports: PackageLockExports::default(),
                permissions: Vec::new(),
                host_requirements: Vec::new(),
            };
            fill_provenance(&mut entry, provenance);
            let inserted = replace_lock_entry(&mut lock, entry)?;
            if inserted {
                if let Some(manifest_dir) = manifest_dir {
                    if let Some(manifest) = read_package_manifest_from_dir(&manifest_dir)? {
                        enqueue_manifest_dependencies(
                            &mut pending,
                            manifest,
                            manifest_dir,
                            package_alias,
                            false,
                        );
                    }
                }
            }
            continue;
        }

        if let Some(url) = dependency.git_url() {
            let rev_request = git_rev_request(&alias, &dependency)?;
            let normalized_url = normalize_git_url(url)?;
            let source = format!("git+{normalized_url}");
            let commit = resolve_git_commit(
                &normalized_url,
                dependency.rev(),
                dependency.tag(),
                dependency.branch(),
            )?;
            let content_hash = ensure_git_cache_populated_in(
                workspace,
                &normalized_url,
                &source,
                &commit,
                None,
                false,
                offline,
            )?;
            let cache_dir = git_cache_dir_in(workspace, &source, &commit)?;
            let provenance = read_lock_entry_provenance(&cache_dir)?;
            let mut entry = LockEntry {
                name: alias.clone(),
                source: source.clone(),
                tag: dependency.tag().map(str::to_string),
                rev_request: Some(rev_request),
                commit: Some(commit.clone()),
                content_hash: Some(content_hash),
                package_version: None,
                harn_compat: None,
                provenance: None,
                manifest_digest: None,
                registry: dependency.registry_provenance(),
                exports: PackageLockExports::default(),
                permissions: Vec::new(),
                host_requirements: Vec::new(),
            };
            fill_provenance(&mut entry, provenance);
            let inserted = replace_lock_entry(&mut lock, entry)?;
            if inserted {
                if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
                    enqueue_manifest_dependencies(&mut pending, manifest, cache_dir, alias, true);
                }
            }
            continue;
        }

        return Err(format!("dependency {alias} is missing a git or path source").into());
    }
    Ok(lock)
}

pub(crate) fn materialize_dependencies_from_lock(
    workspace: &PackageWorkspace,
    ctx: &ManifestContext,
    lock: &LockFile,
    refetch: Option<&str>,
    offline: bool,
) -> Result<usize, PackageError> {
    let packages_dir = ctx.packages_dir();
    fs::create_dir_all(&packages_dir)
        .map_err(|error| format!("failed to create {}: {error}", packages_dir.display()))?;

    let mut installed = 0usize;
    for entry in &lock.packages {
        let alias = &entry.name;
        validate_package_alias(alias)?;
        if entry.source.starts_with("path+") {
            let source = path_from_source_uri(&entry.source)?;
            materialize_path_dependency(&source, &packages_dir, alias)?;
            installed += 1;
            continue;
        }

        let commit = entry
            .commit
            .as_deref()
            .ok_or_else(|| format!("missing locked commit for {alias}"))?;
        let expected_hash = entry
            .content_hash
            .as_deref()
            .ok_or_else(|| format!("missing content hash for {alias}"))?;
        let source = entry.source.clone();
        let url = source.trim_start_matches("git+");
        let refetch_this = refetch == Some("all") || refetch == Some(alias.as_str());
        ensure_git_cache_populated_in(
            workspace,
            url,
            &source,
            commit,
            Some(expected_hash),
            refetch_this,
            offline,
        )?;
        let cache_dir = git_cache_dir_in(workspace, &source, commit)?;
        let dest_dir = packages_dir.join(alias);
        if !dest_dir.exists() || !materialized_hash_matches(&dest_dir, expected_hash) {
            remove_materialized_package(&packages_dir, alias)?;
            copy_dir_recursive(&cache_dir, &dest_dir)?;
            write_cached_content_hash(&dest_dir, expected_hash)?;
        }
        installed += 1;
    }
    Ok(installed)
}

pub(crate) fn validate_lock_matches_manifest(
    workspace: &PackageWorkspace,
    ctx: &ManifestContext,
    lock: &LockFile,
) -> Result<(), PackageError> {
    for (alias, dependency) in &ctx.manifest.dependencies {
        validate_package_alias(alias)?;
        let entry = lock.find(alias).ok_or_else(|| {
            format!(
                "{} is missing an entry for {alias}",
                ctx.lock_path().display()
            )
        })?;
        if !compatible_locked_entry(workspace, alias, dependency, entry, &ctx.dir)? {
            return Err(format!(
                "{} is out of date for {alias}; run `harn install`",
                ctx.lock_path().display()
            )
            .into());
        }
    }
    Ok(())
}

pub fn ensure_dependencies_materialized(anchor: &Path) -> Result<(), PackageError> {
    let Some((manifest, dir)) = find_nearest_manifest(anchor) else {
        return Ok(());
    };
    if manifest.dependencies.is_empty() {
        return Ok(());
    }
    let ctx = ManifestContext { manifest, dir };
    let lock = LockFile::load(&ctx.lock_path())?.ok_or_else(|| {
        format!(
            "{} is missing; run `harn install`",
            ctx.lock_path().display()
        )
    })?;
    let workspace = PackageWorkspace::from_current_dir()?;
    validate_lock_matches_manifest(&workspace, &ctx, &lock)?;
    materialize_dependencies_from_lock(&workspace, &ctx, &lock, None, false)?;
    Ok(())
}

pub(crate) fn dependency_section_bounds(lines: &[String]) -> Option<(usize, usize)> {
    let start = lines
        .iter()
        .position(|line| line.trim() == "[dependencies]")?;
    let end = lines
        .iter()
        .enumerate()
        .skip(start + 1)
        .find(|(_, line)| line.trim_start().starts_with('['))
        .map(|(index, _)| index)
        .unwrap_or(lines.len());
    Some((start, end))
}

pub(crate) fn render_dependency_line(
    alias: &str,
    dependency: &Dependency,
) -> Result<String, PackageError> {
    validate_package_alias(alias)?;
    match dependency {
        Dependency::Path(path) => Ok(format!(
            "{alias} = {{ path = {} }}",
            toml_string_literal(path)?
        )),
        Dependency::Table(table) => {
            let mut fields = Vec::new();
            if let Some(path) = table.path.as_deref() {
                fields.push(format!("path = {}", toml_string_literal(path)?));
            }
            if let Some(git) = table.git.as_deref() {
                fields.push(format!("git = {}", toml_string_literal(git)?));
            }
            if let Some(branch) = table.branch.as_deref() {
                fields.push(format!("branch = {}", toml_string_literal(branch)?));
            } else if let Some(tag) = table.tag.as_deref() {
                fields.push(format!("tag = {}", toml_string_literal(tag)?));
            } else if let Some(rev) = table.rev.as_deref() {
                fields.push(format!("rev = {}", toml_string_literal(rev)?));
            }
            if let Some(version) = table.version.as_deref() {
                fields.push(format!("version = {}", toml_string_literal(version)?));
            }
            if let Some(package) = table.package.as_deref() {
                fields.push(format!("package = {}", toml_string_literal(package)?));
            }
            if let Some(registry) = table.registry.as_deref() {
                fields.push(format!("registry = {}", toml_string_literal(registry)?));
            }
            if let Some(name) = table.registry_name.as_deref() {
                fields.push(format!("registry_name = {}", toml_string_literal(name)?));
            }
            if let Some(version) = table.registry_version.as_deref() {
                fields.push(format!(
                    "registry_version = {}",
                    toml_string_literal(version)?
                ));
            }
            Ok(format!("{alias} = {{ {} }}", fields.join(", ")))
        }
    }
}

pub(crate) fn ensure_manifest_exists(manifest_path: &Path) -> Result<String, PackageError> {
    if manifest_path.exists() {
        return fs::read_to_string(manifest_path).map_err(|error| {
            PackageError::Lockfile(format!(
                "failed to read {}: {error}",
                manifest_path.display()
            ))
        });
    }
    Ok("[package]\nname = \"my-project\"\nversion = \"0.1.0\"\n".to_string())
}

pub(crate) fn upsert_dependency_in_manifest(
    manifest_path: &Path,
    alias: &str,
    dependency: &Dependency,
) -> Result<(), PackageError> {
    let content = ensure_manifest_exists(manifest_path)?;
    let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
    if dependency_section_bounds(&lines).is_none() {
        if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) {
            lines.push(String::new());
        }
        lines.push("[dependencies]".to_string());
    }
    let (start, end) = dependency_section_bounds(&lines).ok_or_else(|| {
        format!(
            "failed to locate [dependencies] in {}",
            manifest_path.display()
        )
    })?;
    let rendered = render_dependency_line(alias, dependency)?;
    if let Some((index, _)) = lines
        .iter()
        .enumerate()
        .skip(start + 1)
        .take(end - start - 1)
        .find(|(_, line)| {
            line.split('=')
                .next()
                .is_some_and(|key| key.trim() == alias)
        })
    {
        lines[index] = rendered;
    } else {
        lines.insert(end, rendered);
    }
    write_manifest_content(manifest_path, &(lines.join("\n") + "\n"))
}

pub(crate) fn remove_dependency_from_manifest(
    manifest_path: &Path,
    alias: &str,
) -> Result<bool, PackageError> {
    let content = fs::read_to_string(manifest_path)
        .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?;
    let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
    let Some((start, end)) = dependency_section_bounds(&lines) else {
        return Ok(false);
    };
    let mut removed = false;
    lines = lines
        .into_iter()
        .enumerate()
        .filter_map(|(index, line)| {
            if index <= start || index >= end {
                return Some(line);
            }
            let matches = line
                .split('=')
                .next()
                .is_some_and(|key| key.trim() == alias);
            if matches {
                removed = true;
                None
            } else {
                Some(line)
            }
        })
        .collect();
    if removed {
        write_manifest_content(manifest_path, &(lines.join("\n") + "\n"))?;
    }
    Ok(removed)
}

pub(crate) fn install_packages_impl(
    frozen: bool,
    refetch: Option<&str>,
    offline: bool,
) -> Result<usize, PackageError> {
    install_packages_in(
        &PackageWorkspace::from_current_dir()?,
        frozen,
        refetch,
        offline,
    )
}

pub(crate) fn install_packages_in(
    workspace: &PackageWorkspace,
    frozen: bool,
    refetch: Option<&str>,
    offline: bool,
) -> Result<usize, PackageError> {
    let ctx = workspace.load_manifest_context()?;
    let existing = LockFile::load(&ctx.lock_path())?;
    if ctx.manifest.dependencies.is_empty() {
        if !frozen {
            LockFile::default().save(&ctx.lock_path())?;
        }
        return Ok(0);
    }

    if (frozen || offline) && existing.is_none() {
        return Err(format!("{} is missing", ctx.lock_path().display()).into());
    }

    let desired = build_lockfile(
        workspace,
        &ctx,
        existing.as_ref(),
        None,
        false,
        !frozen && !offline,
        offline,
    )?;
    if frozen || offline {
        if existing.as_ref() != Some(&desired) {
            return Err(format!("{} would need to change", ctx.lock_path().display()).into());
        }
    } else {
        desired.save(&ctx.lock_path())?;
    }
    materialize_dependencies_from_lock(workspace, &ctx, &desired, refetch, offline)
}

pub fn install_packages(frozen: bool, refetch: Option<&str>, offline: bool, json: bool) {
    match install_packages_impl(frozen, refetch, offline) {
        Ok(installed) if json => {
            print_install_summary_json("install", installed, frozen, offline);
        }
        Ok(0) => println!("No dependencies to install."),
        Ok(installed) => println!("Installed {installed} package(s) to {PKG_DIR}/"),
        Err(error) if json => {
            print_install_error_json("install", &error);
            process::exit(1);
        }
        Err(error) => {
            eprintln!("error: {error}");
            process::exit(1);
        }
    }
}

fn print_install_summary_json(action: &str, installed: usize, frozen: bool, offline: bool) {
    let body = serde_json::json!({
        "action": action,
        "ok": true,
        "installed": installed,
        "frozen": frozen,
        "offline": offline,
        "lock_file": LOCK_FILE,
        "packages_dir": PKG_DIR,
    });
    println!(
        "{}",
        serde_json::to_string_pretty(&body).unwrap_or_default()
    );
}

fn print_install_error_json(action: &str, error: &PackageError) {
    let body = serde_json::json!({
        "action": action,
        "ok": false,
        "error": error.to_string(),
    });
    println!(
        "{}",
        serde_json::to_string_pretty(&body).unwrap_or_default()
    );
}

pub fn lock_packages() {
    let result = (|| -> Result<usize, PackageError> {
        let workspace = PackageWorkspace::from_current_dir()?;
        let ctx = workspace.load_manifest_context()?;
        let existing = LockFile::load(&ctx.lock_path())?;
        let lock = build_lockfile(&workspace, &ctx, existing.as_ref(), None, true, true, false)?;
        lock.save(&ctx.lock_path())?;
        Ok(lock.packages.len())
    })();

    match result {
        Ok(count) => println!("Wrote {LOCK_FILE} with {count} package(s)."),
        Err(error) => {
            eprintln!("error: {error}");
            process::exit(1);
        }
    }
}

pub fn update_packages(alias: Option<&str>, all: bool, json: bool) {
    let result = PackageWorkspace::from_current_dir()
        .and_then(|workspace| update_packages_in(&workspace, alias, all));
    print_update_packages_result(result, json);
}

pub(crate) fn update_packages_in(
    workspace: &PackageWorkspace,
    alias: Option<&str>,
    all: bool,
) -> Result<usize, PackageError> {
    if !all && alias.is_none() {
        return Err("specify a dependency alias or pass --all"
            .to_string()
            .into());
    }

    let ctx = workspace.load_manifest_context()?;
    if let Some(alias) = alias {
        validate_package_alias(alias)?;
        if !ctx.manifest.dependencies.contains_key(alias) {
            return Err(format!("{alias} is not present in [dependencies]").into());
        }
    }
    let existing = LockFile::load(&ctx.lock_path())?;
    let lock = build_lockfile(workspace, &ctx, existing.as_ref(), alias, all, true, false)?;
    lock.save(&ctx.lock_path())?;
    materialize_dependencies_from_lock(workspace, &ctx, &lock, None, false)
}

fn print_update_packages_result(result: Result<usize, PackageError>, json: bool) {
    match result {
        Ok(installed) if json => print_install_summary_json("update", installed, false, false),
        Ok(installed) => println!("Updated {installed} package(s)."),
        Err(error) if json => {
            print_install_error_json("update", &error);
            process::exit(1);
        }
        Err(error) => {
            eprintln!("error: {error}");
            process::exit(1);
        }
    }
}

pub fn remove_package(alias: &str) {
    let result = PackageWorkspace::from_current_dir()
        .and_then(|workspace| remove_package_in(&workspace, alias));
    print_remove_package_result(alias, result);
}

pub(crate) fn remove_package_in(
    workspace: &PackageWorkspace,
    alias: &str,
) -> Result<bool, PackageError> {
    validate_package_alias(alias)?;
    let ctx = workspace.load_manifest_context()?;
    let removed = remove_dependency_from_manifest(&ctx.manifest_path(), alias)?;
    if !removed {
        return Ok(false);
    }
    let mut lock = LockFile::load(&ctx.lock_path())?.unwrap_or_default();
    lock.remove(alias);
    lock.save(&ctx.lock_path())?;
    remove_materialized_package(&ctx.packages_dir(), alias)?;
    Ok(true)
}

fn print_remove_package_result(alias: &str, result: Result<bool, PackageError>) {
    match result {
        Ok(true) => println!("Removed {alias} from {MANIFEST} and {LOCK_FILE}."),
        Ok(false) => {
            eprintln!("error: {alias} is not present in [dependencies]");
            process::exit(1);
        }
        Err(error) => {
            eprintln!("error: {error}");
            process::exit(1);
        }
    }
}

#[derive(Clone, Copy, Debug)]
pub(crate) struct AddPackageRequest<'a> {
    name_or_spec: &'a str,
    alias: Option<&'a str>,
    git_url: Option<&'a str>,
    tag: Option<&'a str>,
    rev: Option<&'a str>,
    branch: Option<&'a str>,
    local_path: Option<&'a str>,
    registry: Option<&'a str>,
}

#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub(crate) fn normalize_add_request(
    name_or_spec: &str,
    alias: Option<&str>,
    git_url: Option<&str>,
    tag: Option<&str>,
    rev: Option<&str>,
    branch: Option<&str>,
    local_path: Option<&str>,
    registry: Option<&str>,
) -> Result<(String, Dependency), PackageError> {
    normalize_add_request_in(
        &PackageWorkspace::from_current_dir()?,
        AddPackageRequest {
            name_or_spec,
            alias,
            git_url,
            tag,
            rev,
            branch,
            local_path,
            registry,
        },
    )
}

pub(crate) fn normalize_add_request_in(
    workspace: &PackageWorkspace,
    request: AddPackageRequest<'_>,
) -> Result<(String, Dependency), PackageError> {
    let AddPackageRequest {
        name_or_spec,
        alias,
        git_url,
        tag,
        rev,
        branch,
        local_path,
        registry,
    } = request;

    if local_path.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
        return Err("path dependencies do not accept --rev, --tag, or --branch"
            .to_string()
            .into());
    }
    if git_url.is_none()
        && local_path.is_none()
        && rev.is_none()
        && tag.is_none()
        && branch.is_none()
    {
        if let Some(path) = existing_local_path_spec(name_or_spec) {
            let alias = alias
                .map(str::to_string)
                .map(Ok)
                .unwrap_or_else(|| derive_package_alias_from_path(&path))?;
            validate_package_alias(&alias)?;
            return Ok((
                alias,
                Dependency::Table(Box::new(DepTable {
                    path: Some(name_or_spec.to_string()),
                    ..DepTable::default()
                })),
            ));
        }
        if parse_registry_package_spec(name_or_spec).is_some() {
            return registry_dependency_from_spec_in(workspace, name_or_spec, alias, registry);
        }
    }
    if git_url.is_some() || local_path.is_some() {
        if let Some(path) = local_path {
            let alias = alias
                .map(str::to_string)
                .unwrap_or_else(|| name_or_spec.to_string());
            validate_package_alias(&alias)?;
            return Ok((
                alias,
                Dependency::Table(Box::new(DepTable {
                    path: Some(path.to_string()),
                    ..DepTable::default()
                })),
            ));
        }
        let alias = alias.unwrap_or(name_or_spec).to_string();
        validate_package_alias(&alias)?;
        if rev.is_some() && tag.is_some() {
            return Err("use only one of --rev or --tag".to_string().into());
        }
        if rev.is_none() && tag.is_none() && branch.is_none() {
            return Err(format!(
                "git dependency {alias} must specify `tag`, `rev`, or `branch`; use `harn add <url>@<tag-or-sha>` or pass `--tag`/`--rev`/`--branch`"
            ).into());
        }
        let git = normalize_git_url(git_url.ok_or_else(|| "missing --git URL".to_string())?)?;
        let package_name = derive_repo_name_from_source(&git)?;
        return Ok((
            alias.clone(),
            Dependency::Table(Box::new(DepTable {
                git: Some(git),
                tag: tag.map(str::to_string),
                rev: rev.map(str::to_string),
                branch: branch.map(str::to_string),
                package: (alias != package_name).then_some(package_name),
                ..DepTable::default()
            })),
        ));
    }

    if rev.is_some() && tag.is_some() {
        return Err("use only one of --rev or --tag".to_string().into());
    }
    let (raw_source, inline_ref) = parse_positional_git_spec(name_or_spec);
    if inline_ref.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
        return Err(
            "specify the git ref either inline as @ref or via --tag/--rev/--branch"
                .to_string()
                .into(),
        );
    }
    let git = normalize_git_url(raw_source)?;
    let package_name = derive_repo_name_from_source(&git)?;
    let alias = alias.unwrap_or(package_name.as_str()).to_string();
    validate_package_alias(&alias)?;
    if inline_ref.is_none() && rev.is_none() && tag.is_none() && branch.is_none() {
        return Err(format!(
            "git dependency {alias} must specify `tag`, `rev`, or `branch`; use `harn add {raw_source}@<tag-or-sha>` or pass `--tag`/`--rev`/`--branch`"
        ).into());
    }
    Ok((
        alias.clone(),
        Dependency::Table(Box::new(DepTable {
            git: Some(git),
            tag: tag.map(str::to_string),
            rev: inline_ref.or(rev).map(str::to_string),
            branch: branch.map(str::to_string),
            package: (alias != package_name).then_some(package_name),
            ..DepTable::default()
        })),
    ))
}

#[cfg(test)]
pub fn add_package(
    name_or_spec: &str,
    alias: Option<&str>,
    git_url: Option<&str>,
    tag: Option<&str>,
    rev: Option<&str>,
    branch: Option<&str>,
    local_path: Option<&str>,
) {
    add_package_with_registry(
        name_or_spec,
        alias,
        git_url,
        tag,
        rev,
        branch,
        local_path,
        None,
    );
}

pub fn add_package_with_registry(
    name_or_spec: &str,
    alias: Option<&str>,
    git_url: Option<&str>,
    tag: Option<&str>,
    rev: Option<&str>,
    branch: Option<&str>,
    local_path: Option<&str>,
    registry: Option<&str>,
) {
    let result = PackageWorkspace::from_current_dir().and_then(|workspace| {
        add_package_to(
            &workspace,
            name_or_spec,
            alias,
            git_url,
            tag,
            rev,
            branch,
            local_path,
            registry,
        )
    });

    match result {
        Ok((alias, installed)) => {
            println!("Added {alias} to {MANIFEST}.");
            println!("Installed {installed} package(s).");
        }
        Err(error) => {
            eprintln!("error: {error}");
            process::exit(1);
        }
    }
}

#[allow(clippy::too_many_arguments)]
pub(crate) fn add_package_to(
    workspace: &PackageWorkspace,
    name_or_spec: &str,
    alias: Option<&str>,
    git_url: Option<&str>,
    tag: Option<&str>,
    rev: Option<&str>,
    branch: Option<&str>,
    local_path: Option<&str>,
    registry: Option<&str>,
) -> Result<(String, usize), PackageError> {
    let manifest_path = workspace.manifest_dir().join(MANIFEST);
    let (alias, dependency) = normalize_add_request_in(
        workspace,
        AddPackageRequest {
            name_or_spec,
            alias,
            git_url,
            tag,
            rev,
            branch,
            local_path,
            registry,
        },
    )?;
    upsert_dependency_in_manifest(&manifest_path, &alias, &dependency)?;
    let installed = install_packages_in(workspace, false, None, false)?;
    Ok((alias, installed))
}

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

    #[test]
    fn lock_file_round_trips_typed_schema() {
        let tmp = tempfile::tempdir().unwrap();
        let path = tmp.path().join(LOCK_FILE);
        let lock = LockFile {
            version: LOCK_FILE_VERSION,
            generator_version: current_generator_version(),
            protocol_artifact_version: current_protocol_artifact_version(),
            packages: vec![LockEntry {
                name: "acme-lib".to_string(),
                source: "git+https://github.com/acme/acme-lib".to_string(),
                tag: Some("v1.0.0".to_string()),
                rev_request: Some("v1.0.0".to_string()),
                commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
                content_hash: Some("sha256:deadbeef".to_string()),
                package_version: Some("1.0.0".to_string()),
                harn_compat: Some(">=0.8,<0.9".to_string()),
                provenance: Some(
                    "https://github.com/acme/acme-lib/releases/tag/v1.0.0".to_string(),
                ),
                manifest_digest: Some("sha256:cafebabe".to_string()),
                registry: None,
                exports: PackageLockExports {
                    modules: vec![PackageLockExport {
                        name: "lib".to_string(),
                        path: Some("lib/main.harn".to_string()),
                        symbol: None,
                    }],
                    tools: vec![PackageLockExport {
                        name: "echo".to_string(),
                        path: Some("lib/tools.harn".to_string()),
                        symbol: Some("tools".to_string()),
                    }],
                    skills: Vec::new(),
                    personas: Vec::new(),
                },
                permissions: vec!["tool:read_only".to_string()],
                host_requirements: vec!["workspace.read_text".to_string()],
            }],
        };
        lock.save(&path).unwrap();
        let loaded = LockFile::load(&path).unwrap().unwrap();
        assert_eq!(loaded, lock);
    }

    #[test]
    fn add_and_remove_git_dependency_round_trip() {
        let (_repo_tmp, repo, _branch) = create_git_package_repo();
        let project_tmp = tempfile::tempdir().unwrap();
        let root = project_tmp.path();
        let workspace = TestWorkspace::new(root);
        fs::create_dir_all(root.join(".git")).unwrap();
        fs::write(
            root.join(MANIFEST),
            r#"
    [package]
    name = "workspace"
    version = "0.1.0"
    "#,
        )
        .unwrap();

        let spec = format!("{}@v1.0.0", repo.display());
        add_package_to(
            workspace.env(),
            &spec,
            None,
            None,
            None,
            None,
            None,
            None,
            None,
        )
        .unwrap();

        let alias = "acme-lib";
        let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
        assert!(manifest.contains("acme-lib"));
        assert!(manifest.contains("rev = \"v1.0.0\""));

        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
        let entry = lock.find(alias).unwrap();
        assert_eq!(lock.version, LOCK_FILE_VERSION);
        assert!(entry.source.starts_with("git+file://"));
        assert!(entry.commit.as_deref().is_some_and(is_full_git_sha));
        assert!(entry
            .content_hash
            .as_deref()
            .is_some_and(|hash| hash.starts_with("sha256:")));
        assert!(root.join(PKG_DIR).join(alias).join("lib.harn").is_file());

        remove_package_in(workspace.env(), alias).unwrap();
        let updated_manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
        assert!(!updated_manifest.contains("acme-lib ="));
        let updated_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
        assert!(updated_lock.find(alias).is_none());
        assert!(!root.join(PKG_DIR).join(alias).exists());
    }

    #[test]
    fn install_resolves_git_tag_dependency_and_records_tag() {
        let (_repo_tmp, repo, _branch) = create_git_package_repo();
        let project_tmp = tempfile::tempdir().unwrap();
        let root = project_tmp.path();
        let workspace = TestWorkspace::new(root);
        fs::create_dir_all(root.join(".git")).unwrap();
        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
        fs::write(
            root.join(MANIFEST),
            format!(
                r#"
    [package]
    name = "workspace"
    version = "0.1.0"

    [dependencies]
    acme-lib = {{ git = "{git}", tag = "v1.0.0" }}
    "#
            ),
        )
        .unwrap();

        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();

        assert_eq!(installed, 1);
        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
        let entry = lock.find("acme-lib").unwrap();
        assert_eq!(entry.tag.as_deref(), Some("v1.0.0"));
        assert_eq!(entry.rev_request.as_deref(), Some("v1.0.0"));
        assert!(entry.commit.as_deref().is_some_and(is_full_git_sha));
        assert!(entry.content_hash.as_deref().is_some());
        assert!(root
            .join(PKG_DIR)
            .join("acme-lib")
            .join("lib.harn")
            .is_file());
    }

    #[test]
    fn install_resolves_registry_version_range_to_highest_matching_tag() {
        let (_repo_tmp, repo, _branch) = create_git_package_repo();
        fs::write(
            repo.join("lib.harn"),
            "pub fn value() -> string { return \"v0.1.1\" }\n",
        )
        .unwrap();
        run_git(&repo, &["add", "."]);
        run_git(&repo, &["commit", "-m", "v0.1.1"]);
        run_git(&repo, &["tag", "v0.1.1"]);
        fs::write(
            repo.join("lib.harn"),
            "pub fn value() -> string { return \"v0.2.0\" }\n",
        )
        .unwrap();
        run_git(&repo, &["add", "."]);
        run_git(&repo, &["commit", "-m", "v0.2.0"]);
        run_git(&repo, &["tag", "v0.2.0"]);

        let project_tmp = tempfile::tempdir().unwrap();
        let root = project_tmp.path();
        let registry_path = root.join("index.toml");
        let workspace =
            TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
        fs::create_dir_all(root.join(".git")).unwrap();
        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
        fs::write(
            &registry_path,
            format!(
                r#"
version = 1

[[package]]
name = "acme-lib"
repository = "{git}"

[[package.version]]
version = "0.1.0"
git = "{git}"
tag = "v1.0.0"

[[package.version]]
version = "0.1.1"
git = "{git}"
tag = "v0.1.1"

[[package.version]]
version = "0.2.0"
git = "{git}"
tag = "v0.2.0"
"#
            ),
        )
        .unwrap();
        fs::write(
            root.join(MANIFEST),
            r#"
    [package]
    name = "workspace"
    version = "0.1.0"

    [dependencies]
    acme-lib = { version = ">=0.1,<0.2" }
    "#,
        )
        .unwrap();

        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();

        assert_eq!(installed, 1);
        let lock_path = root.join(LOCK_FILE);
        let lock = LockFile::load(&lock_path).unwrap().unwrap();
        let entry = lock.find("acme-lib").unwrap();
        assert_eq!(entry.tag.as_deref(), Some("v0.1.1"));
        assert_eq!(entry.rev_request.as_deref(), Some("v0.1.1"));
        assert_eq!(
            entry
                .registry
                .as_ref()
                .map(|registry| registry.version.as_str()),
            Some("0.1.1")
        );
        let source =
            fs::read_to_string(root.join(PKG_DIR).join("acme-lib").join("lib.harn")).unwrap();
        assert!(source.contains("v0.1.1"), "{source}");

        let original_lock = fs::read_to_string(&lock_path).unwrap();
        fs::remove_dir_all(root.join(PKG_DIR)).unwrap();
        fs::remove_dir_all(&repo).unwrap();
        fs::remove_file(&registry_path).unwrap();

        let reinstalled = install_packages_in(workspace.env(), true, None, true).unwrap();
        assert_eq!(reinstalled, 1);
        assert_eq!(fs::read_to_string(&lock_path).unwrap(), original_lock);
        assert!(root
            .join(PKG_DIR)
            .join("acme-lib")
            .join("lib.harn")
            .is_file());
    }

    #[test]
    fn update_branch_dependency_refreshes_locked_commit() {
        let (_repo_tmp, repo, branch) = create_git_package_repo();
        let project_tmp = tempfile::tempdir().unwrap();
        let root = project_tmp.path();
        let workspace = TestWorkspace::new(root);
        fs::create_dir_all(root.join(".git")).unwrap();
        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
        fs::write(
            root.join(MANIFEST),
            format!(
                r#"
    [package]
    name = "workspace"
    version = "0.1.0"

    [dependencies]
    acme-lib = {{ git = "{git}", branch = "{branch}" }}
    "#
            ),
        )
        .unwrap();

        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
        assert_eq!(installed, 1);
        let first_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
        let first_commit = first_lock
            .find("acme-lib")
            .and_then(|entry| entry.commit.clone())
            .unwrap();

        fs::write(
            repo.join("lib.harn"),
            "pub fn value() -> string { return \"v2\" }\n",
        )
        .unwrap();
        run_git(&repo, &["add", "."]);
        run_git(&repo, &["commit", "-m", "update"]);

        update_packages_in(workspace.env(), Some("acme-lib"), false).unwrap();
        let second_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
        let second_commit = second_lock
            .find("acme-lib")
            .and_then(|entry| entry.commit.clone())
            .unwrap();
        assert_ne!(first_commit, second_commit);
    }

    #[test]
    fn add_positional_local_path_dependency_uses_manifest_name_and_live_link() {
        let dependency_tmp = tempfile::tempdir().unwrap();
        let dependency_root = dependency_tmp.path().join("harn-openapi");
        fs::create_dir_all(&dependency_root).unwrap();
        fs::write(
            dependency_root.join(MANIFEST),
            r#"
    [package]
    name = "openapi"
    version = "0.1.0"
    "#,
        )
        .unwrap();
        fs::write(
            dependency_root.join("lib.harn"),
            "pub fn version() -> string { return \"v1\" }\n",
        )
        .unwrap();

        let project_tmp = tempfile::tempdir().unwrap();
        let root = project_tmp.path();
        let workspace = TestWorkspace::new(root);
        fs::create_dir_all(root.join(".git")).unwrap();
        fs::write(
            root.join(MANIFEST),
            r#"
    [package]
    name = "workspace"
    version = "0.1.0"
    "#,
        )
        .unwrap();

        add_package_to(
            workspace.env(),
            dependency_root.to_string_lossy().as_ref(),
            None,
            None,
            None,
            None,
            None,
            None,
            None,
        )
        .unwrap();

        let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
        assert!(
            manifest.contains("openapi = { path = "),
            "manifest should use package.name as alias: {manifest}"
        );
        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
        let entry = lock.find("openapi").expect("openapi lock entry");
        assert!(entry.source.starts_with("path+file://"));
        let materialized = root.join(PKG_DIR).join("openapi");
        assert!(materialized.join("lib.harn").is_file());

        #[cfg(unix)]
        assert!(
            fs::symlink_metadata(&materialized)
                .unwrap()
                .file_type()
                .is_symlink(),
            "path dependencies should be live-linked on Unix"
        );

        #[cfg(windows)]
        let materialized_is_link = fs::symlink_metadata(&materialized)
            .unwrap()
            .file_type()
            .is_symlink();

        fs::write(
            dependency_root.join("lib.harn"),
            "pub fn version() -> string { return \"v2\" }\n",
        )
        .unwrap();
        #[cfg(unix)]
        {
            let live_source = fs::read_to_string(materialized.join("lib.harn")).unwrap();
            assert!(
                live_source.contains("v2"),
                "materialized path dependency should reflect sibling repo edits"
            );
        }
        #[cfg(windows)]
        {
            let materialized_source = fs::read_to_string(materialized.join("lib.harn")).unwrap();
            if materialized_is_link {
                assert!(
                    materialized_source.contains("v2"),
                    "Windows path dependency symlink should reflect sibling repo edits"
                );
            } else {
                assert!(
                    materialized_source.contains("v1"),
                    "Windows path dependency copy fallback should keep the copied contents"
                );
            }
        }

        remove_package_in(workspace.env(), "openapi").unwrap();
        assert!(!materialized.exists());
        assert!(dependency_root.join("lib.harn").exists());
    }

    #[test]
    fn frozen_install_errors_when_lockfile_is_missing() {
        let (_repo_tmp, repo, _branch) = create_git_package_repo();
        let project_tmp = tempfile::tempdir().unwrap();
        let root = project_tmp.path();
        let workspace = TestWorkspace::new(root);
        fs::create_dir_all(root.join(".git")).unwrap();
        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
        fs::write(
            root.join(MANIFEST),
            format!(
                r#"
    [package]
    name = "workspace"
    version = "0.1.0"

    [dependencies]
    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
    "#
            ),
        )
        .unwrap();

        let error = install_packages_in(workspace.env(), true, None, false).unwrap_err();
        assert!(error.to_string().contains(LOCK_FILE));
    }

    #[test]
    fn offline_locked_install_materializes_from_cache_without_source_repo() {
        let (_repo_tmp, repo, _branch) = create_git_package_repo();
        let project_tmp = tempfile::tempdir().unwrap();
        let root = project_tmp.path();
        let workspace = TestWorkspace::new(root);
        fs::create_dir_all(root.join(".git")).unwrap();
        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
        fs::write(
            root.join(MANIFEST),
            format!(
                r#"
    [package]
    name = "workspace"
    version = "0.1.0"

    [dependencies]
    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
    "#
            ),
        )
        .unwrap();

        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
        assert_eq!(installed, 1);
        fs::remove_dir_all(root.join(PKG_DIR)).unwrap();
        fs::remove_dir_all(&repo).unwrap();

        let installed = install_packages_in(workspace.env(), true, None, true).unwrap();
        assert_eq!(installed, 1);
        assert!(root
            .join(PKG_DIR)
            .join("acme-lib")
            .join("lib.harn")
            .is_file());
    }

    #[test]
    fn offline_locked_install_fails_when_cache_is_missing() {
        let (_repo_tmp, repo, _branch) = create_git_package_repo();
        let project_tmp = tempfile::tempdir().unwrap();
        let root = project_tmp.path();
        let workspace = TestWorkspace::new(root);
        let cache_dir = workspace.cache_dir();
        fs::create_dir_all(root.join(".git")).unwrap();
        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
        fs::write(
            root.join(MANIFEST),
            format!(
                r#"
    [package]
    name = "workspace"
    version = "0.1.0"

    [dependencies]
    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
    "#
            ),
        )
        .unwrap();

        install_packages_in(workspace.env(), false, None, false).unwrap();
        fs::remove_dir_all(cache_dir.join("git")).unwrap();
        let error = install_packages_in(workspace.env(), true, None, true).unwrap_err();
        assert!(error.to_string().contains("offline mode"));
    }

    #[test]
    fn add_github_shorthand_requires_version_or_ref() {
        let error = normalize_add_request(
            "github.com/burin-labs/harn-openapi",
            None,
            None,
            None,
            None,
            None,
            None,
            None,
        )
        .unwrap_err();
        assert!(error
            .to_string()
            .contains("must specify `tag`, `rev`, or `branch`"));
    }

    #[test]
    fn add_github_shorthand_with_ref_writes_git_dependency() {
        let (alias, dependency) = normalize_add_request(
            "github.com/burin-labs/harn-openapi@v1.2.3",
            None,
            None,
            None,
            None,
            None,
            None,
            None,
        )
        .unwrap();
        assert_eq!(alias, "harn-openapi");
        assert_eq!(
            render_dependency_line(&alias, &dependency).unwrap(),
            "harn-openapi = { git = \"https://github.com/burin-labs/harn-openapi\", rev = \"v1.2.3\" }"
        );
    }
    #[test]
    fn install_resolves_transitive_git_dependencies_from_clean_cache() {
        let (_sdk_tmp, sdk_repo, _branch) = create_git_package_repo_with(
            "notion-sdk-harn",
            "",
            "pub fn sdk_value() -> string { return \"sdk\" }\n",
        );
        let sdk_git = normalize_git_url(sdk_repo.to_string_lossy().as_ref()).unwrap();
        let connector_tail = format!(
            r#"

    [dependencies]
    notion-sdk-harn = {{ git = "{sdk_git}", rev = "v1.0.0" }}
    "#
        );
        let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
            "notion-connector-harn",
            &connector_tail,
            r#"
    import "notion-sdk-harn"

    pub fn connector_value() -> string {
      return "connector"
    }
    "#,
        );

        let project_tmp = tempfile::tempdir().unwrap();
        let root = project_tmp.path();
        let workspace = TestWorkspace::new(root);
        fs::create_dir_all(root.join(".git")).unwrap();
        let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
        fs::write(
            root.join(MANIFEST),
            format!(
                r#"
    [package]
    name = "workspace"
    version = "0.1.0"

    [dependencies]
    notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
    "#
            ),
        )
        .unwrap();

        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
        assert_eq!(installed, 2);

        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
        assert!(lock.find("notion-connector-harn").is_some());
        assert!(lock.find("notion-sdk-harn").is_some());
        assert!(root
            .join(PKG_DIR)
            .join("notion-connector-harn")
            .join("lib.harn")
            .is_file());
        assert!(root
            .join(PKG_DIR)
            .join("notion-sdk-harn")
            .join("lib.harn")
            .is_file());

        let mut vm = test_vm();
        let exports = futures::executor::block_on(
            vm.load_module_exports(
                &root
                    .join(PKG_DIR)
                    .join("notion-connector-harn")
                    .join("lib.harn"),
            ),
        )
        .expect("transitive import should load from the workspace package root");
        assert!(exports.contains_key("connector_value"));
    }

    #[test]
    fn git_packages_reject_transitive_path_dependencies() {
        let connector_tail = r#"

    [dependencies]
    local-helper = { path = "../helper" }
    "#;
        let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
            "notion-connector-harn",
            connector_tail,
            "pub fn connector_value() -> string { return \"connector\" }\n",
        );

        let project_tmp = tempfile::tempdir().unwrap();
        let root = project_tmp.path();
        let workspace = TestWorkspace::new(root);
        fs::create_dir_all(root.join(".git")).unwrap();
        let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
        fs::write(
            root.join(MANIFEST),
            format!(
                r#"
    [package]
    name = "workspace"
    version = "0.1.0"

    [dependencies]
    notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
    "#
            ),
        )
        .unwrap();

        let error = install_packages_in(workspace.env(), false, None, false).unwrap_err();
        assert!(error
            .to_string()
            .contains("path dependencies are not supported inside git-installed packages"));
    }

    #[test]
    fn package_alias_validation_rejects_path_traversal_names() {
        for alias in [
            "../evil",
            "nested/evil",
            "nested\\evil",
            ".",
            "..",
            "bad alias",
        ] {
            assert!(
                validate_package_alias(alias).is_err(),
                "{alias:?} should be rejected"
            );
        }
        validate_package_alias("acme-lib_1.2").expect("ordinary alias should be accepted");
    }

    #[test]
    fn add_package_rejects_aliases_that_escape_packages_dir() {
        let error = normalize_add_request(
            "ignored",
            Some("../evil"),
            None,
            None,
            None,
            None,
            Some("./dep"),
            None,
        )
        .unwrap_err();
        assert!(error.to_string().contains("invalid dependency alias"));
    }

    #[test]
    fn rendered_dependency_values_are_toml_escaped() {
        let path = "dep\" \nmalicious = true";
        let line = render_dependency_line(
            "safe",
            &Dependency::Table(Box::new(DepTable {
                path: Some(path.to_string()),
                ..DepTable::default()
            })),
        )
        .expect("dependency line");
        let parsed: Manifest = toml::from_str(&format!("[dependencies]\n{line}\n")).unwrap();
        assert_eq!(parsed.dependencies.len(), 1);
        assert_eq!(
            parsed
                .dependencies
                .get("safe")
                .and_then(Dependency::local_path),
            Some(path)
        );
    }

    #[test]
    fn materialization_rejects_lock_alias_path_traversal_before_removing_paths() {
        let tmp = tempfile::tempdir().unwrap();
        let dep = tmp.path().join("dep");
        fs::create_dir_all(&dep).unwrap();
        fs::write(dep.join("lib.harn"), "pub fn dep() { 1 }\n").unwrap();
        let victim = tmp.path().join("victim");
        fs::create_dir_all(&victim).unwrap();
        fs::write(victim.join("keep.txt"), "keep").unwrap();

        let manifest: Manifest = toml::from_str("[package]\nname = \"root\"\n").unwrap();
        let ctx = ManifestContext {
            manifest,
            dir: tmp.path().to_path_buf(),
        };
        let workspace = TestWorkspace::new(tmp.path());
        let lock = LockFile {
            packages: vec![LockEntry {
                name: "../victim".to_string(),
                source: path_source_uri(&dep).unwrap(),
                ..LockEntry::default()
            }],
            ..LockFile::default()
        };

        let error = materialize_dependencies_from_lock(workspace.env(), &ctx, &lock, None, false)
            .unwrap_err();
        assert!(error.to_string().contains("invalid dependency alias"));
        assert!(
            victim.join("keep.txt").exists(),
            "malicious alias should not remove paths outside .harn/packages"
        );
    }
}
