//! Go-module-style dependency management for WIT packages.
//!
//! A component declares exact dependencies in `wasmeld.toml`. Resolution
//! prefers root-level path replacements and otherwise downloads immutable
//! binary packages from the configured Registry. The complete transitive graph
//! is materialized under `wit/deps` and recorded in `wit.lock`.
//!
//! `wasmeld.toml` is the human-authored input; `wit.lock` is the reproducibility
//! record; and `wit/deps` is disposable generated output. Commit the first two,
//! but regenerate `wit/deps` with [`crate::module::sync_dependencies`] after
//! cloning.
//!
//! # Example
//!
//! ```no_run
//! use wasmeld_package::module::sync_dependencies;
//!
//! // CI uses locked mode so changed Registry content, replacements, or
//! // transitive dependencies fail instead of rewriting wit.lock.
//! let report = sync_dependencies("components/image-resize/wasmeld.toml", true)?;
//! println!("resolved {} packages", report.packages.len());
//! # Ok::<(), wasmeld_package::module::ModuleError>(())
//! ```

use std::{
    collections::{BTreeMap, BTreeSet},
    fs,
    io::{self, Read},
    path::{Component, Path, PathBuf},
};

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

use crate::wit_package::{WitDependency, inspect_wit_package};

/// Component dependency manifest filename.
pub const MODULE_MANIFEST_FILE: &str = "wasmeld.toml";
/// Resolved dependency lock filename.
pub const MODULE_LOCK_FILE: &str = "wit.lock";
/// Current `wasmeld.toml` schema.
pub const MODULE_SCHEMA_VERSION: u32 = 1;
/// Current `wit.lock` schema.
pub const LOCK_SCHEMA_VERSION: u32 = 1;
const MAX_FETCHED_WIT_PACKAGE_BYTES: usize = 16 * 1024 * 1024;

/// Parsed `wasmeld.toml` dependency configuration.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ModuleManifest {
    /// Manifest format version.
    pub schema_version: u32,
    /// Local WIT source and generated dependency layout.
    #[serde(default)]
    pub wit: WitConfig,
    /// Exact root dependencies keyed by `namespace:package`.
    #[serde(default)]
    pub dependencies: BTreeMap<String, String>,
    /// Registry used for dependencies without a path replacement.
    #[serde(default, skip_serializing_if = "RegistryConfig::is_empty")]
    pub registry: RegistryConfig,
    /// Root-controlled package or package-version path replacements.
    #[serde(default, rename = "replace")]
    pub replacements: BTreeMap<String, PathReplacement>,
}

/// Local WIT source layout.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WitConfig {
    /// WIT root relative to `wasmeld.toml`; defaults to `wit`.
    #[serde(default = "default_wit_root")]
    pub root: PathBuf,
}

impl Default for WitConfig {
    fn default() -> Self {
        Self {
            root: default_wit_root(),
        }
    }
}

/// Local source override for a Registry package.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PathReplacement {
    /// Replacement package directory relative to `wasmeld.toml`.
    pub path: PathBuf,
}

/// Remote WIT Registry configuration.
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RegistryConfig {
    /// Base HTTP(S) URL of a Wasmeld Console instance.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
}

impl RegistryConfig {
    fn is_empty(&self) -> bool {
        self.url.is_none()
    }
}

/// Complete dependency graph captured in `wit.lock`.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ModuleLock {
    /// Lock format version.
    pub schema_version: u32,
    /// Direct and transitive packages in deterministic identity order.
    #[serde(default, rename = "package")]
    pub packages: Vec<LockedPackage>,
}

/// Reproducibility data for one resolved package.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct LockedPackage {
    /// Package identity in `namespace:package` form.
    pub name: String,
    /// Exact semantic version.
    pub version: String,
    /// `registry+URL` or `path+relative/path`.
    pub source: String,
    /// Digest of the binary package or normalized local WIT source.
    pub sha256: String,
    /// Whether the root manifest selected this package through `replace`.
    pub replaced: bool,
}

/// One materialized package returned after dependency synchronization.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ResolvedPackage {
    /// Package identity in `namespace:package` form.
    pub name: String,
    /// Exact semantic version.
    pub version: String,
    /// Effective Registry or path source.
    pub source: String,
    /// Whether the package came from a root path replacement.
    pub replaced: bool,
    /// Final location under the component's `wit/deps`.
    pub materialized_path: PathBuf,
    /// Content digest written to the lock file.
    pub sha256: String,
}

#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct DependencyId {
    name: String,
    version: String,
}

/// Result of resolving and materializing a component's WIT dependencies.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SyncReport {
    /// Canonical `wasmeld.toml` path.
    pub manifest_path: PathBuf,
    /// Adjacent `wit.lock` path.
    pub lock_path: PathBuf,
    /// Effective local WIT root.
    pub wit_root: PathBuf,
    /// Complete direct and transitive dependency graph.
    pub packages: Vec<ResolvedPackage>,
}

/// Errors raised while parsing, resolving, locking, or materializing WIT dependencies.
#[derive(Debug, Error)]
pub enum ModuleError {
    /// A manifest value, dependency identity, version, or path is invalid.
    #[error("invalid WIT module: {0}")]
    InvalidManifest(String),

    /// A manifest, lock file, or local WIT source could not be read.
    #[error("failed to read {path}: {source}")]
    Read {
        /// File or directory involved in the failed read.
        path: PathBuf,
        /// Underlying filesystem error.
        source: io::Error,
    },

    /// A lock file or materialized dependency could not be written.
    #[error("failed to write {path}: {source}")]
    Write {
        /// Destination that could not be updated.
        path: PathBuf,
        /// Underlying filesystem error.
        source: io::Error,
    },

    /// TOML input could not be decoded.
    #[error("failed to parse {path}: {source}")]
    ParseManifest {
        /// Manifest or lock file containing invalid TOML.
        path: PathBuf,
        /// TOML decoder error.
        source: toml::de::Error,
    },

    /// A canonical manifest or lock file could not be encoded as TOML.
    #[error("failed to serialize {path}: {source}")]
    SerializeManifest {
        /// Destination whose model could not be serialized.
        path: PathBuf,
        /// TOML encoder error.
        source: toml::ser::Error,
    },

    /// Local or downloaded bytes are not the requested versioned WIT package.
    #[error("invalid WIT package at {path}: {message}")]
    InvalidWitPackage {
        /// Source path used while validating the package.
        path: PathBuf,
        /// Identity, version, parsing, or dependency validation detail.
        message: String,
    },

    /// Locked resolution produced a graph different from `wit.lock`.
    #[error("locked dependency graph differs from {path}; run `wasmeld wit tidy`")]
    LockMismatch {
        /// Existing lock file that must be updated outside locked mode.
        path: PathBuf,
    },

    /// Fetching or validating an immutable Registry artifact failed.
    #[error("WIT registry request to {url} failed: {message}")]
    Registry {
        /// Exact Registry endpoint requested by the resolver.
        url: String,
        /// HTTP, size-limit, digest, or package-validation detail.
        message: String,
    },
}

impl ModuleManifest {
    /// Reads and validates a `wasmeld.toml` manifest.
    ///
    /// # Errors
    ///
    /// Returns [`ModuleError::Read`], [`ModuleError::ParseManifest`], or
    /// [`ModuleError::InvalidManifest`] without modifying the source file.
    pub fn read(path: impl AsRef<Path>) -> Result<Self, ModuleError> {
        let path = path.as_ref();
        let source = fs::read_to_string(path).map_err(|source| ModuleError::Read {
            path: path.to_path_buf(),
            source,
        })?;
        let manifest =
            toml::from_str::<Self>(&source).map_err(|source| ModuleError::ParseManifest {
                path: path.to_path_buf(),
                source,
            })?;
        manifest.validate()?;
        Ok(manifest)
    }

    /// Validates and writes a canonical TOML manifest.
    ///
    /// Callers editing replacements should mutate a parsed value and invoke
    /// this method once, so invalid intermediate state is never persisted.
    pub fn write(&self, path: impl AsRef<Path>) -> Result<(), ModuleError> {
        self.validate()?;
        let path = path.as_ref();
        let mut source =
            toml::to_string_pretty(self).map_err(|source| ModuleError::SerializeManifest {
                path: path.to_path_buf(),
                source,
            })?;
        if !source.ends_with('\n') {
            source.push('\n');
        }
        fs::write(path, source).map_err(|source| ModuleError::Write {
            path: path.to_path_buf(),
            source,
        })
    }

    /// Validates schema, exact versions, package names, paths, and Registry URL.
    pub fn validate(&self) -> Result<(), ModuleError> {
        if self.schema_version != MODULE_SCHEMA_VERSION {
            return Err(ModuleError::InvalidManifest(format!(
                "unsupported wasmeld.toml schema version {}, expected {MODULE_SCHEMA_VERSION}",
                self.schema_version
            )));
        }
        validate_wit_root(&self.wit.root)?;

        for (name, version) in &self.dependencies {
            validate_package_name(name)?;
            parse_version(version)?;
        }

        for (key, replacement) in &self.replacements {
            parse_replacement_key(key)?;
            validate_replacement_path(&format!("replace.{key}.path"), &replacement.path)?;
        }
        if let Some(url) = &self.registry.url {
            validate_registry_url(url)?;
        }
        Ok(())
    }

    /// Sets a package-wide or exact-version path replacement.
    ///
    /// An exact key such as `wasmeld:kv@0.1.0` takes precedence over the
    /// package-wide `wasmeld:kv` key during resolution.
    pub fn set_path_replacement(
        &mut self,
        package: impl Into<String>,
        path: impl Into<PathBuf>,
    ) -> Result<(), ModuleError> {
        let package = package.into();
        let path = path.into();
        parse_replacement_key(&package)?;
        validate_replacement_path("replacement path", &path)?;
        self.replacements.insert(package, PathReplacement { path });
        Ok(())
    }

    /// Removes a path replacement and reports whether it existed.
    pub fn drop_replacement(&mut self, package: &str) -> bool {
        self.replacements.remove(package).is_some()
    }
}

impl ModuleLock {
    /// Reads a lock file and rejects unsupported lock schemas.
    pub fn read(path: impl AsRef<Path>) -> Result<Self, ModuleError> {
        let path = path.as_ref();
        let source = fs::read_to_string(path).map_err(|source| ModuleError::Read {
            path: path.to_path_buf(),
            source,
        })?;
        let lock =
            toml::from_str::<Self>(&source).map_err(|source| ModuleError::ParseManifest {
                path: path.to_path_buf(),
                source,
            })?;
        if lock.schema_version != LOCK_SCHEMA_VERSION {
            return Err(ModuleError::InvalidManifest(format!(
                "unsupported wit.lock schema version {}, expected {LOCK_SCHEMA_VERSION}",
                lock.schema_version
            )));
        }
        Ok(lock)
    }

    fn write(&self, path: &Path) -> Result<(), ModuleError> {
        let mut source =
            toml::to_string_pretty(self).map_err(|source| ModuleError::SerializeManifest {
                path: path.to_path_buf(),
                source,
            })?;
        if !source.ends_with('\n') {
            source.push('\n');
        }
        fs::write(path, source).map_err(|source| ModuleError::Write {
            path: path.to_path_buf(),
            source,
        })
    }
}

/// Resolves, locks, and materializes all dependencies declared by a component.
///
/// Resolution is deterministic and recursively follows direct package
/// dependencies embedded in Registry artifacts. With `locked = true`, the
/// computed graph must exactly match the existing `wit.lock`; the function does
/// not update the lock file.
///
/// The dependency directory is assembled in a temporary sibling directory and
/// swapped into place only after the complete graph validates. A failed fetch
/// therefore leaves both the lock and materialized dependencies intact.
///
/// # Errors
///
/// Returns an error when the manifest or lock is invalid, a replacement has the
/// wrong identity, a Registry package is unavailable or oversized, or two paths
/// resolve the same package version to different content.
pub fn sync_dependencies(
    manifest_path: impl AsRef<Path>,
    locked: bool,
) -> Result<SyncReport, ModuleError> {
    let manifest_path = canonical_file(manifest_path.as_ref())?;
    let manifest = ModuleManifest::read(&manifest_path)?;
    let module_root = manifest_path
        .parent()
        .expect("a canonical manifest path always has a parent");
    let wit_root = module_root.join(&manifest.wit.root);
    if !wit_root.is_dir() {
        return Err(ModuleError::InvalidManifest(format!(
            "WIT root {} is not a directory",
            wit_root.display()
        )));
    }

    let mut packages = Vec::new();
    let mut materialized_names = BTreeSet::new();
    let mut pending = manifest
        .dependencies
        .iter()
        .map(|(name, version)| DependencyId {
            name: name.clone(),
            version: version.clone(),
        })
        .collect::<BTreeSet<_>>();
    let mut resolved = BTreeSet::new();
    // Resolve into a staging tree so a failed download or validation never
    // leaves a partially updated wit/deps directory.
    let staging = wit_root.join(format!(".deps.tmp-{}", std::process::id()));
    remove_directory_if_present(&staging)?;
    create_directory(&staging)?;
    let registry_url = manifest
        .registry
        .url
        .as_deref()
        .map(normalize_registry_url)
        .transpose()?;
    let client = reqwest::blocking::Client::builder()
        .user_agent(format!("wasmeld/{}", env!("CARGO_PKG_VERSION")))
        .build()
        .map_err(|error| ModuleError::Registry {
            url: registry_url.clone().unwrap_or_default(),
            message: error.to_string(),
        })?;

    let result = (|| {
        while let Some(dependency) = pending.pop_first() {
            if !resolved.insert(dependency.clone()) {
                continue;
            }
            let directory_name = materialized_directory_name(&dependency.name, &dependency.version);
            if !materialized_names.insert(directory_name.clone()) {
                return Err(ModuleError::InvalidManifest(format!(
                    "dependencies produce the same materialized directory {directory_name:?}"
                )));
            }

            // Only the root manifest controls replacement. Exact-version keys
            // take precedence over package-wide keys in select_replacement.
            let replacement = select_replacement(&manifest, &dependency.name, &dependency.version);
            let (source, replaced, sha256, transitive, materialized_path) =
                if let Some(replacement) = replacement {
                    let source_path = canonical_directory(&module_root.join(&replacement.path))?;
                    let unresolved =
                        UnresolvedPackageGroup::parse_path(&source_path).map_err(|error| {
                            ModuleError::InvalidWitPackage {
                                path: source_path.clone(),
                                message: error.to_string(),
                            }
                        })?;
                    let actual_name = format!(
                        "{}:{}",
                        unresolved.main.name.namespace, unresolved.main.name.name
                    );
                    let actual_version = unresolved
                        .main
                        .name
                        .version
                        .as_ref()
                        .map(ToString::to_string)
                        .ok_or_else(|| ModuleError::InvalidWitPackage {
                            path: source_path.clone(),
                            message: "package must declare an explicit semantic version".to_owned(),
                        })?;
                    if actual_name != dependency.name || actual_version != dependency.version {
                        return Err(ModuleError::InvalidWitPackage {
                            path: source_path,
                            message: format!(
                                "expected {}@{}, found {actual_name}@{actual_version}",
                                dependency.name, dependency.version
                            ),
                        });
                    }
                    let transitive = unresolved
                        .main
                        .foreign_deps
                        .keys()
                        .map(dependency_from_package_name)
                        .collect::<Result<Vec<_>, _>>()?;
                    let destination = staging.join(&directory_name);
                    let sha256 = copy_wit_package(&source_path, &destination)?;
                    (
                        format!("path+{}", replacement.path.display()),
                        true,
                        sha256,
                        transitive,
                        wit_root.join("deps").join(directory_name),
                    )
                } else {
                    let registry_url = registry_url.as_deref().ok_or_else(|| {
                        ModuleError::InvalidManifest(format!(
                            "dependency {}@{} has no replace and [registry].url is not configured",
                            dependency.name, dependency.version
                        ))
                    })?;
                    let (metadata, bytes) =
                        fetch_registry_package(&client, registry_url, &dependency)?;
                    let file_name = format!("{directory_name}.wasm");
                    let destination = staging.join(&file_name);
                    fs::write(&destination, bytes).map_err(|source| ModuleError::Write {
                        path: destination,
                        source,
                    })?;
                    (
                        format!("registry+{registry_url}"),
                        false,
                        metadata.sha256,
                        metadata
                            .dependencies
                            .iter()
                            .map(dependency_from_metadata)
                            .collect::<Result<Vec<_>, _>>()?,
                        wit_root.join("deps").join(file_name),
                    )
                };

            for dependency in transitive {
                if !resolved.contains(&dependency) {
                    pending.insert(dependency);
                }
            }
            packages.push(ResolvedPackage {
                name: dependency.name,
                version: dependency.version,
                source,
                replaced,
                materialized_path,
                sha256,
            });
        }

        Ok(())
    })();

    if result.is_err() {
        let _ = fs::remove_dir_all(&staging);
    }
    result?;

    let lock_path = manifest_path.with_file_name(MODULE_LOCK_FILE);
    let expected_lock = ModuleLock {
        schema_version: LOCK_SCHEMA_VERSION,
        packages: packages
            .iter()
            .map(|package| LockedPackage {
                name: package.name.clone(),
                version: package.version.clone(),
                source: package.source.clone(),
                sha256: package.sha256.clone(),
                replaced: package.replaced,
            })
            .collect(),
    };

    // Check the complete graph before installing staging. A locked build must
    // not change either wit.lock or the currently materialized dependencies.
    if locked {
        let actual_lock = match ModuleLock::read(&lock_path) {
            Ok(lock) => lock,
            Err(error) => {
                remove_directory_if_present(&staging)?;
                return Err(error);
            }
        };
        if actual_lock != expected_lock {
            remove_directory_if_present(&staging)?;
            return Err(ModuleError::LockMismatch { path: lock_path });
        }
    }

    install_and_validate_dependencies(&wit_root, &staging)?;
    if !locked {
        expected_lock.write(&lock_path)?;
    }

    Ok(SyncReport {
        manifest_path,
        lock_path,
        wit_root,
        packages,
    })
}

/// Searches `start` and its ancestors for the nearest `wasmeld.toml`.
pub fn find_module_manifest(start: impl AsRef<Path>) -> Result<PathBuf, ModuleError> {
    let mut current = canonical_directory(start.as_ref())?;
    loop {
        let candidate = current.join(MODULE_MANIFEST_FILE);
        if candidate.is_file() {
            return Ok(candidate);
        }
        if !current.pop() {
            return Err(ModuleError::InvalidManifest(format!(
                "could not find {MODULE_MANIFEST_FILE} from {}",
                start.as_ref().display()
            )));
        }
    }
}

fn default_wit_root() -> PathBuf {
    PathBuf::from("wit")
}

fn select_replacement<'a>(
    manifest: &'a ModuleManifest,
    name: &str,
    version: &str,
) -> Option<&'a PathReplacement> {
    let exact = format!("{name}@{version}");
    manifest
        .replacements
        .get(&exact)
        .or_else(|| manifest.replacements.get(name))
}

fn dependency_from_package_name(
    package: &wit_parser::PackageName,
) -> Result<DependencyId, ModuleError> {
    let name = format!("{}:{}", package.namespace, package.name);
    let version = package
        .version
        .as_ref()
        .map(ToString::to_string)
        .ok_or_else(|| {
            ModuleError::InvalidManifest(format!(
                "transitive WIT dependency {name} must declare an exact version"
            ))
        })?;
    Ok(DependencyId { name, version })
}

fn dependency_from_metadata(dependency: &WitDependency) -> Result<DependencyId, ModuleError> {
    validate_package_name(&dependency.name)?;
    parse_version(&dependency.version)?;
    Ok(DependencyId {
        name: dependency.name.clone(),
        version: dependency.version.clone(),
    })
}

fn fetch_registry_package(
    client: &reqwest::blocking::Client,
    registry_url: &str,
    dependency: &DependencyId,
) -> Result<(crate::wit_package::WitPackageMetadata, Vec<u8>), ModuleError> {
    let (namespace, name) = dependency
        .name
        .split_once(':')
        .expect("validated package names always contain a namespace");
    let url = format!(
        "{registry_url}/api/v1/wit/packages/{namespace}/{name}/{}/content",
        dependency.version
    );
    let mut response = client
        .get(&url)
        .send()
        .map_err(|error| ModuleError::Registry {
            url: url.clone(),
            message: error.to_string(),
        })?;
    let status = response.status();
    if !status.is_success() {
        let message = response
            .text()
            .unwrap_or_else(|error| format!("failed to read error response: {error}"));
        return Err(ModuleError::Registry {
            url,
            message: format!("HTTP {status}: {message}"),
        });
    }
    if response
        .content_length()
        .is_some_and(|length| length > MAX_FETCHED_WIT_PACKAGE_BYTES as u64)
    {
        return Err(ModuleError::Registry {
            url,
            message: format!(
                "package exceeds the {MAX_FETCHED_WIT_PACKAGE_BYTES}-byte client limit"
            ),
        });
    }
    let mut bytes = Vec::new();
    response
        .by_ref()
        .take(MAX_FETCHED_WIT_PACKAGE_BYTES.saturating_add(1) as u64)
        .read_to_end(&mut bytes)
        .map_err(|error| ModuleError::Registry {
            url: url.clone(),
            message: error.to_string(),
        })?;
    if bytes.len() > MAX_FETCHED_WIT_PACKAGE_BYTES {
        return Err(ModuleError::Registry {
            url,
            message: format!(
                "package exceeds the {MAX_FETCHED_WIT_PACKAGE_BYTES}-byte client limit"
            ),
        });
    }
    let metadata = inspect_wit_package(&bytes).map_err(|error| ModuleError::Registry {
        url: url.clone(),
        message: error.to_string(),
    })?;
    if metadata.name != dependency.name || metadata.version != dependency.version {
        return Err(ModuleError::Registry {
            url,
            message: format!(
                "expected {}@{}, received {}@{}",
                dependency.name, dependency.version, metadata.name, metadata.version
            ),
        });
    }
    Ok((metadata, bytes))
}

fn parse_replacement_key(key: &str) -> Result<(&str, Option<&str>), ModuleError> {
    if let Some((name, version)) = key.rsplit_once('@') {
        validate_package_name(name)?;
        parse_version(version)?;
        Ok((name, Some(version)))
    } else {
        validate_package_name(key)?;
        Ok((key, None))
    }
}

fn validate_package_name(name: &str) -> Result<(), ModuleError> {
    let Some((namespace, package)) = name.split_once(':') else {
        return Err(ModuleError::InvalidManifest(format!(
            "WIT package {name:?} must use namespace:name"
        )));
    };
    if package.contains(':')
        || !valid_kebab_identifier(namespace)
        || !valid_kebab_identifier(package)
    {
        return Err(ModuleError::InvalidManifest(format!(
            "invalid WIT package name {name:?}"
        )));
    }
    Ok(())
}

fn valid_kebab_identifier(value: &str) -> bool {
    !value.is_empty()
        && value.len() <= 128
        && !value.starts_with('-')
        && !value.ends_with('-')
        && value
            .bytes()
            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}

fn parse_version(version: &str) -> Result<Version, ModuleError> {
    Version::parse(version).map_err(|error| {
        ModuleError::InvalidManifest(format!("invalid exact version {version:?}: {error}"))
    })
}

fn validate_registry_url(url: &str) -> Result<(), ModuleError> {
    normalize_registry_url(url).map(|_| ())
}

fn normalize_registry_url(url: &str) -> Result<String, ModuleError> {
    let normalized = url.trim_end_matches('/');
    let parsed = reqwest::Url::parse(normalized).map_err(|error| {
        ModuleError::InvalidManifest(format!("invalid [registry].url {url:?}: {error}"))
    })?;
    if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
        return Err(ModuleError::InvalidManifest(format!(
            "[registry].url must be an absolute HTTP(S) URL, found {url:?}"
        )));
    }
    Ok(normalized.to_owned())
}

fn validate_wit_root(path: &Path) -> Result<(), ModuleError> {
    if path.as_os_str().is_empty() || path.is_absolute() {
        return Err(ModuleError::InvalidManifest(
            "wit.root must be a non-empty relative directory inside the component".to_owned(),
        ));
    }
    if path.components().any(|component| {
        matches!(
            component,
            Component::ParentDir | Component::Prefix(_) | Component::RootDir
        )
    }) {
        return Err(ModuleError::InvalidManifest(
            "wit.root must stay inside the component directory".to_owned(),
        ));
    }
    Ok(())
}

fn validate_replacement_path(name: &str, path: &Path) -> Result<(), ModuleError> {
    if path.as_os_str().is_empty() {
        Err(ModuleError::InvalidManifest(format!(
            "{name} must be a non-empty directory"
        )))
    } else {
        Ok(())
    }
}

fn canonical_file(path: &Path) -> Result<PathBuf, ModuleError> {
    let canonical = fs::canonicalize(path).map_err(|source| ModuleError::Read {
        path: path.to_path_buf(),
        source,
    })?;
    if !canonical.is_file() {
        return Err(ModuleError::InvalidManifest(format!(
            "{} is not a file",
            canonical.display()
        )));
    }
    Ok(canonical)
}

fn canonical_directory(path: &Path) -> Result<PathBuf, ModuleError> {
    let canonical = fs::canonicalize(path).map_err(|source| ModuleError::Read {
        path: path.to_path_buf(),
        source,
    })?;
    if !canonical.is_dir() {
        return Err(ModuleError::InvalidManifest(format!(
            "{} is not a directory",
            canonical.display()
        )));
    }
    Ok(canonical)
}

fn materialized_directory_name(name: &str, version: &str) -> String {
    format!("{}-{version}", name.replace(':', "-"))
}

fn copy_wit_package(source: &Path, destination: &Path) -> Result<String, ModuleError> {
    create_directory(destination)?;
    let mut files = fs::read_dir(source)
        .map_err(|source_error| ModuleError::Read {
            path: source.to_path_buf(),
            source: source_error,
        })?
        .collect::<Result<Vec<_>, _>>()
        .map_err(|source_error| ModuleError::Read {
            path: source.to_path_buf(),
            source: source_error,
        })?;
    files.sort_by_key(|entry| entry.file_name());

    let mut digest = Sha256::new();
    let mut copied = 0_usize;
    for entry in files {
        let file_type = entry
            .file_type()
            .map_err(|source_error| ModuleError::Read {
                path: entry.path(),
                source: source_error,
            })?;
        let path = entry.path();
        if !file_type.is_file() || path.extension().and_then(|value| value.to_str()) != Some("wit")
        {
            continue;
        }
        let name = entry.file_name();
        let bytes = fs::read(&path).map_err(|source_error| ModuleError::Read {
            path: path.clone(),
            source: source_error,
        })?;
        let name_bytes = name.to_string_lossy();
        digest.update((name_bytes.len() as u64).to_le_bytes());
        digest.update(name_bytes.as_bytes());
        digest.update((bytes.len() as u64).to_le_bytes());
        digest.update(&bytes);
        fs::write(destination.join(&name), bytes).map_err(|source_error| ModuleError::Write {
            path: destination.join(&name),
            source: source_error,
        })?;
        copied += 1;
    }
    if copied == 0 {
        return Err(ModuleError::InvalidWitPackage {
            path: source.to_path_buf(),
            message: "package directory contains no top-level .wit files".to_owned(),
        });
    }
    Ok(format!("{:x}", digest.finalize()))
}

fn install_and_validate_dependencies(wit_root: &Path, staging: &Path) -> Result<(), ModuleError> {
    let dependencies = wit_root.join("deps");
    let backup = wit_root.join(format!(".deps.backup-{}", std::process::id()));
    remove_directory_if_present(&backup)?;

    let had_dependencies = dependencies.exists();
    if had_dependencies {
        fs::rename(&dependencies, &backup).map_err(|source| ModuleError::Write {
            path: dependencies.clone(),
            source,
        })?;
    }
    if let Err(source) = fs::rename(staging, &dependencies) {
        if had_dependencies {
            let _ = fs::rename(&backup, &dependencies);
        }
        return Err(ModuleError::Write {
            path: dependencies,
            source,
        });
    }

    let validation = Resolve::default()
        .push_path(wit_root)
        .map(|_| ())
        .map_err(|error| ModuleError::InvalidWitPackage {
            path: wit_root.to_path_buf(),
            message: error.to_string(),
        });
    if validation.is_err() {
        let _ = fs::remove_dir_all(&dependencies);
        if had_dependencies {
            let _ = fs::rename(&backup, &dependencies);
        }
        return validation;
    }
    if had_dependencies {
        remove_directory_if_present(&backup)?;
    }
    Ok(())
}

fn create_directory(path: &Path) -> Result<(), ModuleError> {
    fs::create_dir_all(path).map_err(|source| ModuleError::Write {
        path: path.to_path_buf(),
        source,
    })
}

fn remove_directory_if_present(path: &Path) -> Result<(), ModuleError> {
    match fs::remove_dir_all(path) {
        Ok(()) => Ok(()),
        Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()),
        Err(source) => Err(ModuleError::Write {
            path: path.to_path_buf(),
            source,
        }),
    }
}

#[cfg(test)]
mod tests {
    use std::fs;

    use tempfile::tempdir;

    use super::*;

    #[test]
    fn path_replace_materializes_dependencies_and_writes_lock() {
        let fixture = fixture();

        let report = sync_dependencies(&fixture.manifest, false).unwrap();

        assert_eq!(report.packages.len(), 1);
        assert!(
            fixture
                .root
                .join("wit/deps/wasmeld-clock-1.1.0/package.wit")
                .is_file()
        );
        let lock = ModuleLock::read(fixture.root.join(MODULE_LOCK_FILE)).unwrap();
        assert_eq!(lock.packages[0].name, "wasmeld:clock");
        assert_eq!(lock.packages[0].version, "1.1.0");
        assert_eq!(lock.packages[0].source, "path+../clock");
        assert!(lock.packages[0].replaced);
    }

    #[test]
    fn locked_sync_rejects_changed_local_source() {
        let fixture = fixture();
        sync_dependencies(&fixture.manifest, false).unwrap();
        fs::write(
            fixture.clock.join("package.wit"),
            "package wasmeld:clock@1.1.0;\ninterface monotonic-clock { ticks: func() -> u64; }\n",
        )
        .unwrap();

        let error = sync_dependencies(&fixture.manifest, true).unwrap_err();

        assert!(matches!(error, ModuleError::LockMismatch { .. }));
    }

    #[test]
    fn rejects_a_replacement_with_the_wrong_package_identity() {
        let fixture = fixture();
        fs::write(
            fixture.clock.join("package.wit"),
            "package wasmeld:other@1.1.0;\ninterface monotonic-clock { now: func() -> u64; }\n",
        )
        .unwrap();

        let error = sync_dependencies(&fixture.manifest, false).unwrap_err();

        assert!(
            error
                .to_string()
                .contains("expected wasmeld:clock@1.1.0, found wasmeld:other@1.1.0")
        );
    }

    #[test]
    fn exact_replace_takes_priority_over_package_replace() {
        let fixture = fixture();
        let alternate = fixture.root.parent().unwrap().join("alternate-clock");
        fs::create_dir(&alternate).unwrap();
        fs::write(
            alternate.join("package.wit"),
            "package wasmeld:clock@1.1.0;\ninterface monotonic-clock { ticks: func() -> u64; }\n",
        )
        .unwrap();
        let mut manifest = ModuleManifest::read(&fixture.manifest).unwrap();
        manifest
            .set_path_replacement("wasmeld:clock@1.1.0", PathBuf::from("../alternate-clock"))
            .unwrap();
        manifest.write(&fixture.manifest).unwrap();

        sync_dependencies(&fixture.manifest, false).unwrap();

        let materialized = fs::read_to_string(
            fixture
                .root
                .join("wit/deps/wasmeld-clock-1.1.0/package.wit"),
        )
        .unwrap();
        assert!(materialized.contains("ticks"));
    }

    #[test]
    fn set_and_drop_path_replacement_round_trip() {
        let fixture = fixture();
        let mut manifest = ModuleManifest::read(&fixture.manifest).unwrap();
        assert!(manifest.drop_replacement("wasmeld:clock"));
        manifest
            .set_path_replacement("wasmeld:clock@1.1.0", "../clock")
            .unwrap();
        manifest.write(&fixture.manifest).unwrap();

        let parsed = ModuleManifest::read(&fixture.manifest).unwrap();
        assert!(!parsed.replacements.contains_key("wasmeld:clock"));
        assert_eq!(
            parsed.replacements["wasmeld:clock@1.1.0"].path,
            PathBuf::from("../clock")
        );
    }

    struct Fixture {
        _temp: tempfile::TempDir,
        root: PathBuf,
        clock: PathBuf,
        manifest: PathBuf,
    }

    fn fixture() -> Fixture {
        let temp = tempdir().unwrap();
        let root = temp.path().join("component");
        let clock = temp.path().join("clock");
        fs::create_dir_all(root.join("wit")).unwrap();
        fs::create_dir(&clock).unwrap();
        fs::write(
            clock.join("package.wit"),
            "package wasmeld:clock@1.1.0;\ninterface monotonic-clock { now: func() -> u64; }\n",
        )
        .unwrap();
        fs::write(
            root.join("wit/world.wit"),
            "package example:component@0.1.0;\nworld component {\n  import wasmeld:clock/monotonic-clock@1.1.0;\n}\n",
        )
        .unwrap();
        let manifest = root.join(MODULE_MANIFEST_FILE);
        fs::write(
            &manifest,
            r#"
schema_version = 1

[dependencies]
"wasmeld:clock" = "1.1.0"

[replace."wasmeld:clock"]
path = "../clock"
"#,
        )
        .unwrap();
        Fixture {
            _temp: temp,
            root,
            clock,
            manifest,
        }
    }
}
