use indexmap::IndexMap;
use itertools::Itertools;
use serde::{Deserialize, Deserializer};
use std::borrow::Cow;
use std::collections::{BTreeMap, Bound};
use std::ffi::OsStr;
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::str::{self, FromStr};
use tracing::{debug, trace, warn};
use uv_warnings::warn_user_once;
use version_ranges::Ranges;
use walkdir::WalkDir;

use uv_fs::Simplified;
use uv_globfilter::{GlobDirFilter, PortableGlobParser};
use uv_normalize::{ExtraName, PackageName};
use uv_pep440::{Version, VersionSpecifiers};
use uv_pep508::{
    ExtraOperator, MarkerExpression, MarkerTree, MarkerValueExtra, Requirement, VersionOrUrl,
};
use uv_pypi_types::{Keywords, Metadata23, ProjectUrls, VerbatimParsedUrl};

use crate::serde_verbatim::SerdeVerbatim;
use crate::{BuildBackendSettings, Error, error_on_venv};

/// By default, we ignore generated python files.
pub(crate) const DEFAULT_EXCLUDES: &[&str] = &["__pycache__", "*.pyc", "*.pyo"];

/// No breaking changes were introduced to the uv build backend since these releases, so we can use
/// the fast path for them too.
const COMPATIBLE_VERSIONS: &[&str] = &["0.9.30", "0.10.12"];

#[derive(Debug, Error)]
pub enum ValidationError {
    /// The spec isn't clear about what the values in that field would be, and we only support the
    /// default value (UTF-8).
    #[error(
        "Charsets other than UTF-8 are not supported. Please convert your README to UTF-8 and remove `project.readme.charset`."
    )]
    ReadmeCharset,
    #[error(
        "Unknown Readme extension `{0}`, can't determine content type. Please use a support extension (`.md`, `.rst`, `.txt`) or set the content type manually."
    )]
    UnknownExtension(String),
    #[error("Can't infer content type because `{}` does not have an extension. Please use a support extension (`.md`, `.rst`, `.txt`) or set the content type manually.", _0.user_display())]
    MissingExtension(PathBuf),
    #[error("Unsupported content type: {0}")]
    UnsupportedContentType(String),
    #[error("`project.description` must be a single line")]
    DescriptionNewlines,
    #[error("Dynamic metadata is not supported")]
    Dynamic,
    #[error(
        "When `project.license-files` is defined, `project.license` must be an SPDX expression string"
    )]
    MixedLicenseGenerations,
    #[error(
        "Entrypoint groups must consist of letters and numbers separated by dots, invalid group: {0}"
    )]
    InvalidGroup(String),
    #[error(
        "Script entry point name `{0}` must include a non-dot character and consist only of letters, numbers, dots, underscores and dashes"
    )]
    InvalidScriptName(String),
    #[error("Use `project.scripts` instead of `project.entry-points.console_scripts`")]
    ReservedScripts,
    #[error("Use `project.gui-scripts` instead of `project.entry-points.gui_scripts`")]
    ReservedGuiScripts,
    #[error("`project.license` is not a valid SPDX expression: {0}")]
    InvalidSpdx(String, #[source] spdx::error::ParseError),
    #[error("`{field}` glob `{glob}` did not match any files")]
    LicenseGlobNoMatches { field: String, glob: String },
    #[error("License file `{}` must be UTF-8 encoded", _0)]
    LicenseFileNotUtf8(String),
    #[error("`project.classifiers` contains an invalid classifier: {0}")]
    InvalidClassifiers(String),
}

/// The project is not compatible with a direct uv build.
///
/// Displays a half-sentence with a reason why it isn't, format into a full sentence for using it
/// as an error.
#[derive(Debug, Error)]
pub enum DirectBuildIncompatibility {
    #[error("its `pyproject.toml` failed to parse: {0}")]
    PyprojectToml(String),
    #[error("`build_system.build-backend` is not `uv_build`, but `{0}`")]
    WrongBackend(String),
    #[error("`build-system.requires` is not exactly `uv_build`, but `{0}`")]
    MultipleRequires(String),
    #[error("`build-system.requires` is not `uv_build`, but `{0}`")]
    WrongPackage(PackageName),
    #[error("`build_system.requires` uses a URL requirement")]
    UrlRequirement,
    #[error("`uv_build{0}` is not a known compatible range")]
    IncompatibleRange(VersionSpecifiers),
}

/// Check if the build backend is matching the currently running uv version.
///
/// Example table compatible with uv 0.4.21:
///
/// ```toml
/// [build-system]
/// requires = ["uv_build>=0.4.15,<0.5.0"]
/// build-backend = "uv_build"
/// ```
pub fn check_direct_build(
    source_tree: &Path,
    uv_version: &str,
) -> Result<(), DirectBuildIncompatibility> {
    #[derive(Deserialize)]
    #[serde(rename_all = "kebab-case")]
    struct PyProjectToml {
        build_system: BuildSystem,
    }

    let path = source_tree.join("pyproject.toml");
    let pyproject_toml: PyProjectToml = match fs_err::read_to_string(&path)
        .map_err(|err| err.to_string())
        .and_then(|pyproject_toml| {
            tracing::info_span!("toml::from_str check direct build", path = %path.display())
                .in_scope(|| toml::from_str(&pyproject_toml))
                .map_err(|err| err.to_string())
        }) {
        Ok(pyproject_toml) => pyproject_toml,
        Err(err) => {
            return Err(DirectBuildIncompatibility::PyprojectToml(err));
        }
    };

    if pyproject_toml.build_system.build_backend.as_deref() != Some("uv_build") {
        return Err(DirectBuildIncompatibility::WrongBackend(
            pyproject_toml
                .build_system
                .build_backend
                .clone()
                .unwrap_or_default(),
        ));
    }

    let compatible: Vec<Version> = COMPATIBLE_VERSIONS
        .iter()
        .chain([&uv_version])
        .map(|version| {
            Version::from_str(version).expect("hardcoded version is not PEP 440 compliant")
        })
        .collect();

    let [uv_requirement] = &pyproject_toml.build_system.requires.as_slice() else {
        return Err(DirectBuildIncompatibility::MultipleRequires(
            pyproject_toml
                .build_system
                .requires
                .iter()
                .map(ToString::to_string)
                .join("`, `"),
        ));
    };
    if uv_requirement.name.as_str() != "uv-build" {
        return Err(DirectBuildIncompatibility::WrongPackage(
            uv_requirement.name.clone(),
        ));
    }
    match &uv_requirement.version_or_url {
        None => {
            // If the user doesn't set any upper bound, we don't help them by not using the fast
            // path, their build may equally fail if the index version of `uv_build`.
        }
        Some(VersionOrUrl::Url(_)) => {
            // We can't validate the url.
            return Err(DirectBuildIncompatibility::UrlRequirement);
        }
        Some(VersionOrUrl::VersionSpecifier(specifier))
            // If the user doesn't set an upper bound, we don't help them by not using the fast
            // path, their build may equally fail if the index version of `uv_build`, so we allow
            // missing upper bounds.
            if !compatible.iter().any(|version| specifier.contains(version)) =>
        {
            return Err(DirectBuildIncompatibility::IncompatibleRange(
                specifier.clone(),
            ));
        }
        Some(VersionOrUrl::VersionSpecifier(_)) => {}
    }

    Ok(())
}

/// A package name as provided in a `pyproject.toml`.
#[derive(Debug, Clone)]
struct VerbatimPackageName {
    /// The package name as given in the `pyproject.toml`.
    given: String,
    /// The normalized package name.
    normalized: PackageName,
}

impl<'de> Deserialize<'de> for VerbatimPackageName {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let given = <Cow<'_, str>>::deserialize(deserializer)?;
        let normalized = PackageName::from_str(&given).map_err(serde::de::Error::custom)?;
        Ok(Self {
            given: given.to_string(),
            normalized,
        })
    }
}

/// A `pyproject.toml` as specified in PEP 517.
#[derive(Deserialize, Debug, Clone)]
#[serde(
    rename_all = "kebab-case",
    expecting = "The project table needs to follow \
    https://packaging.python.org/en/latest/guides/writing-pyproject-toml"
)]
pub struct PyProjectToml {
    /// Project metadata
    project: Project,
    /// uv-specific configuration
    tool: Option<Tool>,
    /// Build-related data
    build_system: BuildSystem,
}

impl PyProjectToml {
    pub(crate) fn name(&self) -> &PackageName {
        &self.project.name.normalized
    }

    pub(crate) fn version(&self) -> &Version {
        &self.project.version
    }

    pub(crate) fn parse(path: &Path) -> Result<Self, Error> {
        let contents = fs_err::read_to_string(path)?;
        let pyproject_toml =
            tracing::info_span!("toml::from_str uv build backend", path = %path.display())
                .in_scope(|| toml::from_str(&contents))
                .map_err(|err| Error::Toml(path.to_path_buf(), err))?;
        Ok(pyproject_toml)
    }

    pub(crate) fn readme(&self) -> Option<&Readme> {
        self.project.readme.as_ref()
    }

    /// The license files that need to be included in the source distribution.
    pub(crate) fn license_files_source_dist(&self) -> impl Iterator<Item = &str> {
        let license_file = self
            .project
            .license
            .as_ref()
            .and_then(|license| license.file())
            .into_iter();
        let license_files = self
            .project
            .license_files
            .iter()
            .flatten()
            .map(String::as_str);
        license_files.chain(license_file)
    }

    /// The license files that need to be included in the wheel.
    pub(crate) fn license_files_wheel(&self) -> impl Iterator<Item = &str> {
        // The pre-PEP 639 `license = { file = "..." }` is included inline in `METADATA`.
        self.project
            .license_files
            .iter()
            .flatten()
            .map(String::as_str)
    }

    pub(crate) fn settings(&self) -> Option<&BuildBackendSettings> {
        self.tool.as_ref()?.uv.as_ref()?.build_backend.as_ref()
    }

    /// See [`BuildSystem::check_build_system`].
    pub(crate) fn check_build_system(&self, uv_version: &str) -> Vec<String> {
        self.build_system.check_build_system(uv_version)
    }

    /// Validate and convert a `pyproject.toml` to core metadata.
    ///
    /// <https://packaging.python.org/en/latest/guides/writing-pyproject-toml/>
    /// <https://packaging.python.org/en/latest/specifications/pyproject-toml/>
    /// <https://packaging.python.org/en/latest/specifications/core-metadata/>
    pub(crate) fn to_metadata(&self, root: &Path) -> Result<Metadata23, Error> {
        let summary = if let Some(description) = &self.project.description {
            if description.contains('\n') {
                return Err(ValidationError::DescriptionNewlines.into());
            }
            Some(description.clone())
        } else {
            None
        };

        let supported_content_types = ["text/plain", "text/x-rst", "text/markdown"];
        let (description, description_content_type) = match &self.project.readme {
            Some(Readme::String(path)) => {
                let content = fs_err::read_to_string(root.join(path))?;
                let content_type = match path.extension().and_then(OsStr::to_str) {
                    Some("txt") => "text/plain",
                    Some("rst") => "text/x-rst",
                    Some("md") => "text/markdown",
                    Some(unknown) => {
                        return Err(ValidationError::UnknownExtension(unknown.to_owned()).into());
                    }
                    None => return Err(ValidationError::MissingExtension(path.clone()).into()),
                }
                .to_string();
                (Some(content), Some(content_type))
            }
            Some(Readme::File {
                file,
                content_type,
                charset,
            }) => {
                let content = fs_err::read_to_string(root.join(file))?;
                if !supported_content_types.contains(&content_type.as_str()) {
                    return Err(
                        ValidationError::UnsupportedContentType(content_type.clone()).into(),
                    );
                }
                if charset.as_ref().is_some_and(|charset| charset != "UTF-8") {
                    return Err(ValidationError::ReadmeCharset.into());
                }
                (Some(content), Some(content_type.clone()))
            }
            Some(Readme::Text {
                text,
                content_type,
                charset,
            }) => {
                if !supported_content_types.contains(&content_type.as_str()) {
                    return Err(
                        ValidationError::UnsupportedContentType(content_type.clone()).into(),
                    );
                }
                if charset.as_ref().is_some_and(|charset| charset != "UTF-8") {
                    return Err(ValidationError::ReadmeCharset.into());
                }
                (Some(text.clone()), Some(content_type.clone()))
            }
            None => (None, None),
        };

        if self
            .project
            .dynamic
            .as_ref()
            .is_some_and(|dynamic| !dynamic.is_empty())
        {
            return Err(ValidationError::Dynamic.into());
        }

        let author = self
            .project
            .authors
            .as_ref()
            .map(|authors| {
                authors
                    .iter()
                    .filter_map(|author| match author {
                        Contact::Name { name } => Some(name),
                        Contact::Email { .. } => None,
                        Contact::NameEmail { name, .. } => Some(name),
                    })
                    .join(", ")
            })
            .filter(|author| !author.is_empty());
        let author_email = self
            .project
            .authors
            .as_ref()
            .map(|authors| {
                authors
                    .iter()
                    .filter_map(|author| match author {
                        Contact::Name { .. } => None,
                        Contact::Email { email } => Some(email.clone()),
                        Contact::NameEmail { name, email } => Some(format!("{name} <{email}>")),
                    })
                    .join(", ")
            })
            .filter(|author_email| !author_email.is_empty());
        let maintainer = self
            .project
            .maintainers
            .as_ref()
            .map(|maintainers| {
                maintainers
                    .iter()
                    .filter_map(|maintainer| match maintainer {
                        Contact::Name { name } => Some(name),
                        Contact::Email { .. } => None,
                        Contact::NameEmail { name, .. } => Some(name),
                    })
                    .join(", ")
            })
            .filter(|maintainer| !maintainer.is_empty());
        let maintainer_email = self
            .project
            .maintainers
            .as_ref()
            .map(|maintainers| {
                maintainers
                    .iter()
                    .filter_map(|maintainer| match maintainer {
                        Contact::Name { .. } => None,
                        Contact::Email { email } => Some(email.clone()),
                        Contact::NameEmail { name, email } => Some(format!("{name} <{email}>")),
                    })
                    .join(", ")
            })
            .filter(|maintainer_email| !maintainer_email.is_empty());

        // Using PEP 639 bumps the METADATA version
        let metadata_version = if self.project.license_files.is_some()
            || matches!(self.project.license, Some(License::Spdx(_)))
        {
            debug!("Found PEP 639 license declarations, using METADATA 2.4");
            "2.4"
        } else {
            "2.3"
        };

        let (license, license_expression, license_files) = self.license_metadata(root)?;

        // TODO(konsti): https://peps.python.org/pep-0753/#label-normalization (Draft)
        let project_urls = ProjectUrls::new(self.project.urls.clone().unwrap_or_default());

        let extras = self
            .project
            .optional_dependencies
            .iter()
            .flat_map(|optional_dependencies| optional_dependencies.keys())
            .collect::<Vec<_>>();

        let requires_dist =
            self.project
                .dependencies
                .iter()
                .flatten()
                .cloned()
                .chain(self.project.optional_dependencies.iter().flat_map(
                    |optional_dependencies| {
                        optional_dependencies
                            .iter()
                            .flat_map(|(extra, requirements)| {
                                requirements.iter().cloned().map(|mut requirement| {
                                    requirement.marker.and(MarkerTree::expression(
                                        MarkerExpression::Extra {
                                            operator: ExtraOperator::Equal,
                                            name: MarkerValueExtra::Extra(extra.clone()),
                                        },
                                    ));
                                    requirement
                                })
                            })
                    },
                ))
                .collect::<Vec<_>>();

        Ok(Metadata23 {
            metadata_version: metadata_version.to_string(),
            name: self.project.name.given.clone(),
            version: self.project.version.to_string(),
            // Not supported.
            platforms: vec![],
            // Not supported.
            supported_platforms: vec![],
            summary,
            description,
            description_content_type,
            keywords: self.project.keywords.clone().map(Keywords::new),
            home_page: None,
            download_url: None,
            author,
            author_email,
            maintainer,
            maintainer_email,
            license,
            license_expression,
            license_files,
            classifiers: self
                .project
                .classifiers
                .iter()
                .flatten()
                .cloned()
                .map(Into::into)
                .collect(),

            requires_dist: requires_dist.iter().map(ToString::to_string).collect(),
            provides_extra: extras.iter().map(ToString::to_string).collect(),
            // Not commonly set.
            provides_dist: vec![],
            // Not supported.
            obsoletes_dist: vec![],
            requires_python: self
                .project
                .requires_python
                .as_ref()
                .map(ToString::to_string),
            // Not used by other tools, not supported.
            requires_external: vec![],
            project_urls,
            dynamic: vec![],
        })
    }

    /// Parse and validate the old (PEP 621) and new (PEP 639) license files.
    #[expect(clippy::type_complexity)]
    fn license_metadata(
        &self,
        root: &Path,
    ) -> Result<(Option<String>, Option<String>, Vec<String>), Error> {
        // TODO(konsti): Issue a warning on old license metadata once PEP 639 is universal.
        let (license, license_expression, license_files) = if let Some(license_globs) =
            &self.project.license_files
        {
            let license_expression = match &self.project.license {
                None => None,
                Some(License::Spdx(license_expression)) => Some(license_expression.clone()),
                Some(License::Text { .. } | License::File { .. }) => {
                    return Err(ValidationError::MixedLicenseGenerations.into());
                }
            };

            let mut license_files = Vec::new();
            let mut license_globs_parsed = Vec::with_capacity(license_globs.len());
            let mut license_glob_matchers = Vec::with_capacity(license_globs.len());

            for license_glob in license_globs {
                let pep639_glob =
                    PortableGlobParser::Pep639
                        .parse(license_glob)
                        .map_err(|err| Error::PortableGlob {
                            field: license_glob.to_owned(),
                            source: err,
                        })?;
                license_glob_matchers.push(pep639_glob.compile_matcher());
                license_globs_parsed.push(pep639_glob);
            }

            // Track whether each user-specified glob matched so we can flag the unmatched ones.
            let mut license_globs_matched = vec![false; license_globs_parsed.len()];

            let license_globs =
                GlobDirFilter::from_globs(&license_globs_parsed).map_err(|err| {
                    Error::GlobSetTooLarge {
                        field: "project.license-files".to_string(),
                        source: err,
                    }
                })?;

            for entry in WalkDir::new(root)
                .sort_by_file_name()
                .into_iter()
                .filter_entry(|entry| {
                    license_globs.match_directory(
                        entry
                            .path()
                            .strip_prefix(root)
                            .expect("walkdir starts with root"),
                    )
                })
            {
                let entry = entry.map_err(|err| Error::WalkDir {
                    root: root.to_path_buf(),
                    err,
                })?;

                let relative = entry
                    .path()
                    .strip_prefix(root)
                    .expect("walkdir starts with root");

                if !license_globs.match_path(relative) {
                    trace!("Not a license files match: {}", relative.user_display());
                    continue;
                }

                let file_type = entry.file_type();

                if !(file_type.is_file() || file_type.is_symlink()) {
                    trace!(
                        "Not a file or symlink in license files match: {}",
                        relative.user_display()
                    );
                    continue;
                }

                error_on_venv(entry.file_name(), entry.path())?;

                debug!("License files match: {}", relative.user_display());

                for (matched, matcher) in license_globs_matched
                    .iter_mut()
                    .zip(license_glob_matchers.iter())
                {
                    if *matched {
                        continue;
                    }

                    if matcher.is_match(relative) {
                        *matched = true;
                    }
                }

                license_files.push(relative.portable_display().to_string());
            }

            if let Some((pattern, _)) = license_globs_parsed
                .into_iter()
                .zip(license_globs_matched)
                .find(|(_, matched)| !matched)
            {
                return Err(ValidationError::LicenseGlobNoMatches {
                    field: "project.license-files".to_string(),
                    glob: pattern.to_string(),
                }
                .into());
            }

            for license_file in &license_files {
                let file_path = root.join(license_file);
                let bytes = fs_err::read(&file_path)?;
                if str::from_utf8(&bytes).is_err() {
                    return Err(ValidationError::LicenseFileNotUtf8(license_file.clone()).into());
                }
            }

            // The glob order may be unstable
            license_files.sort();

            (None, license_expression, license_files)
        } else {
            match &self.project.license {
                None => (None, None, Vec::new()),
                Some(License::Spdx(license_expression)) => {
                    (None, Some(license_expression.clone()), Vec::new())
                }
                Some(License::Text { text }) => (Some(text.clone()), None, Vec::new()),
                Some(License::File { file }) => {
                    let text = fs_err::read_to_string(root.join(file))?;
                    (Some(text), None, Vec::new())
                }
            }
        };

        // Check that the license expression is a valid SPDX identifier.
        if let Some(license_expression) = &license_expression {
            if let Err(err) = spdx::Expression::parse(license_expression) {
                return Err(ValidationError::InvalidSpdx(license_expression.clone(), err).into());
            }
        }

        // Reconcile any structured license metadata against the deprecated license classifiers.
        for classifier in self.project.classifiers.iter().flatten() {
            let classifier = classifier.as_str();
            // Warn on `License :: ` classifiers.
            // We don't produce a hard failure here, per PEP 639.
            if classifier.starts_with("License :: ") {
                warn_user_once!(
                    "Found license classifier `{classifier}`. License classifiers are ambiguous and deprecated per PEP 639; projects should use `project.license` and `project.license-files` instead."
                );

                // TODO: Produce a hard error here if the project also has
                // structured license metadata, per PEP 639.
            }
        }

        Ok((license, license_expression, license_files))
    }

    /// Validate and convert the entrypoints in `pyproject.toml`, including console and GUI scripts,
    /// to an `entry_points.txt`.
    ///
    /// <https://packaging.python.org/en/latest/specifications/entry-points/>
    ///
    /// Returns `None` if no entrypoints were defined.
    pub(crate) fn to_entry_points(&self) -> Result<Option<String>, ValidationError> {
        let mut writer = String::new();

        if self.project.scripts.is_none()
            && self.project.gui_scripts.is_none()
            && self.project.entry_points.is_none()
        {
            return Ok(None);
        }

        if let Some(scripts) = &self.project.scripts {
            Self::write_group(&mut writer, "console_scripts", scripts)?;
        }
        if let Some(gui_scripts) = &self.project.gui_scripts {
            Self::write_group(&mut writer, "gui_scripts", gui_scripts)?;
        }
        for (group, entries) in self.project.entry_points.iter().flatten() {
            if group == "console_scripts" {
                return Err(ValidationError::ReservedScripts);
            }
            if group == "gui_scripts" {
                return Err(ValidationError::ReservedGuiScripts);
            }
            Self::write_group(&mut writer, group, entries)?;
        }
        Ok(Some(writer))
    }

    /// Write a group to `entry_points.txt`.
    fn write_group<'a>(
        writer: &mut String,
        group: &str,
        entries: impl IntoIterator<Item = (&'a String, &'a String)>,
    ) -> Result<(), ValidationError> {
        if !group
            .chars()
            .next()
            .map(|c| c.is_alphanumeric() || c == '_')
            .unwrap_or(false)
            || !group
                .chars()
                .all(|c| c.is_alphanumeric() || c == '.' || c == '_')
        {
            return Err(ValidationError::InvalidGroup(group.to_string()));
        }

        let _ = writeln!(writer, "[{group}]");
        for (name, object_reference) in entries {
            let compliant_name = name.chars().all(|character| {
                character.is_alphanumeric() || matches!(character, '.' | '-' | '_')
            });
            let dot_only_name = name.chars().all(|character| character == '.');

            if matches!(group, "console_scripts" | "gui_scripts")
                && (name.is_empty() || dot_only_name || !compliant_name)
            {
                return Err(ValidationError::InvalidScriptName(name.clone()));
            }

            if !compliant_name {
                warn!(
                    "Entrypoint names should consist of letters, numbers, dots, underscores and \
                    dashes; non-compliant name: {name}"
                );
            }

            // TODO(konsti): Validate that the object references are valid Python identifiers.
            let _ = writeln!(writer, "{name} = {object_reference}");
        }
        writer.push('\n');
        Ok(())
    }
}

/// The `[project]` section of a pyproject.toml as specified in
/// <https://packaging.python.org/en/latest/specifications/pyproject-toml>.
///
/// This struct does not have schema export; the schema is shared between all Python tools, and we
/// should update the shared schema instead.
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "kebab-case")]
struct Project {
    /// The name of the project.
    name: VerbatimPackageName,
    /// The version of the project.
    version: Version,
    /// The summary description of the project in one line.
    description: Option<String>,
    /// The full description of the project (i.e. the README).
    readme: Option<Readme>,
    /// The Python version requirements of the project.
    requires_python: Option<VersionSpecifiers>,
    /// The license under which the project is distributed.
    ///
    /// Supports both the current standard and the provisional PEP 639.
    license: Option<License>,
    /// The paths to files containing licenses and other legal notices to be distributed with the
    /// project.
    ///
    /// From the provisional PEP 639
    license_files: Option<Vec<String>>,
    /// The people or organizations considered to be the "authors" of the project.
    authors: Option<Vec<Contact>>,
    /// The people or organizations considered to be the "maintainers" of the project.
    maintainers: Option<Vec<Contact>>,
    /// The keywords for the project.
    keywords: Option<Vec<String>>,
    /// Trove classifiers which apply to the project.
    classifiers: Option<Vec<Classifier>>,
    /// A table of URLs where the key is the URL label and the value is the URL itself.
    ///
    /// PyPI shows all URLs with their name. For some known patterns, they add favicons.
    /// main: <https://github.com/pypi/warehouse/blob/main/warehouse/templates/packaging/detail.html>
    /// archived: <https://github.com/pypi/warehouse/blob/e3bd3c3805ff47fff32b67a899c1ce11c16f3c31/warehouse/templates/packaging/detail.html>
    urls: Option<IndexMap<String, String>>,
    /// The console entrypoints of the project.
    ///
    /// The key of the table is the name of the entry point and the value is the object reference.
    scripts: Option<BTreeMap<String, String>>,
    /// The GUI entrypoints of the project.
    ///
    /// The key of the table is the name of the entry point and the value is the object reference.
    gui_scripts: Option<BTreeMap<String, String>>,
    /// Entrypoints groups of the project.
    ///
    /// The key of the table is the name of the entry point and the value is the object reference.
    entry_points: Option<BTreeMap<String, BTreeMap<String, String>>>,
    /// The dependencies of the project.
    dependencies: Option<Vec<Requirement>>,
    /// The optional dependencies of the project.
    optional_dependencies: Option<BTreeMap<ExtraName, Vec<Requirement>>>,
    /// Specifies which fields listed by PEP 621 were intentionally unspecified so another tool
    /// can/will provide such metadata dynamically.
    ///
    /// Not supported, an error if anything but the default empty list.
    dynamic: Option<Vec<String>>,
}

/// The optional `project.readme` key in a pyproject.toml as specified in
/// <https://packaging.python.org/en/latest/specifications/pyproject-toml/#readme>.
#[derive(Deserialize, Debug, Clone)]
#[serde(untagged, rename_all_fields = "kebab-case")]
pub(crate) enum Readme {
    /// Relative path to the README.
    String(PathBuf),
    /// Relative path to the README.
    File {
        file: PathBuf,
        content_type: String,
        charset: Option<String>,
    },
    /// The full description of the project as an inline value.
    Text {
        text: String,
        content_type: String,
        charset: Option<String>,
    },
}

impl Readme {
    /// If the readme is a file, return the path to the file.
    pub(crate) fn path(&self) -> Option<&Path> {
        match self {
            Self::String(path) => Some(path),
            Self::File { file, .. } => Some(file),
            Self::Text { .. } => None,
        }
    }
}

/// The optional `project.license` key in a pyproject.toml as specified in
/// <https://packaging.python.org/en/latest/specifications/pyproject-toml/#license>.
#[derive(Deserialize, Debug, Clone)]
#[serde(untagged)]
pub(crate) enum License {
    /// An SPDX Expression.
    ///
    /// From the provisional PEP 639.
    Spdx(String),
    Text {
        /// The full text of the license.
        text: String,
    },
    File {
        /// The file containing the license text.
        file: String,
    },
}

impl License {
    fn file(&self) -> Option<&str> {
        if let Self::File { file } = self {
            Some(file)
        } else {
            None
        }
    }
}

/// A `project.authors` or `project.maintainers` entry as specified in
/// <https://packaging.python.org/en/latest/specifications/pyproject-toml/#authors-maintainers>.
///
/// The entry is derived from the email format of `John Doe <john.doe@example.net>`. You need to
/// provide at least name or email.
#[derive(Deserialize, Debug, Clone)]
// deny_unknown_fields prevents using the name field when the email is not a string.
#[serde(
    untagged,
    deny_unknown_fields,
    expecting = "a table with 'name' and/or 'email' keys"
)]
pub(crate) enum Contact {
    /// TODO(konsti): RFC 822 validation.
    NameEmail { name: String, email: String },
    /// TODO(konsti): RFC 822 validation.
    Name { name: String },
    /// TODO(konsti): RFC 822 validation.
    Email { email: String },
}

/// A trove classifier as originally specified in PEP 301.
#[derive(Debug, Clone)]
pub(crate) struct Classifier(String);

impl From<Classifier> for String {
    fn from(val: Classifier) -> Self {
        val.0
    }
}

impl<'de> Deserialize<'de> for Classifier {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        let s = <Cow<'_, str>>::deserialize(deserializer)?;
        Self::from_str(&s).map_err(serde::de::Error::custom)
    }
}

impl FromStr for Classifier {
    type Err = ValidationError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        // A classifier has two or more non-empty parts separated by ` :: `.
        let mut parts = s.split(" :: ");
        let valid = parts.next().is_some_and(|p| !p.is_empty())
            && parts.next().is_some_and(|p| !p.is_empty())
            && parts.all(|p| !p.is_empty());
        if !valid {
            return Err(ValidationError::InvalidClassifiers(s.to_string()));
        }

        Ok(Self(s.to_string()))
    }
}

impl Classifier {
    fn as_str(&self) -> &str {
        &self.0
    }
}

/// The `tool` section as specified in PEP 517.
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct Tool {
    /// uv-specific configuration
    uv: Option<ToolUv>,
}

/// The `tool.uv` section with build configuration.
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct ToolUv {
    /// Configuration for building source distributions and wheels with the uv build backend
    build_backend: Option<BuildBackendSettings>,
}

/// The `[build-system]` section of a pyproject.toml as specified in PEP 517.
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
struct BuildSystem {
    /// PEP 508 dependencies required to execute the build system.
    requires: Vec<SerdeVerbatim<Requirement<VerbatimParsedUrl>>>,
    /// A string naming a Python object that will be used to perform the build.
    build_backend: Option<String>,
    /// <https://peps.python.org/pep-0517/#in-tree-build-backends>
    backend_path: Option<Vec<String>>,
}

impl BuildSystem {
    /// Check if the `[build-system]` table matches the uv build backend expectations and return
    /// a list of warnings if it looks suspicious.
    ///
    /// Example of a valid table:
    ///
    /// ```toml
    /// [build-system]
    /// requires = ["uv_build>=0.4.15,<0.5.0"]
    /// build-backend = "uv_build"
    /// ```
    pub(crate) fn check_build_system(&self, uv_version: &str) -> Vec<String> {
        let mut warnings = Vec::new();
        if self.build_backend.as_deref() != Some("uv_build") {
            warnings.push(format!(
                r#"`build_system.build-backend` was expected to be `"uv_build"`, not `"{}"`"#,
                self.build_backend.clone().unwrap_or_default()
            ));
        }

        let uv_version =
            Version::from_str(uv_version).expect("uv's own version is not PEP 440 compliant");
        let next_minor = uv_version.release().get(1).copied().unwrap_or_default() + 1;
        let next_breaking = Version::new([0, next_minor]);

        let [uv_requirement] = &self.requires.as_slice() else {
            warnings.push(format!(
                "Expected `build-system.requires` to contain only `uv_build`, found `{}`",
                self.requires.iter().map(ToString::to_string).join("`, `")
            ));
            return warnings;
        };
        if uv_requirement.name.as_str() != "uv-build" {
            warnings.push(format!(
                "Expected `build-system.requires` to be `uv_build`, found `{}`",
                self.requires.iter().map(ToString::to_string).join("`, `")
            ));
            return warnings;
        }
        let bounded = match &uv_requirement.version_or_url {
            None => false,
            Some(VersionOrUrl::Url(_)) => {
                // We can't validate the url
                true
            }
            Some(VersionOrUrl::VersionSpecifier(specifier)) => {
                // We don't check how wide the range is (that's up to the user), we just
                // check that the current version is compliant, to avoid accidentally using a
                // too new or too old uv, and we check that an upper bound exists. The latter
                // is very important to allow making breaking changes in uv without breaking
                // the existing immutable source distributions on pypi.
                if !specifier.contains(&uv_version) {
                    // This is allowed to happen when testing prereleases, but we should still warn.
                    warnings.push(format!(
                        "`build_system.requires = [\"{uv_requirement}\"]` does not contain the \
                        current uv version {uv_version}",
                    ));
                }
                Ranges::from(specifier.clone())
                    .bounding_range()
                    .map(|bounding_range| bounding_range.1 != Bound::Unbounded)
                    .unwrap_or(false)
            }
        };

        if !bounded {
            warnings.push(format!(
                "`build_system.requires = [\"{}\"]` is missing an \
                upper bound on the `uv_build` version such as `<{next_breaking}`. \
                Without bounding the `uv_build` version, the source distribution will break \
                when a future, breaking version of `uv_build` is released.",
                // Use an underscore consistently, to avoid confusing users between a package name with dash and a
                // module name with underscore
                uv_requirement.verbatim()
            ));
        }

        warnings
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use indoc::{formatdoc, indoc};
    use insta::assert_snapshot;
    use std::iter;
    use tempfile::TempDir;

    fn extend_project(payload: &str) -> String {
        formatdoc! {r#"
            [project]
            name = "hello-world"
            version = "0.1.0"
            {payload}

            [build-system]
            requires = ["uv_build>=0.4.15,<0.5.0"]
            build-backend = "uv_build"
        "#
        }
    }

    fn format_err(err: impl std::error::Error) -> String {
        let mut formatted = err.to_string();
        for source in iter::successors(err.source(), |&err| err.source()) {
            let _ = write!(formatted, "\n  Caused by: {source}");
        }
        formatted
    }

    #[test]
    fn uppercase_package_name() {
        let contents = r#"
            [project]
            name = "Hello-World"
            version = "0.1.0"

            [build-system]
            requires = ["uv_build>=0.4.15,<0.5.0"]
            build-backend = "uv_build"
        "#;
        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
        let temp_dir = TempDir::new().unwrap();

        let metadata = pyproject_toml.to_metadata(temp_dir.path()).unwrap();
        assert_snapshot!(metadata.core_metadata_format(), @"
        Metadata-Version: 2.3
        Name: Hello-World
        Version: 0.1.0
        ");
    }

    #[test]
    fn valid() {
        let temp_dir = TempDir::new().unwrap();

        fs_err::write(
            temp_dir.path().join("Readme.md"),
            indoc! {r"
            # Foo

            This is the foo library.
        "},
        )
        .unwrap();

        fs_err::write(
            temp_dir.path().join("License.txt"),
            indoc! {r#"
                THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
                INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
                PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
                HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
                CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
                OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
        "#},
        )
        .unwrap();

        let contents = indoc! {r#"
            # See https://github.com/pypa/sampleproject/blob/main/pyproject.toml for another example

            [project]
            name = "hello-world"
            version = "0.1.0"
            description = "A Python package"
            readme = "Readme.md"
            requires_python = ">=3.12"
            license = { file = "License.txt" }
            authors = [{ name = "Ferris the crab", email = "ferris@rustacean.net" }]
            maintainers = [{ name = "Konsti", email = "konstin@mailbox.org" }]
            keywords = ["demo", "example", "package"]
            classifiers = [
                "Development Status :: 6 - Mature",
                "License :: OSI Approved :: MIT License",
                # https://github.com/pypa/trove-classifiers/issues/17
                "License :: OSI Approved :: Apache Software License",
                "Programming Language :: Python",
            ]
            dependencies = ["flask>=3,<4", "sqlalchemy[asyncio]>=2.0.35,<3"]
            # We don't support dynamic fields, the default empty array is the only allowed value.
            dynamic = []

            [project.optional-dependencies]
            postgres = ["psycopg>=3.2.2,<4"]
            mysql = ["pymysql>=1.1.1,<2"]

            [project.urls]
            "Homepage" = "https://github.com/astral-sh/uv"
            "Repository" = "https://astral.sh"

            [project.scripts]
            foo = "foo.cli:__main__"

            [project.gui-scripts]
            foo-gui = "foo.gui"

            [project.entry-points.bar_group]
            foo-bar = "foo:bar"

            [build-system]
            requires = ["uv_build>=0.4.15,<0.5.0"]
            build-backend = "uv_build"
        "#
        };

        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
        let metadata = pyproject_toml.to_metadata(temp_dir.path()).unwrap();

        assert_snapshot!(metadata.core_metadata_format(), @r#"
        Metadata-Version: 2.3
        Name: hello-world
        Version: 0.1.0
        Summary: A Python package
        Keywords: demo,example,package
        Author: Ferris the crab
        Author-email: Ferris the crab <ferris@rustacean.net>
        License: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
                 INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
                 PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
                 HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
                 CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
                 OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
        Classifier: Development Status :: 6 - Mature
        Classifier: License :: OSI Approved :: MIT License
        Classifier: License :: OSI Approved :: Apache Software License
        Classifier: Programming Language :: Python
        Requires-Dist: flask>=3,<4
        Requires-Dist: sqlalchemy[asyncio]>=2.0.35,<3
        Requires-Dist: pymysql>=1.1.1,<2 ; extra == 'mysql'
        Requires-Dist: psycopg>=3.2.2,<4 ; extra == 'postgres'
        Maintainer: Konsti
        Maintainer-email: Konsti <konstin@mailbox.org>
        Project-URL: Homepage, https://github.com/astral-sh/uv
        Project-URL: Repository, https://astral.sh
        Provides-Extra: mysql
        Provides-Extra: postgres
        Description-Content-Type: text/markdown

        # Foo

        This is the foo library.
        "#);

        assert_snapshot!(pyproject_toml.to_entry_points().unwrap().unwrap(), @"
        [console_scripts]
        foo = foo.cli:__main__

        [gui_scripts]
        foo-gui = foo.gui

        [bar_group]
        foo-bar = foo:bar
        ");
    }

    #[test]
    fn readme() {
        let temp_dir = TempDir::new().unwrap();

        fs_err::write(
            temp_dir.path().join("Readme.md"),
            indoc! {r"
            # Foo

            This is the foo library.
        "},
        )
        .unwrap();

        fs_err::write(
            temp_dir.path().join("License.txt"),
            indoc! {r#"
                THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
                INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
                PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
                HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
                CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
                OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
        "#},
        )
        .unwrap();

        let contents = indoc! {r#"
            # See https://github.com/pypa/sampleproject/blob/main/pyproject.toml for another example

            [project]
            name = "hello-world"
            version = "0.1.0"
            description = "A Python package"
            readme = { file = "Readme.md", content-type = "text/markdown" }
            requires_python = ">=3.12"

            [build-system]
            requires = ["uv_build>=0.4.15,<0.5"]
            build-backend = "uv_build"
        "#
        };

        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
        let metadata = pyproject_toml.to_metadata(temp_dir.path()).unwrap();

        assert_snapshot!(metadata.core_metadata_format(), @"
        Metadata-Version: 2.3
        Name: hello-world
        Version: 0.1.0
        Summary: A Python package
        Description-Content-Type: text/markdown

        # Foo

        This is the foo library.
        ");
    }

    #[test]
    fn self_extras() {
        let temp_dir = TempDir::new().unwrap();

        fs_err::write(
            temp_dir.path().join("Readme.md"),
            indoc! {r"
            # Foo

            This is the foo library.
        "},
        )
        .unwrap();

        fs_err::write(
            temp_dir.path().join("License.txt"),
            indoc! {r#"
                THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
                INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
                PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
                HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
                CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
                OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
        "#},
        )
        .unwrap();

        let contents = indoc! {r#"
            # See https://github.com/pypa/sampleproject/blob/main/pyproject.toml for another example

            [project]
            name = "hello-world"
            version = "0.1.0"
            description = "A Python package"
            readme = "Readme.md"
            requires_python = ">=3.12"
            license = { file = "License.txt" }
            authors = [{ name = "Ferris the crab", email = "ferris@rustacean.net" }]
            maintainers = [{ name = "Konsti", email = "konstin@mailbox.org" }]
            keywords = ["demo", "example", "package"]
            classifiers = [
                "Development Status :: 6 - Mature",
                "License :: OSI Approved :: MIT License",
                # https://github.com/pypa/trove-classifiers/issues/17
                "License :: OSI Approved :: Apache Software License",
                "Programming Language :: Python",
            ]
            dependencies = ["flask>=3,<4", "sqlalchemy[asyncio]>=2.0.35,<3"]
            # We don't support dynamic fields, the default empty array is the only allowed value.
            dynamic = []

            [project.optional-dependencies]
            postgres = ["psycopg>=3.2.2,<4 ; sys_platform == 'linux'"]
            mysql = ["pymysql>=1.1.1,<2"]
            databases = ["hello-world[mysql]", "hello-world[postgres]"]
            all = ["hello-world[databases]", "hello-world[postgres]", "hello-world[mysql]"]

            [project.urls]
            "Homepage" = "https://github.com/astral-sh/uv"
            "Repository" = "https://astral.sh"

            [project.scripts]
            foo = "foo.cli:__main__"

            [project.gui-scripts]
            foo-gui = "foo.gui"

            [project.entry-points.bar_group]
            foo-bar = "foo:bar"

            [build-system]
            requires = ["uv_build>=0.4.15,<0.5.0"]
            build-backend = "uv_build"
        "#
        };

        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
        let metadata = pyproject_toml.to_metadata(temp_dir.path()).unwrap();

        assert_snapshot!(metadata.core_metadata_format(), @r#"
        Metadata-Version: 2.3
        Name: hello-world
        Version: 0.1.0
        Summary: A Python package
        Keywords: demo,example,package
        Author: Ferris the crab
        Author-email: Ferris the crab <ferris@rustacean.net>
        License: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
                 INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
                 PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
                 HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
                 CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
                 OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
        Classifier: Development Status :: 6 - Mature
        Classifier: License :: OSI Approved :: MIT License
        Classifier: License :: OSI Approved :: Apache Software License
        Classifier: Programming Language :: Python
        Requires-Dist: flask>=3,<4
        Requires-Dist: sqlalchemy[asyncio]>=2.0.35,<3
        Requires-Dist: hello-world[databases] ; extra == 'all'
        Requires-Dist: hello-world[postgres] ; extra == 'all'
        Requires-Dist: hello-world[mysql] ; extra == 'all'
        Requires-Dist: hello-world[mysql] ; extra == 'databases'
        Requires-Dist: hello-world[postgres] ; extra == 'databases'
        Requires-Dist: pymysql>=1.1.1,<2 ; extra == 'mysql'
        Requires-Dist: psycopg>=3.2.2,<4 ; sys_platform == 'linux' and extra == 'postgres'
        Maintainer: Konsti
        Maintainer-email: Konsti <konstin@mailbox.org>
        Project-URL: Homepage, https://github.com/astral-sh/uv
        Project-URL: Repository, https://astral.sh
        Provides-Extra: all
        Provides-Extra: databases
        Provides-Extra: mysql
        Provides-Extra: postgres
        Description-Content-Type: text/markdown

        # Foo

        This is the foo library.
        "#);

        assert_snapshot!(pyproject_toml.to_entry_points().unwrap().unwrap(), @"
        [console_scripts]
        foo = foo.cli:__main__

        [gui_scripts]
        foo-gui = foo.gui

        [bar_group]
        foo-bar = foo:bar
        ");
    }

    #[test]
    fn build_system_valid() {
        let contents = extend_project("");
        let pyproject_toml: PyProjectToml = toml::from_str(&contents).unwrap();
        assert_snapshot!(
            pyproject_toml.check_build_system("0.4.15+test").join("\n"),
            @""
        );
    }

    #[test]
    fn build_system_no_bound() {
        let contents = indoc! {r#"
            [project]
            name = "hello-world"
            version = "0.1.0"

            [build-system]
            requires = ["uv_build"]
            build-backend = "uv_build"
        "#};
        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
        assert_snapshot!(
            pyproject_toml.check_build_system("0.4.15+test").join("\n"),
            @r#"`build_system.requires = ["uv_build"]` is missing an upper bound on the `uv_build` version such as `<0.5`. Without bounding the `uv_build` version, the source distribution will break when a future, breaking version of `uv_build` is released."#
        );
    }

    #[test]
    fn build_system_multiple_packages() {
        let contents = indoc! {r#"
            [project]
            name = "hello-world"
            version = "0.1.0"

            [build-system]
            requires = ["uv_build>=0.4.15,<0.5.0", "wheel"]
            build-backend = "uv_build"
        "#};
        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
        assert_snapshot!(
            pyproject_toml.check_build_system("0.4.15+test").join("\n"),
            @"Expected `build-system.requires` to contain only `uv_build`, found `uv-build>=0.4.15,<0.5.0`, `wheel`"
        );
    }

    #[test]
    fn build_system_no_requires_uv() {
        let contents = indoc! {r#"
            [project]
            name = "hello-world"
            version = "0.1.0"

            [build-system]
            requires = ["setuptools"]
            build-backend = "uv_build"
        "#};
        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
        assert_snapshot!(
            pyproject_toml.check_build_system("0.4.15+test").join("\n"),
            @"Expected `build-system.requires` to be `uv_build`, found `setuptools`"
        );
    }

    #[test]
    fn build_system_not_uv() {
        let contents = indoc! {r#"
            [project]
            name = "hello-world"
            version = "0.1.0"

            [build-system]
            requires = ["uv_build>=0.4.15,<0.5.0"]
            build-backend = "setuptools"
        "#};
        let pyproject_toml: PyProjectToml = toml::from_str(contents).unwrap();
        assert_snapshot!(
            pyproject_toml.check_build_system("0.4.15+test").join("\n"),
            @r#"`build_system.build-backend` was expected to be `"uv_build"`, not `"setuptools"`"#
        );
    }

    #[test]
    fn minimal() {
        let contents = extend_project("");

        let metadata = toml::from_str::<PyProjectToml>(&contents)
            .unwrap()
            .to_metadata(Path::new("/do/not/read"))
            .unwrap();

        assert_snapshot!(metadata.core_metadata_format(), @"
        Metadata-Version: 2.3
        Name: hello-world
        Version: 0.1.0
        ");
    }

    #[test]
    fn invalid_readme_spec() {
        let contents = extend_project(indoc! {r#"
            readme = { path = "Readme.md" }
        "#
        });

        let err = toml::from_str::<PyProjectToml>(&contents).unwrap_err();
        assert_snapshot!(format_err(err), @r#"
        TOML parse error at line 4, column 10
          |
        4 | readme = { path = "Readme.md" }
          |          ^^^^^^^^^^^^^^^^^^^^^^
        data did not match any variant of untagged enum Readme
        "#);
    }

    #[test]
    fn missing_readme() {
        let contents = extend_project(indoc! {r#"
            readme = "Readme.md"
        "#
        });

        let err = toml::from_str::<PyProjectToml>(&contents)
            .unwrap()
            .to_metadata(Path::new("/do/not/read"))
            .unwrap_err();
        // Strip away OS specific part.
        let err = err
            .to_string()
            .replace('\\', "/")
            .split_once(':')
            .unwrap()
            .0
            .to_string();
        assert_snapshot!(err, @"failed to open file `/do/not/read/Readme.md`");
    }

    #[test]
    fn multiline_description() {
        let contents = extend_project(indoc! {r#"
            description = "Hi :)\nThis is my project"
        "#
        });

        let err = toml::from_str::<PyProjectToml>(&contents)
            .unwrap()
            .to_metadata(Path::new("/do/not/read"))
            .unwrap_err();
        assert_snapshot!(format_err(err), @"
        Invalid project metadata
          Caused by: `project.description` must be a single line
        ");
    }

    #[test]
    fn mixed_licenses() {
        let contents = extend_project(indoc! {r#"
            license-files = ["licenses/*"]
            license =  { text = "MIT" }
        "#
        });

        let err = toml::from_str::<PyProjectToml>(&contents)
            .unwrap()
            .to_metadata(Path::new("/do/not/read"))
            .unwrap_err();
        assert_snapshot!(format_err(err), @"
        Invalid project metadata
          Caused by: When `project.license-files` is defined, `project.license` must be an SPDX expression string
        ");
    }

    #[test]
    fn valid_license() {
        let contents = extend_project(indoc! {r#"
            license = "MIT OR Apache-2.0"
        "#
        });
        let metadata = toml::from_str::<PyProjectToml>(&contents)
            .unwrap()
            .to_metadata(Path::new("/do/not/read"))
            .unwrap();
        assert_snapshot!(metadata.core_metadata_format(), @"
        Metadata-Version: 2.4
        Name: hello-world
        Version: 0.1.0
        License-Expression: MIT OR Apache-2.0
        ");
    }

    #[test]
    fn invalid_license() {
        let contents = extend_project(indoc! {r#"
            license = "MIT XOR Apache-2"
        "#
        });
        let err = toml::from_str::<PyProjectToml>(&contents)
            .unwrap()
            .to_metadata(Path::new("/do/not/read"))
            .unwrap_err();
        // TODO(konsti): We mess up the indentation in the error.
        assert_snapshot!(format_err(err), @"
        Invalid project metadata
          Caused by: `project.license` is not a valid SPDX expression: MIT XOR Apache-2
          Caused by: MIT XOR Apache-2
            ^^^ unknown term
        ");
    }

    #[test]
    fn invalid_classifier() {
        for tc in [
            "",
            " ",
            "\n",
            "NotAClassifier",
            "Foo ::  :: Bar",
            "Foo :: Bar :: ",
            " :: Foo :: Bar",
        ] {
            assert!(
                Classifier::from_str(tc).is_err(),
                "expected `{tc}` to be an invalid classifier"
            );
        }
    }

    /// Test that we produce a reasonable parse error when a pyproject.toml contains an invalid classifier.
    #[test]
    fn invalid_classifier_in_pyproject() {
        let contents = extend_project(indoc! {r#"
            classifiers = ["NotAClassifier"]
        "#
        });

        let err = toml::from_str::<PyProjectToml>(&contents).unwrap_err();
        assert_snapshot!(format_err(err), @r#"
        TOML parse error at line 4, column 15
          |
        4 | classifiers = ["NotAClassifier"]
          |               ^^^^^^^^^^^^^^^^^^
        `project.classifiers` contains an invalid classifier: NotAClassifier
        "#);
    }

    #[test]
    fn dynamic() {
        let contents = extend_project(indoc! {r#"
            dynamic = ["dependencies"]
        "#
        });

        let err = toml::from_str::<PyProjectToml>(&contents)
            .unwrap()
            .to_metadata(Path::new("/do/not/read"))
            .unwrap_err();
        assert_snapshot!(format_err(err), @"
        Invalid project metadata
          Caused by: Dynamic metadata is not supported
        ");
    }

    fn script_error(contents: &str) -> String {
        let err = toml::from_str::<PyProjectToml>(contents)
            .unwrap()
            .to_entry_points()
            .unwrap_err();
        format_err(err)
    }

    #[test]
    fn invalid_entry_point_group() {
        let contents = extend_project(indoc! {r#"
            [project.entry-points."a@b"]
            foo = "bar"
        "#
        });
        assert_snapshot!(script_error(&contents), @"Entrypoint groups must consist of letters and numbers separated by dots, invalid group: a@b");
    }

    #[test]
    fn invalid_entry_point_conflict_scripts() {
        let contents = extend_project(indoc! {r#"
            [project.entry-points.console_scripts]
            foo = "bar"
        "#
        });
        assert_snapshot!(script_error(&contents), @"Use `project.scripts` instead of `project.entry-points.console_scripts`");
    }

    #[test]
    fn invalid_entry_point_conflict_gui_scripts() {
        let contents = extend_project(indoc! {r#"
            [project.entry-points.gui_scripts]
            foo = "bar"
        "#
        });
        assert_snapshot!(script_error(&contents), @"Use `project.gui-scripts` instead of `project.entry-points.gui_scripts`");
    }

    #[test]
    fn check_direct_build_ok() {
        let temp_dir = TempDir::new().unwrap();
        fs_err::write(
            temp_dir.path().join("pyproject.toml"),
            indoc! {r#"
                [project]
                name = "hello-world"
                version = "0.1.0"

                [build-system]
                requires = ["uv_build>=0.10.0,<0.11"]
                build-backend = "uv_build"
            "#},
        )
        .unwrap();
        check_direct_build(temp_dir.path(), "0.10.0").unwrap();
    }

    #[test]
    fn check_direct_build_parse_error() {
        let temp_dir = TempDir::new().unwrap();
        fs_err::write(
            temp_dir.path().join("pyproject.toml"),
            "invalid toml >>>>>>>",
        )
        .unwrap();
        assert_snapshot!(
            check_direct_build(temp_dir.path(), "0.10.0").unwrap_err(),
            @r#"
            its `pyproject.toml` failed to parse: TOML parse error at line 1, column 9
              |
            1 | invalid toml >>>>>>>
              |         ^
            key with no value, expected `=`
            "#
        );
    }

    #[test]
    fn check_direct_build_wrong_backend() {
        let temp_dir = TempDir::new().unwrap();
        fs_err::write(
            temp_dir.path().join("pyproject.toml"),
            indoc! {r#"
                [project]
                name = "hello-world"
                version = "0.1.0"

                [build-system]
                requires = ["setuptools"]
                build-backend = "setuptools"
            "#},
        )
        .unwrap();
        assert_snapshot!(
            check_direct_build(temp_dir.path(), "0.10.0").unwrap_err(),
            @"`build_system.build-backend` is not `uv_build`, but `setuptools`"
        );
    }

    #[test]
    fn check_direct_build_multiple_requires() {
        let temp_dir = TempDir::new().unwrap();
        fs_err::write(
            temp_dir.path().join("pyproject.toml"),
            indoc! {r#"
                [project]
                name = "hello-world"
                version = "0.1.0"

                [build-system]
                requires = ["uv_build>=0.10.0,<0.11", "wheel"]
                build-backend = "uv_build"
            "#},
        )
        .unwrap();
        assert_snapshot!(
            check_direct_build(temp_dir.path(), "0.10.0").unwrap_err(),
            @"`build-system.requires` is not exactly `uv_build`, but `uv-build>=0.10.0,<0.11`, `wheel`"
        );
    }

    #[test]
    fn check_direct_build_wrong_package() {
        let temp_dir = TempDir::new().unwrap();
        fs_err::write(
            temp_dir.path().join("pyproject.toml"),
            indoc! {r#"
                [project]
                name = "hello-world"
                version = "0.1.0"

                [build-system]
                requires = ["setuptools>=70"]
                build-backend = "uv_build"
            "#},
        )
        .unwrap();
        assert_snapshot!(
            check_direct_build(temp_dir.path(), "0.10.0").unwrap_err(),
            @"`build-system.requires` is not `uv_build`, but `setuptools`"
        );
    }

    #[test]
    fn check_direct_build_url_requirement() {
        let temp_dir = TempDir::new().unwrap();
        fs_err::write(
            temp_dir.path().join("pyproject.toml"),
            indoc! {r#"
                [project]
                name = "hello-world"
                version = "0.1.0"

                [build-system]
                requires = ["uv_build @ https://example.com/uv_build-0.10.0-py3-none-any.whl"]
                build-backend = "uv_build"
            "#},
        )
        .unwrap();
        assert_snapshot!(
            check_direct_build(temp_dir.path(), "0.10.0").unwrap_err(),
            @"`build_system.requires` uses a URL requirement"
        );
    }

    #[test]
    fn check_direct_build_incompatible_range() {
        let temp_dir = TempDir::new().unwrap();
        fs_err::write(
            temp_dir.path().join("pyproject.toml"),
            indoc! {r#"
                [project]
                name = "hello-world"
                version = "0.1.0"

                [build-system]
                requires = ["uv_build>=0.5.0,<0.6"]
                build-backend = "uv_build"
            "#},
        )
        .unwrap();
        assert_snapshot!(
            check_direct_build(temp_dir.path(), "0.10.0").unwrap_err(),
            @"`uv_build>=0.5.0, <0.6` is not a known compatible range"
        );
    }

    #[test]
    fn update_compatibility_for_breaking_release() {
        // Handle the case where we made a breaking change to the build backend.
        // Feel free to update the heuristic if it doesn't suit the current compatibility rules
        // anymore.
        if COMPATIBLE_VERSIONS.is_empty() {
            return;
        }

        let current_version = Version::from_str(uv_version::version()).unwrap();
        // Versions are ordered from oldest to latest
        let last_compatible =
            Version::from_str(COMPATIBLE_VERSIONS[COMPATIBLE_VERSIONS.len() - 1]).unwrap();
        if last_compatible.release()[0] != current_version.release()[0]
            && last_compatible.release()[0] != current_version.release()[1]
        {
            panic!(
                "Please update the list of compatible versions for the uv build backend: \
                If there was no breaking change in uv-build, add the last release before the \
                breaking release to `COMPATIBLE_VERSIONS`, otherwise reset `COMPATIBLE_VERSIONS` \
                to an empty list"
            );
        }
    }
}
