use itertools::Itertools;
use miette::{Diagnostic, NamedSource, SourceSpan};
use path_slash::PathBufExt;
use project_toml::{
    LocalProjectTomlValidationError, PartialProjectToml, RemoteProjectTomlValidationError,
};
use std::{
    io,
    ops::Deref,
    path::{Path, PathBuf},
    str::FromStr,
};
use thiserror::Error;
use toml_edit::{DocumentMut, Item};

use crate::fs;

use crate::{
    config::Config,
    git::{
        self,
        shorthand::RemoteGitUrlShorthand,
        url::RemoteGitUrl,
        utils::{GitError, SemVerTagOrSha},
    },
    lua_rockspec::{
        LocalLuaRockspec, LuaRockspecError, LuaVersionError, PartialLuaRockspec,
        PartialRockspecError, RemoteLuaRockspec,
    },
    lua_version::LuaVersion,
    package::SpecRev,
    remote_package_db::RemotePackageDB,
    rockspec::{
        lua_dependency::{DependencyType, LuaDependencySpec, LuaDependencyType},
        LuaVersionCompatibility,
    },
};
use crate::{
    lockfile::PinnedState,
    package::{PackageName, PackageReq},
};

pub(crate) mod gen;
pub mod project_toml;

pub use project_toml::PROJECT_TOML;

pub const EXTRA_ROCKSPEC: &str = "extra.rockspec";

/// A wrapper around [`toml::de::Error`] plus the original source code and the byte range where the
/// error occurred so that `miette` can render the failure with line numbers and context.
#[derive(Debug, Error, Diagnostic)]
#[error("{message}", message = _inner.message())]
#[diagnostic(
    code(lux_lib::toml::deserialize),
    help("check that the file is valid TOML and matches the expected schema"),
    url("https://lux.lumen-labs.org/reference/lux-toml")
)]
pub struct TomlDeError {
    #[source]
    _inner: Box<toml::de::Error>,
    #[source_code]
    src: NamedSource<String>,
    #[label("here")]
    span: SourceSpan,
}

impl TomlDeError {
    pub fn new(name: &str, src: &str, inner: toml::de::Error) -> Self {
        let src = NamedSource::new(name, src.to_string());
        let span = inner.span().unwrap_or(0..0).into();
        Self {
            _inner: Box::new(inner),
            src,
            span,
        }
    }

    pub fn inner(&self) -> &toml::de::Error {
        &self._inner
    }
}

pub(crate) fn parse_toml<T>(name: &str, src: &str) -> Result<T, TomlDeError>
where
    T: serde::de::DeserializeOwned,
{
    toml::from_str(src).map_err(|e| TomlDeError::new(name, src, e))
}

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
#[error(transparent)]
pub enum ProjectError {
    #[error(transparent)]
    #[diagnostic(transparent)]
    Fs(#[from] fs::FsError),
    #[diagnostic(transparent)]
    Project(#[from] LocalProjectTomlValidationError),
    #[diagnostic(transparent)]
    Toml(#[from] TomlDeError),
    #[error("error when parsing 'extra.rockspec'")]
    #[diagnostic(forward(0))]
    Rockspec(#[from] PartialRockspecError),
}

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
#[error(transparent)]
pub enum IntoLocalRockspecError {
    LocalProjectTomlValidationError(Box<LocalProjectTomlValidationError>),
    RockspecError(Box<LuaRockspecError>),
}

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
#[error(transparent)]
pub enum IntoRemoteRockspecError {
    RocksTomlValidationError(Box<RemoteProjectTomlValidationError>),
    RockspecError(Box<LuaRockspecError>),
}

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
pub enum ProjectEditError {
    #[error(transparent)]
    Io(#[from] tokio::io::Error),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Fs(#[from] fs::FsError),
    #[error(transparent)]
    Toml(#[from] toml_edit::TomlError),
    #[error("error parsing {PROJECT_TOML} after edit. This is probably a bug.")]
    #[diagnostic(forward(0))]
    TomlDe(#[from] TomlDeError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Git(#[from] GitError),
    #[error("unable to query latest version for '{0}'")]
    #[diagnostic(help("check your server config and ensure the package has been published"))]
    LatestVersionNotFound(PackageName),
    #[error("expected field to be a value, but got '{0}'")]
    #[diagnostic(
        help("ensure your {PROJECT_TOML} matches the specification"),
        url("https://lux.lumen-labs.org/reference/lux-toml")
    )]
    ExpectedValue(Box<toml_edit::Item>),
    #[error("expected string, but got '{0}'")]
    #[diagnostic(
        help("ensure your {PROJECT_TOML} matches the specification"),
        url("https://lux.lumen-labs.org/reference/lux-toml")
    )]
    ExpectedString(Box<toml_edit::Value>),
    #[error(transparent)]
    #[diagnostic(transparent)]
    GitUrlShorthandParse(#[from] git::shorthand::ParseError),
}

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
pub enum PinError {
    #[error("package '{0}' not found in dependencies")]
    #[diagnostic(help("to add it, run 'lx add {0}'"))]
    PackageNotFound(PackageName),
    #[error("dependency '{dep}' is already {}pinned!", if *.pin_state == PinnedState::Unpinned { "un" } else { "" })]
    PinStateUnchanged {
        pin_state: PinnedState,
        dep: PackageName,
    },
    #[error("unable to parse {toml_path} for editing")]
    #[diagnostic(
        code(lux_lib::toml_edit::parse),
        help("check that the file is valid TOML and matches the expected schema"),
        url("https://lux.lumen-labs.org/reference/lux-toml")
    )]
    ParseTomlEdit {
        toml_path: String,
        source: toml_edit::TomlError,
    },
    #[error("error parsing lux.toml after edit. This is probably a bug.")]
    #[diagnostic(forward(0))]
    TomlDe(#[from] TomlDeError),
    #[error(transparent)]
    Io(#[from] tokio::io::Error),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Fs(#[from] fs::FsError),
}

/// A newtype for the project root directory.
/// This is used to ensure that the project root is a valid project directory.
#[derive(Clone, Debug)]
#[cfg_attr(test, derive(Default))]
pub struct ProjectRoot(PathBuf);

impl ProjectRoot {
    pub(crate) fn new() -> Self {
        Self(PathBuf::new())
    }
}

impl AsRef<Path> for ProjectRoot {
    fn as_ref(&self) -> &Path {
        self.0.as_ref()
    }
}

impl Deref for ProjectRoot {
    type Target = PathBuf;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

/// Lux project, with methods for managing dependencies, etc.
#[derive(Clone, Debug)]
pub struct Project {
    /// The path where the `lux.toml` resides.
    root: ProjectRoot,
    /// The parsed lux.toml.
    toml: PartialProjectToml,
}

impl Project {
    /// Load a project at the specified path, if it exists
    #[tracing::instrument(level = "trace", skip_all)]
    pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, ProjectError> {
        if !start.as_ref().exists() {
            return Ok(None);
        }

        if start.as_ref().join(PROJECT_TOML).exists() {
            let project_toml_path = start.as_ref().join(PROJECT_TOML);
            let toml_content = fs::sync::read_to_string(&project_toml_path)?;
            let root = start.as_ref();

            let mut project = Project {
                root: ProjectRoot(root.to_path_buf()),
                toml: PartialProjectToml::new(
                    &project_toml_path.to_string_lossy(),
                    &toml_content,
                    ProjectRoot(root.to_path_buf()),
                )?,
            };

            if let Some(extra_rockspec) = project.extra_rockspec()? {
                project.toml = project.toml.merge(extra_rockspec);
            }

            Ok(Some(project))
        } else {
            Ok(None)
        }
    }

    /// Get the `lux.toml` path.
    pub fn toml_path(&self) -> PathBuf {
        self.root.join(PROJECT_TOML)
    }

    /// Get the `extra.rockspec` path.
    pub fn extra_rockspec_path(&self) -> PathBuf {
        self.root.join(EXTRA_ROCKSPEC)
    }

    pub fn root(&self) -> &ProjectRoot {
        &self.root
    }

    pub fn toml(&self) -> &PartialProjectToml {
        &self.toml
    }

    pub fn local_rockspec(&self) -> Result<LocalLuaRockspec, IntoLocalRockspecError> {
        self.toml()
            .into_local()
            .map_err(|err| IntoLocalRockspecError::LocalProjectTomlValidationError(Box::new(err)))?
            .to_lua_rockspec()
            .map_err(|err| IntoLocalRockspecError::RockspecError(Box::new(err)))
    }

    pub fn remote_rockspec(
        &self,
        specrev: Option<SpecRev>,
    ) -> Result<RemoteLuaRockspec, IntoRemoteRockspecError> {
        self.toml()
            .into_remote(specrev)
            .map_err(|err| IntoRemoteRockspecError::RocksTomlValidationError(Box::new(err)))?
            .to_lua_rockspec()
            .map_err(|err| IntoRemoteRockspecError::RockspecError(Box::new(err)))
    }

    pub fn extra_rockspec(&self) -> Result<Option<PartialLuaRockspec>, PartialRockspecError> {
        if self.extra_rockspec_path().exists() {
            Ok(Some(PartialLuaRockspec::new(
                &fs::sync::read_to_string(self.extra_rockspec_path()).map_err(io::Error::other)?,
            )?))
        } else {
            Ok(None)
        }
    }

    pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
        self.toml().lua_version_matches(config)
    }

    pub async fn add(
        &mut self,
        dependencies: DependencyType<&PackageReq>,
        package_db: &RemotePackageDB,
    ) -> Result<(), ProjectEditError> {
        let mut project_toml =
            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;

        prepare_dependency_tables(&mut project_toml);
        let table = match dependencies {
            DependencyType::Regular(_) => &mut project_toml["dependencies"],
            DependencyType::Build(_) => &mut project_toml["build_dependencies"],
            DependencyType::Test(_) => &mut project_toml["test_dependencies"],
            DependencyType::External(_) => &mut project_toml["external_dependencies"],
        };

        match dependencies {
            DependencyType::Regular(ref deps)
            | DependencyType::Build(ref deps)
            | DependencyType::Test(ref deps) => {
                for dep in deps {
                    let dep_version_str = if dep.version_req().is_any() {
                        package_db
                            .latest_version(dep.name())
                            .map(|latest_version| latest_version.to_string())
                            .unwrap_or_else(|| dep.version_req().to_string())
                    } else {
                        dep.version_req().to_string()
                    };
                    table[dep.name().to_string()] = toml_edit::value(dep_version_str);
                }
            }
            DependencyType::External(ref deps) => {
                for (name, dep) in deps {
                    if let Some(path) = &dep.header {
                        table[name]["header"] = toml_edit::value(path.to_slash_lossy().to_string());
                    }
                    if let Some(path) = &dep.library {
                        table[name]["library"] =
                            toml_edit::value(path.to_slash_lossy().to_string());
                    }
                }
            }
        };

        let toml_content = project_toml.to_string();
        fs::tokio::write(self.toml_path(), &toml_content).await?;
        self.toml = PartialProjectToml::new(
            self.toml_path().to_str().unwrap_or("<lux.toml>"),
            &toml_content,
            self.root.clone(),
        )?;

        Ok(())
    }

    pub async fn add_git(
        &mut self,
        dependencies: LuaDependencyType<&RemoteGitUrlShorthand>,
    ) -> Result<(), ProjectEditError> {
        let mut project_toml =
            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;

        prepare_dependency_tables(&mut project_toml);
        let table = match dependencies {
            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
        };

        match dependencies {
            LuaDependencyType::Regular(urls)
            | LuaDependencyType::Build(urls)
            | LuaDependencyType::Test(urls) => {
                for url in urls {
                    let git_url: RemoteGitUrl = url.clone().into();
                    let mut dep_entry = toml_edit::table();
                    match git::utils::latest_semver_tag_or_commit_sha(&git_url)? {
                        SemVerTagOrSha::SemVerTag(tag) => {
                            dep_entry["git"] = Item::Value(url.to_string().into());
                            dep_entry["version"] = Item::Value(tag.clone().into());
                            if tag.contains("-") {
                                // Tag contains a specrev.
                                dep_entry["rev"] = Item::Value(tag.into());
                            }
                        }
                        SemVerTagOrSha::CommitSha(sha) => {
                            dep_entry["git"] = Item::Value(url.to_string().into());
                            dep_entry["version"] = Item::Value(sha.into());
                        }
                    }
                    table[git_url.repo()] = dep_entry;
                }
            }
        }

        let toml_content = project_toml.to_string();
        fs::tokio::write(self.toml_path(), &toml_content).await?;
        self.toml = PartialProjectToml::new(
            self.toml_path().to_str().unwrap_or("<lux.toml>"),
            &toml_content,
            self.root.clone(),
        )?;

        Ok(())
    }

    pub async fn remove(
        &mut self,
        dependencies: DependencyType<&PackageName>,
    ) -> Result<(), ProjectEditError> {
        let mut project_toml =
            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;

        prepare_dependency_tables(&mut project_toml);
        let table = match dependencies {
            DependencyType::Regular(_) => &mut project_toml["dependencies"],
            DependencyType::Build(_) => &mut project_toml["build_dependencies"],
            DependencyType::Test(_) => &mut project_toml["test_dependencies"],
            DependencyType::External(_) => &mut project_toml["external_dependencies"],
        };

        match dependencies {
            DependencyType::Regular(ref deps)
            | DependencyType::Build(ref deps)
            | DependencyType::Test(ref deps) => {
                for dep in deps {
                    table[dep.to_string()] = Item::None;
                }
            }
            DependencyType::External(ref deps) => {
                for (name, dep) in deps {
                    if dep.header.is_some() {
                        table[name]["header"] = Item::None;
                    }
                    if dep.library.is_some() {
                        table[name]["library"] = Item::None;
                    }
                }
            }
        };

        let toml_content = project_toml.to_string();
        fs::tokio::write(self.toml_path(), &toml_content).await?;
        self.toml = PartialProjectToml::new(
            self.toml_path().to_str().unwrap_or("<lux.toml>"),
            &toml_content,
            self.root.clone(),
        )?;

        Ok(())
    }

    pub async fn upgrade(
        &mut self,
        dependencies: LuaDependencyType<&PackageName>,
        package_db: &RemotePackageDB,
    ) -> Result<(), ProjectEditError> {
        let mut project_toml =
            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;

        prepare_dependency_tables(&mut project_toml);
        let table = match dependencies {
            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
        };

        match dependencies {
            LuaDependencyType::Regular(deps)
            | LuaDependencyType::Build(deps)
            | LuaDependencyType::Test(deps) => {
                let latest_rock_version_str =
                    |dep: &PackageName| -> Result<String, ProjectEditError> {
                        Ok(package_db
                            .latest_version(dep)
                            .ok_or(ProjectEditError::LatestVersionNotFound(dep.clone()))?
                            .to_string())
                    };
                for dep in deps {
                    let mut dep_item = table[dep.to_string()].clone();
                    match &dep_item {
                        Item::Value(_) => {
                            let dep_version_str = latest_rock_version_str(dep)?;
                            table[dep.to_string()] = toml_edit::value(dep_version_str);
                        }
                        Item::Table(tbl) => {
                            match tbl.get("git") {
                                Some(git_item) => {
                                    let git_value =
                                        git_item.clone().into_value().map_err(|err| {
                                            ProjectEditError::ExpectedValue(Box::new(err))
                                        })?;
                                    let git_url_str = git_value.as_str().ok_or(
                                        ProjectEditError::ExpectedString(Box::new(
                                            git_value.clone(),
                                        )),
                                    )?;
                                    let shorthand: RemoteGitUrlShorthand = git_url_str.parse()?;
                                    match git::utils::latest_semver_tag_or_commit_sha(
                                        &shorthand.into(),
                                    )? {
                                        SemVerTagOrSha::SemVerTag(latest_tag) => {
                                            table[dep.to_string()]["version"] =
                                                Item::Value(latest_tag.clone().into());
                                            if latest_tag.contains("-") {
                                                // Tag contains a specrev.
                                                table[dep.to_string()]["rev"] =
                                                    Item::Value(latest_tag.into());
                                            }
                                        }
                                        SemVerTagOrSha::CommitSha(latest_sha) => {
                                            table[dep.to_string()]["version"] =
                                                Item::Value(latest_sha.into());
                                        }
                                    }
                                    table[dep.to_string()] = dep_item;
                                }
                                None => {
                                    let dep_version_str = latest_rock_version_str(dep)?;
                                    dep_item["version".to_string()] =
                                        toml_edit::value(dep_version_str);
                                    table[dep.to_string()] = dep_item;
                                }
                            }
                        }
                        _ => {}
                    }
                }
            }
        }

        let toml_content = project_toml.to_string();
        fs::tokio::write(self.toml_path(), &toml_content).await?;
        self.toml = PartialProjectToml::new(
            self.toml_path().to_str().unwrap_or("<lux.toml>"),
            &toml_content,
            self.root.clone(),
        )?;

        Ok(())
    }

    pub async fn upgrade_all(
        &mut self,
        package_db: &RemotePackageDB,
    ) -> Result<(), ProjectEditError> {
        if let Some(dependencies) = &self.toml().dependencies {
            let packages = dependencies
                .iter()
                .map(|dep| dep.name())
                .cloned()
                .collect_vec();
            self.upgrade(
                LuaDependencyType::Regular(packages.iter().collect()),
                package_db,
            )
            .await?;
        }
        if let Some(dependencies) = &self.toml().build_dependencies {
            let packages = dependencies
                .iter()
                .map(|dep| dep.name())
                .cloned()
                .collect_vec();
            self.upgrade(
                LuaDependencyType::Build(packages.iter().collect()),
                package_db,
            )
            .await?;
        }
        if let Some(dependencies) = &self.toml().test_dependencies {
            let packages = dependencies
                .iter()
                .map(|dep| dep.name())
                .cloned()
                .collect_vec();
            self.upgrade(
                LuaDependencyType::Test(packages.iter().collect()),
                package_db,
            )
            .await?;
        }
        Ok(())
    }

    pub async fn set_pinned_state(
        &mut self,
        dependencies: LuaDependencyType<&PackageName>,
        pin: PinnedState,
    ) -> Result<(), PinError> {
        let mut project_toml =
            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)
                .map_err(|source| PinError::ParseTomlEdit {
                toml_path: self.toml_path().to_slash_lossy().to_string(),
                source,
            })?;

        prepare_dependency_tables(&mut project_toml);
        let table = match dependencies {
            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
        };

        match dependencies {
            LuaDependencyType::Regular(ref _deps) => {
                self.toml.dependencies = Some(
                    self.toml
                        .dependencies
                        .take()
                        .unwrap_or_default()
                        .into_iter()
                        .map(|dep| LuaDependencySpec { pin, ..dep })
                        .collect(),
                )
            }
            LuaDependencyType::Build(ref _deps) => {
                self.toml.build_dependencies = Some(
                    self.toml
                        .build_dependencies
                        .take()
                        .unwrap_or_default()
                        .into_iter()
                        .map(|dep| LuaDependencySpec { pin, ..dep })
                        .collect(),
                )
            }
            LuaDependencyType::Test(ref _deps) => {
                self.toml.test_dependencies = Some(
                    self.toml
                        .test_dependencies
                        .take()
                        .unwrap_or_default()
                        .into_iter()
                        .map(|dep| LuaDependencySpec { pin, ..dep })
                        .collect(),
                )
            }
        }

        match dependencies {
            LuaDependencyType::Regular(ref deps)
            | LuaDependencyType::Build(ref deps)
            | LuaDependencyType::Test(ref deps) => {
                for dep in deps {
                    let mut dep_item = table[dep.to_string()].clone();
                    match dep_item {
                        version @ Item::Value(_) => match &pin {
                            PinnedState::Unpinned => {}
                            PinnedState::Pinned => {
                                if let Ok(mut dep_entry) = toml_edit::table().into_table() {
                                    dep_entry.set_implicit(true);
                                    dep_entry["version"] = version;
                                    dep_entry["pin"] = toml_edit::value(true);
                                    table[dep.to_string()] = toml_edit::Item::Table(dep_entry);
                                }
                            }
                        },
                        Item::Table(_) => {
                            dep_item["pin".to_string()] = toml_edit::value(pin.as_bool());
                            table[dep.to_string()] = dep_item;
                        }
                        _ => {}
                    }
                }
            }
        }

        let toml_content = project_toml.to_string();
        fs::tokio::write(self.toml_path(), &toml_content).await?;
        self.toml = PartialProjectToml::new(
            self.toml_path().to_str().unwrap_or("<lux.toml>"),
            &toml_content,
            self.root.clone(),
        )?;

        Ok(())
    }

    pub fn project_files(&self) -> Vec<PathBuf> {
        project_files(&self.root().0)
    }
}

/// Get the files that Lux treats as project files
/// This respects ignore files and excludes hidden files and directories.
pub(crate) fn project_files(src: &Path) -> Vec<PathBuf> {
    ignore::WalkBuilder::new(src)
        .add(src.join(".cargo"))
        .follow_links(false)
        .build()
        .filter_map(Result::ok)
        .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file()))
        .map(|entry| entry.into_path())
        .collect_vec()
}

fn prepare_dependency_tables(project_toml: &mut DocumentMut) {
    if !project_toml.contains_table("dependencies") {
        if let Ok(mut table) = toml_edit::table().into_table() {
            table.set_implicit(true);
            project_toml["dependencies"] = toml_edit::Item::Table(table);
        }
    }
    if !project_toml.contains_table("build_dependencies") {
        if let Ok(mut table) = toml_edit::table().into_table() {
            table.set_implicit(true);
            project_toml["build_dependencies"] = toml_edit::Item::Table(table);
        }
    }
    if !project_toml.contains_table("test_dependencies") {
        if let Ok(mut table) = toml_edit::table().into_table() {
            table.set_implicit(true);
            project_toml["test_dependencies"] = toml_edit::Item::Table(table);
        }
    }
    if !project_toml.contains_table("external_dependencies") {
        if let Ok(mut table) = toml_edit::table().into_table() {
            table.set_implicit(true);
            project_toml["external_dependencies"] = toml_edit::Item::Table(table);
        }
    }
}

// TODO: More project-based test
#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use assert_fs::prelude::{PathChild, PathCopy, PathCreateDir};
    use url::Url;

    use super::*;
    use crate::{
        fs,
        lua_rockspec::ExternalDependencySpec,
        manifest::{Manifest, ManifestMetadata},
        package::PackageReq,
        rockspec::Rockspec,
    };

    #[tokio::test]
    async fn test_add_various_dependencies() {
        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("resources/test/sample-projects/no-build-spec/");
        let project_root = assert_fs::TempDir::new().unwrap();
        project_root.copy_from(&sample_project, &["**"]).unwrap();
        let project_root: PathBuf = project_root.path().into();
        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
        let add_dependencies = [PackageReq::new("busted".into(), Some(">= 1.0.0".into())).unwrap()];
        let expected_dependencies = vec![PackageReq::new("busted".into(), Some(">= 1.0.0".into()))
            .unwrap()
            .into()];

        let test_manifest_path =
            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
        let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
        let metadata = ManifestMetadata::new(&content).unwrap();
        let package_db = Manifest::new(
            Url::parse("https://example.com").unwrap(),
            metadata,
            Default::default(),
        )
        .into();

        project
            .add(
                DependencyType::Regular(add_dependencies.iter().collect_vec()),
                &package_db,
            )
            .await
            .unwrap();

        project
            .add(
                DependencyType::Build(add_dependencies.iter().collect_vec()),
                &package_db,
            )
            .await
            .unwrap();
        project
            .add(
                DependencyType::Test(add_dependencies.iter().collect_vec()),
                &package_db,
            )
            .await
            .unwrap();

        project
            .add(
                DependencyType::External(HashMap::from([(
                    "lib".into(),
                    ExternalDependencySpec {
                        library: Some("path.so".into()),
                        header: None,
                    },
                )])),
                &package_db,
            )
            .await
            .unwrap();

        // Reparse the lux.toml (not usually necessary, but we want to test that the file was
        // written correctly)
        let project = Project::from_exact(&project_root).unwrap().unwrap();
        let validated_toml = project.toml().into_remote(None).unwrap();

        assert_eq!(
            validated_toml.dependencies().current_platform(),
            &expected_dependencies
        );
        assert_eq!(
            validated_toml.build_dependencies().current_platform(),
            &expected_dependencies
        );
        assert_eq!(
            validated_toml.test_dependencies().current_platform(),
            &expected_dependencies
        );
        assert_eq!(
            validated_toml
                .external_dependencies()
                .current_platform()
                .get("lib")
                .unwrap(),
            &ExternalDependencySpec {
                library: Some("path.so".into()),
                header: None
            }
        );
    }

    #[tokio::test]
    async fn test_remove_dependencies() {
        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("resources/test/sample-projects/dependencies/");
        let project_root = assert_fs::TempDir::new().unwrap();
        project_root.copy_from(&sample_project, &["**"]).unwrap();
        let project_root: PathBuf = project_root.path().into();
        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
        let lua_cjson = "lua-cjson".into();
        let plenary_nvim = "plenary.nvim".into();
        let remove_dependencies = vec![&lua_cjson, &plenary_nvim];
        project
            .remove(DependencyType::Regular(remove_dependencies.clone()))
            .await
            .unwrap();
        let check = |project: &Project| {
            for name in &remove_dependencies {
                assert!(!project
                    .toml()
                    .dependencies
                    .clone()
                    .unwrap_or_default()
                    .iter()
                    .any(|dep| &dep.name() == name));
            }
        };
        check(&project);
        // check again after reloading lux.toml
        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
        check(&reloaded_project);
    }

    #[tokio::test]
    async fn test_extra_rockspec_parsing() {
        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("resources/test/sample-projects/extra-rockspec/");
        let project_root = assert_fs::TempDir::new().unwrap();
        project_root.copy_from(&sample_project, &["**"]).unwrap();
        let project_root: PathBuf = project_root.path().into();
        let project = Project::from_exact(project_root).unwrap().unwrap();

        let extra_rockspec = project.extra_rockspec().unwrap();

        assert!(extra_rockspec.is_some());

        let rocks = project.toml().into_remote(None).unwrap();

        assert_eq!(rocks.package().to_string(), "custom-package");
    }

    #[tokio::test]
    async fn test_pin_dependencies() {
        test_pin_unpin_dependencies(PinnedState::Pinned).await
    }

    #[tokio::test]
    async fn test_unpin_dependencies() {
        test_pin_unpin_dependencies(PinnedState::Unpinned).await
    }

    async fn test_pin_unpin_dependencies(pin: PinnedState) {
        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("resources/test/sample-projects/dependencies/");
        let project_root = assert_fs::TempDir::new().unwrap();
        project_root.copy_from(&sample_project, &["**"]).unwrap();
        let project_root: PathBuf = project_root.path().into();
        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
        let lua_cjson = "lua-cjson".into();
        let plenary_nvim = "plenary.nvim".into();
        let pin_dependencies = vec![&lua_cjson, &plenary_nvim];
        project
            .set_pinned_state(LuaDependencyType::Regular(pin_dependencies.clone()), pin)
            .await
            .unwrap();
        let check = |project: &Project| {
            for name in &pin_dependencies {
                assert!(project
                    .toml()
                    .dependencies
                    .clone()
                    .unwrap_or_default()
                    .iter()
                    .any(|dep| &dep.name() == name && dep.pin == pin));
            }
        };
        check(&project);
        // check again after reloading lux.toml
        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
        check(&reloaded_project);
    }

    #[tokio::test]
    async fn project_files_includes_cargo_directory() {
        let project_root = assert_fs::TempDir::new().unwrap();
        let cargo_dir = project_root.child(".cargo");
        cargo_dir.create_dir_all().unwrap();
        let cargo_config = cargo_dir.join("config.toml");
        fs::tokio::write(&cargo_config, "").await.unwrap();
        let project_files = project_files(&project_root);
        assert!(project_files.contains(&cargo_config.to_path_buf()));
    }

    #[tokio::test]
    async fn from_exact_on_nonexistent_directory() {
        let empty_dir = assert_fs::TempDir::new().unwrap();
        assert!(Project::from_exact(&empty_dir).unwrap().is_none());
    }
}
