use compact_str::CompactString;
use configuration_file::{ConfigurationFile, ConfigurationFileError};
use ruff_db::files::FileRootKind;
use ruff_db::system::{System, SystemPath, SystemPathBuf};
use ruff_db::vendored::VendoredFileSystem;
use ruff_ranged_value::ValueSource;
use std::sync::Arc;
use thiserror::Error;
use ty_combine::Combine;
use ty_python_core::program::{FallibleStrategy, MisconfigurationStrategy, ProgramSettings};

use crate::Db;
use crate::metadata::options::{OptionDiagnostic, ProgramSettingsDiagnostic, ToSettingsError};
use crate::metadata::pyproject::{Project, PyProject, PyProjectError, ResolveRequiresPythonError};
use crate::metadata::settings::Settings;
pub use options::Options;
use options::TyTomlError;

mod configuration_file;
pub mod options;
pub mod pyproject;
pub mod python_version;
pub mod settings;
pub mod value;

#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)]
#[cfg_attr(test, derive(serde::Serialize))]
pub struct ProjectMetadata {
    name: ProjectName,

    pub(super) root: SystemPathBuf,

    /// The highest-precedence options, such as CLI flags or inline editor configuration.
    #[cfg_attr(test, serde(skip_serializing_if = "Option::is_none"))]
    override_options: Option<Box<Options>>,

    /// The raw (unmerged, unresolved) options from the project's configuration.
    /// When [`Self::config_file_override`] is `None`, then these are the options from the
    /// project's `ty.toml` or `pyproject.toml`. The options come from
    /// the file specified by [`Self::config_file_override`] if it is `Some` (e.g. when using `--config-file <path>`).
    pub(super) options: Options,

    /// The user-level configuration path and its options.
    ///
    /// Its options have lower precedence than [`Self::override_options`] and [`Self::options`],
    /// but higher precedence than [`Self::fallback_options`].
    #[cfg_attr(test, serde(skip_serializing_if = "Option::is_none"))]
    user_configuration: Option<Box<(SystemPathBuf, Options)>>,

    /// The lowest-precedence options, such as the editor-selected Python environment.
    #[cfg_attr(test, serde(skip_serializing_if = "Option::is_none"))]
    fallback_options: Option<Box<Options>>,

    /// The explicit configuration file that replaces normal project discovery.
    ///
    /// Can be specified using `--config-file <path>`. When `Some`, [`Self::options`] were loaded from this file
    /// instead of from the project's `pyproject.toml` or `ty.toml` file.
    #[cfg_attr(test, serde(skip_serializing_if = "Option::is_none"))]
    config_file_override: Option<SystemPathBuf>,
}

impl ProjectMetadata {
    /// Creates a project with the given name and root that uses the default options.
    pub fn new(name: impl AsRef<str>, root: SystemPathBuf) -> Self {
        Self {
            name: ProjectName::new(name),
            root,
            options: Options::default(),
            override_options: None,
            user_configuration: None,
            fallback_options: None,
            config_file_override: None,
        }
    }

    pub fn from_config_file(
        path: SystemPathBuf,
        root: &SystemPath,
        system: &dyn System,
    ) -> Result<Self, ProjectMetadataError> {
        tracing::debug!("Using overridden configuration file at '{path}'");

        let config_file = ConfigurationFile::from_path(path.clone(), system).map_err(|error| {
            ProjectMetadataError::ConfigurationFileError {
                source: Box::new(error),
                path: path.clone(),
            }
        })?;

        let options = config_file.into_options();

        Ok(Self {
            name: ProjectName::new(root.file_name().unwrap_or("root")),
            root: root.to_path_buf(),
            options,
            override_options: None,
            user_configuration: None,
            fallback_options: None,
            config_file_override: Some(path),
        })
    }

    /// Loads a project from a `pyproject.toml` file.
    pub(crate) fn from_pyproject(
        pyproject: PyProject,
        root: SystemPathBuf,
    ) -> Result<Self, ResolveRequiresPythonError> {
        Self::from_options(
            pyproject.tool.and_then(|tool| tool.ty).unwrap_or_default(),
            root,
            pyproject.project.as_ref(),
            &FallibleStrategy,
        )
    }

    /// Loads a project from a set of options with an optional pyproject-project table.
    pub fn from_options<Strategy: MisconfigurationStrategy>(
        mut options: Options,
        root: SystemPathBuf,
        project: Option<&Project>,
        strategy: &Strategy,
    ) -> Result<Self, Strategy::Error<ResolveRequiresPythonError>> {
        let name = project
            .and_then(|project| project.name.as_deref())
            .map(|name| ProjectName::new(&**name))
            .unwrap_or_else(|| ProjectName::new(root.file_name().unwrap_or("root")));

        // If the `options` don't specify a python version but the `project.requires-python` field is set,
        // use that as a lower bound instead.
        if let Some(project) = project {
            if options
                .environment
                .as_ref()
                .is_none_or(|env| env.python_version.is_none())
            {
                let requires_python = strategy.fallback_opt(
                    project.resolve_requires_python_lower_bound(),
                    |err| {
                        tracing::debug!("skipping invalid requires_python lower bound: {err}");
                    },
                )?;
                if let Some(requires_python) = requires_python.flatten() {
                    let mut environment = options.environment.unwrap_or_default();
                    environment.python_version = Some(requires_python);
                    options.environment = Some(environment);
                }
            }
        }

        Ok(Self {
            name,
            root,
            options,
            override_options: None,
            user_configuration: None,
            fallback_options: None,
            config_file_override: None,
        })
    }

    /// Discovers the closest project at `path` and returns its metadata.
    ///
    /// The algorithm traverses upwards in the `path`'s ancestor chain and uses the following precedence
    /// the resolve the project's root.
    ///
    /// 1. The closest `pyproject.toml` with a `tool.ty` section or `ty.toml`.
    /// 1. The closest `pyproject.toml`.
    /// 1. Fallback to use `path` as the root and use the default settings.
    pub fn discover(
        path: &SystemPath,
        system: &dyn System,
    ) -> Result<ProjectMetadata, ProjectMetadataError> {
        tracing::debug!("Searching for a project in '{path}'");

        if !system.is_directory(path) {
            return Err(ProjectMetadataError::NotADirectory(path.to_path_buf()));
        }

        let mut closest_project: Option<ProjectMetadata> = None;

        for project_root in path.ancestors() {
            let pyproject_path = project_root.join("pyproject.toml");

            let pyproject = if let Ok(pyproject_str) = system.read_to_string(&pyproject_path) {
                match PyProject::from_toml_str(
                    &pyproject_str,
                    ValueSource::File(Arc::new(pyproject_path.clone())),
                ) {
                    Ok(pyproject) => Some(pyproject),
                    Err(error) => {
                        return Err(ProjectMetadataError::InvalidPyProject {
                            path: pyproject_path,
                            source: Box::new(error),
                        });
                    }
                }
            } else {
                None
            };

            // A `ty.toml` takes precedence over a `pyproject.toml`.
            let ty_toml_path = project_root.join("ty.toml");
            if let Ok(ty_str) = system.read_to_string(&ty_toml_path) {
                let options = match Options::from_toml_str(
                    &ty_str,
                    ValueSource::File(Arc::new(ty_toml_path.clone())),
                ) {
                    Ok(options) => options,
                    Err(error) => {
                        return Err(ProjectMetadataError::InvalidTyToml {
                            path: ty_toml_path,
                            source: Box::new(error),
                        });
                    }
                };

                if pyproject
                    .as_ref()
                    .is_some_and(|project| project.ty().is_some())
                {
                    // TODO: Consider using a diagnostic here
                    tracing::warn!(
                        "Ignoring the `tool.ty` section in `{pyproject_path}` because `{ty_toml_path}` takes precedence."
                    );
                }

                tracing::debug!("Found project at '{}'", project_root);

                let metadata = ProjectMetadata::from_options(
                    options,
                    project_root.to_path_buf(),
                    pyproject
                        .as_ref()
                        .and_then(|pyproject| pyproject.project.as_ref()),
                    &FallibleStrategy,
                )
                .map_err(|err| {
                    ProjectMetadataError::InvalidRequiresPythonConstraint {
                        source: err,
                        path: pyproject_path,
                    }
                })?;

                return Ok(metadata);
            }

            if let Some(pyproject) = pyproject {
                let has_ty_section = pyproject.ty().is_some();
                let metadata =
                    ProjectMetadata::from_pyproject(pyproject, project_root.to_path_buf())
                        .map_err(
                            |err| ProjectMetadataError::InvalidRequiresPythonConstraint {
                                source: err,
                                path: pyproject_path,
                            },
                        )?;

                if has_ty_section {
                    tracing::debug!("Found project at '{}'", project_root);

                    return Ok(metadata);
                }

                // Not a project itself, keep looking for an enclosing project.
                if closest_project.is_none() {
                    closest_project = Some(metadata);
                }
            }
        }

        // No project found, but maybe a pyproject.toml was found.
        let metadata = if let Some(closest_project) = closest_project {
            tracing::debug!(
                "Project without `tool.ty` section: '{}'",
                closest_project.root()
            );

            closest_project
        } else {
            tracing::debug!(
                "The ancestor directories contain no `pyproject.toml`. Falling back to a virtual project."
            );

            // Create a project with a default configuration
            Self::new(path.file_name().unwrap_or("root"), path.to_path_buf())
        };

        Ok(metadata)
    }

    /// Rediscovers the project, while preserving applied options.
    pub(crate) fn rediscover(&self, system: &dyn System) -> Result<Self, ProjectMetadataError> {
        let mut metadata = if let Some(config_file) = self.config_file_override() {
            Self::from_config_file(config_file.to_path_buf(), self.root(), system)?
        } else {
            // The active project root may have been deleted. Start rediscovery from the closest
            // existing ancestor so ty can fall back to an enclosing project.
            let rediscovery_path = self
                .root()
                .ancestors()
                .find(|path| system.is_directory(path))
                .unwrap_or_else(|| self.root());
            Self::discover(rediscovery_path, system)?
        };

        metadata.override_options.clone_from(&self.override_options);
        metadata.fallback_options.clone_from(&self.fallback_options);

        Ok(metadata)
    }

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

    pub fn name(&self) -> &str {
        self.name.as_str()
    }

    pub fn options(&self) -> &Options {
        &self.options
    }

    /// Returns the explicit configuration file that replaces normal project discovery, if any.
    pub(crate) fn config_file_override(&self) -> Option<&SystemPath> {
        self.config_file_override.as_deref()
    }

    /// Returns configuration paths outside normal project discovery that should be watched.
    pub fn extra_configuration_paths(&self) -> impl Iterator<Item = &SystemPath> {
        self.config_file_override().into_iter().chain(
            self.user_configuration
                .as_deref()
                .map(|(path, _)| path.as_path()),
        )
    }

    pub(crate) fn try_add_project_root(&self, db: &dyn Db) {
        // This adds a file root for the project itself. This enables
        // tracking of when changes are made to the files in a project
        // at the directory level. At time of writing (2025-07-17),
        // this is used for caching completions for submodules.
        db.files()
            .try_add_root(db, self.root(), FileRootKind::Project);
    }

    /// Applies higher-precedence options to this project.
    ///
    /// Options applied later take precedence over options applied earlier.
    pub fn apply_override_options(&mut self, options: Options) {
        if let Some(existing) = self.override_options.as_mut() {
            let previous = std::mem::replace(existing.as_mut(), options);
            existing.combine_with(previous);
        } else {
            self.override_options = Some(Box::new(options));
        }
    }

    /// Applies lower-precedence options to this project.
    ///
    /// Options applied later take precedence over options applied earlier, but all fallback options
    /// have lower precedence than the raw and user-level options.
    pub fn apply_fallback_options(&mut self, options: Options) {
        if let Some(existing) = self.fallback_options.as_mut() {
            let previous = std::mem::replace(existing.as_mut(), options);
            existing.combine_with(previous);
        } else {
            self.fallback_options = Some(Box::new(options));
        }
    }

    /// Returns the project's option layers from highest to lowest precedence.
    ///
    /// `options` is used as the raw base layer between the override and user-level options.
    /// Layers can be merged by passing them to [`Options::combine_with`] in iterator order:
    ///
    /// ```ignore
    /// let mut merged = Options::default();
    /// for layer in metadata.options_in_precedence_order(metadata.options()) {
    ///     merged.combine_with(layer.clone());
    /// }
    /// ```
    pub(crate) fn options_in_precedence_order<'a>(
        &'a self,
        options: &'a Options,
    ) -> impl Iterator<Item = &'a Options> {
        self.override_options
            .as_deref()
            .into_iter()
            .chain(std::iter::once(options))
            .chain(
                self.user_configuration
                    .as_deref()
                    .map(|(_, options)| options),
            )
            .chain(self.fallback_options.as_deref())
    }

    /// Loads the lower-precedence options from configuration files.
    ///
    /// This includes:
    ///
    /// * The user-level configuration
    pub fn apply_configuration_files(
        &mut self,
        system: &dyn System,
    ) -> Result<(), ConfigurationFileError> {
        self.user_configuration = None;

        if let Some(user) = ConfigurationFile::user(system)? {
            tracing::debug!(
                "Applying user-level configuration loaded from `{path}`.",
                path = user.path()
            );
            self.user_configuration = Some(Box::new((user.path().to_owned(), user.into_options())));
        }

        Ok(())
    }

    /// Returns all option layers merged according to their precedence.
    pub fn to_merged_options(&self) -> MergedOptions<'_> {
        let mut options = Options::default();

        for layer in self.options_in_precedence_order(&self.options) {
            options.combine_with(layer.clone());
        }

        MergedOptions {
            metadata: self,
            options,
        }
    }
}

/// The merged options for a project and the metadata needed to resolve them.
pub struct MergedOptions<'a> {
    metadata: &'a ProjectMetadata,
    options: Options,
}

impl MergedOptions<'_> {
    /// Returns the merged raw options.
    pub fn options(&self) -> &Options {
        &self.options
    }

    pub fn to_program_settings<Strategy: MisconfigurationStrategy>(
        &self,
        system: &dyn System,
        vendored: &VendoredFileSystem,
        strategy: &Strategy,
    ) -> Result<(ProgramSettings, Vec<ProgramSettingsDiagnostic>), Strategy::Error<anyhow::Error>>
    {
        self.options.to_program_settings(
            self.metadata.root(),
            self.metadata.name(),
            system,
            vendored,
            strategy,
        )
    }

    pub fn to_settings<Strategy: MisconfigurationStrategy>(
        &self,
        db: &dyn Db,
        strategy: &Strategy,
    ) -> Result<(Settings, Vec<OptionDiagnostic>), Strategy::Error<ToSettingsError>> {
        self.options.to_settings(db, self.metadata.root(), strategy)
    }
}

#[derive(Debug, Clone, PartialEq, Eq, get_size2::GetSize)]
#[cfg_attr(test, derive(serde::Serialize))]
struct ProjectName(CompactString);

impl ProjectName {
    fn new(name: impl AsRef<str>) -> Self {
        Self(CompactString::new(name))
    }

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

#[derive(Debug, Error)]
pub enum ProjectMetadataError {
    #[error("project path '{0}' is not a directory")]
    NotADirectory(SystemPathBuf),

    #[error("{path} is not a valid `pyproject.toml`")]
    InvalidPyProject {
        source: Box<PyProjectError>,
        path: SystemPathBuf,
    },

    #[error("{path} is not a valid `ty.toml`")]
    InvalidTyToml {
        source: Box<TyTomlError>,
        path: SystemPathBuf,
    },

    #[error("Invalid `requires-python` version specifier (`{path}`)")]
    InvalidRequiresPythonConstraint {
        source: ResolveRequiresPythonError,
        path: SystemPathBuf,
    },

    #[error("Error loading configuration file at {path}")]
    ConfigurationFileError {
        source: Box<ConfigurationFileError>,
        path: SystemPathBuf,
    },
}

#[cfg(test)]
mod tests {
    //! Integration tests for project discovery

    use anyhow::{Context, anyhow};
    use insta::assert_ron_snapshot;
    use ruff_db::system::{SystemPathBuf, TestSystem};
    use ruff_python_ast::PythonVersion;

    use crate::{ProjectMetadata, ProjectMetadataError};

    #[test]
    fn project_without_pyproject() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_files_all([(root.join("foo.py"), ""), (root.join("bar.py"), "")])
            .context("Failed to write files")?;

        let project =
            ProjectMetadata::discover(&root, &system).context("Failed to discover project")?;

        assert_eq!(project.root(), &*root);

        with_escaped_paths(|| {
            assert_ron_snapshot!(&project, @r#"
            ProjectMetadata(
              name: ProjectName("app"),
              root: "/app",
              options: Options(),
            )
            "#);
        });

        Ok(())
    }

    #[test]
    fn project_with_pyproject() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_files_all([
                (
                    root.join("pyproject.toml"),
                    r#"
                    [project]
                    name = "backend"

                    "#,
                ),
                (root.join("db/__init__.py"), ""),
            ])
            .context("Failed to write files")?;

        let project =
            ProjectMetadata::discover(&root, &system).context("Failed to discover project")?;

        assert_eq!(project.root(), &*root);

        with_escaped_paths(|| {
            assert_ron_snapshot!(&project, @r#"
            ProjectMetadata(
              name: ProjectName("backend"),
              root: "/app",
              options: Options(),
            )
            "#);
        });

        // Discovering the same package from a subdirectory should give the same result
        let from_src = ProjectMetadata::discover(&root.join("db"), &system)
            .context("Failed to discover project from src sub-directory")?;

        assert_eq!(from_src, project);

        Ok(())
    }

    #[test]
    fn project_with_invalid_pyproject() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_files_all([
                (
                    root.join("pyproject.toml"),
                    r#"
                    [project]
                    name = "backend"

                    [tool.ty
                    "#,
                ),
                (root.join("db/__init__.py"), ""),
            ])
            .context("Failed to write files")?;

        let Err(error) = ProjectMetadata::discover(&root, &system) else {
            return Err(anyhow!(
                "Expected project discovery to fail because of invalid syntax in the pyproject.toml"
            ));
        };

        assert_error_chain_eq(
            error,
            r#"/app/pyproject.toml is not a valid `pyproject.toml`: TOML parse error at line 5, column 29
  |
5 |                     [tool.ty
  |                             ^
unclosed table, expected `]`
"#,
        );

        Ok(())
    }

    #[test]
    fn nested_projects_in_sub_project() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_files_all([
                (
                    root.join("pyproject.toml"),
                    r#"
                    [project]
                    name = "project-root"

                    [tool.ty.src]
                    root = "src"
                    "#,
                ),
                (
                    root.join("packages/a/pyproject.toml"),
                    r#"
                    [project]
                    name = "nested-project"

                    [tool.ty.src]
                    root = "src"
                    "#,
                ),
            ])
            .context("Failed to write files")?;

        let sub_project = ProjectMetadata::discover(&root.join("packages/a"), &system)?;

        with_escaped_paths(|| {
            assert_ron_snapshot!(sub_project, @r#"
            ProjectMetadata(
              name: ProjectName("nested-project"),
              root: "/app/packages/a",
              options: Options(
                src: Some(SrcOptions(
                  root: Some("src"),
                )),
              ),
            )
            "#);
        });

        Ok(())
    }

    #[test]
    fn nested_projects_in_root_project() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_files_all([
                (
                    root.join("pyproject.toml"),
                    r#"
                    [project]
                    name = "project-root"

                    [tool.ty.src]
                    root = "src"
                    "#,
                ),
                (
                    root.join("packages/a/pyproject.toml"),
                    r#"
                    [project]
                    name = "nested-project"

                    [tool.ty.src]
                    root = "src"
                    "#,
                ),
            ])
            .context("Failed to write files")?;

        let root = ProjectMetadata::discover(&root, &system)?;

        with_escaped_paths(|| {
            assert_ron_snapshot!(root, @r#"
            ProjectMetadata(
              name: ProjectName("project-root"),
              root: "/app",
              options: Options(
                src: Some(SrcOptions(
                  root: Some("src"),
                )),
              ),
            )
            "#);
        });

        Ok(())
    }

    #[test]
    fn nested_projects_without_ty_sections() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_files_all([
                (
                    root.join("pyproject.toml"),
                    r#"
                    [project]
                    name = "project-root"
                    "#,
                ),
                (
                    root.join("packages/a/pyproject.toml"),
                    r#"
                    [project]
                    name = "nested-project"
                    "#,
                ),
            ])
            .context("Failed to write files")?;

        let sub_project = ProjectMetadata::discover(&root.join("packages/a"), &system)?;

        with_escaped_paths(|| {
            assert_ron_snapshot!(sub_project, @r#"
            ProjectMetadata(
              name: ProjectName("nested-project"),
              root: "/app/packages/a",
              options: Options(),
            )
            "#);
        });

        Ok(())
    }

    #[test]
    fn nested_projects_with_outer_ty_section() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_files_all([
                (
                    root.join("pyproject.toml"),
                    r#"
                    [project]
                    name = "project-root"

                    [tool.ty.environment]
                    python-version = "3.10"
                    "#,
                ),
                (
                    root.join("packages/a/pyproject.toml"),
                    r#"
                    [project]
                    name = "nested-project"
                    "#,
                ),
            ])
            .context("Failed to write files")?;

        let root = ProjectMetadata::discover(&root.join("packages/a"), &system)?;

        with_escaped_paths(|| {
            assert_ron_snapshot!(root, @r#"
            ProjectMetadata(
              name: ProjectName("project-root"),
              root: "/app",
              options: Options(
                environment: Some(EnvironmentOptions(
                  r#python-version: Some(r#3.10),
                )),
              ),
            )
            "#);
        });

        Ok(())
    }

    /// A `ty.toml` takes precedence over any `pyproject.toml`.
    ///
    /// However, the `pyproject.toml` is still loaded to get the project name and, in the future,
    /// the requires-python constraint.
    #[test]
    fn project_with_ty_and_pyproject_toml() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_files_all([
                (
                    root.join("pyproject.toml"),
                    r#"
                    [project]
                    name = "super-app"
                    requires-python = ">=3.12"

                    [tool.ty.src]
                    root = "this_option_is_ignored"
                    "#,
                ),
                (
                    root.join("ty.toml"),
                    r#"
                    [src]
                    root = "src"
                    "#,
                ),
            ])
            .context("Failed to write files")?;

        let root = ProjectMetadata::discover(&root, &system)?;

        with_escaped_paths(|| {
            assert_ron_snapshot!(root, @r#"
            ProjectMetadata(
              name: ProjectName("super-app"),
              root: "/app",
              options: Options(
                environment: Some(EnvironmentOptions(
                  r#python-version: Some(r#3.12),
                )),
                src: Some(SrcOptions(
                  root: Some("src"),
                )),
              ),
            )
            "#);
        });

        Ok(())
    }
    #[test]
    fn requires_python_major_minor() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_file_all(
                root.join("pyproject.toml"),
                r#"
                [project]
                requires-python = ">=3.12"
                "#,
            )
            .context("Failed to write file")?;

        let root = ProjectMetadata::discover(&root, &system)?;

        assert_eq!(
            root.options
                .environment
                .unwrap_or_default()
                .python_version
                .as_deref()
                .copied()
                .map(PythonVersion::from),
            Some(PythonVersion::PY312)
        );

        Ok(())
    }

    #[test]
    fn requires_python_major_only() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_file_all(
                root.join("pyproject.toml"),
                r#"
                [project]
                requires-python = ">=3"
                "#,
            )
            .context("Failed to write file")?;

        let root = ProjectMetadata::discover(&root, &system)?;

        assert_eq!(
            root.options
                .environment
                .unwrap_or_default()
                .python_version
                .as_deref()
                .copied()
                .map(PythonVersion::from),
            Some(PythonVersion::PY37)
        );

        Ok(())
    }

    /// A `requires-python` constraint with major, minor and patch can be simplified
    /// to major and minor (e.g. 3.12.1 -> 3.12).
    #[test]
    fn requires_python_major_minor_patch() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_file_all(
                root.join("pyproject.toml"),
                r#"
                [project]
                requires-python = ">=3.12.8"
                "#,
            )
            .context("Failed to write file")?;

        let root = ProjectMetadata::discover(&root, &system)?;

        assert_eq!(
            root.options
                .environment
                .unwrap_or_default()
                .python_version
                .as_deref()
                .copied()
                .map(PythonVersion::from),
            Some(PythonVersion::PY312)
        );

        Ok(())
    }

    #[test]
    fn requires_python_beta_version() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_file_all(
                root.join("pyproject.toml"),
                r#"
                [project]
                requires-python = ">= 3.13.0b0"
                "#,
            )
            .context("Failed to write file")?;

        let root = ProjectMetadata::discover(&root, &system)?;

        assert_eq!(
            root.options
                .environment
                .unwrap_or_default()
                .python_version
                .as_deref()
                .copied()
                .map(PythonVersion::from),
            Some(PythonVersion::PY313)
        );

        Ok(())
    }

    #[test]
    fn requires_python_greater_than_major_minor() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_file_all(
                root.join("pyproject.toml"),
                r#"
                [project]
                # This is somewhat nonsensical because 3.12.1 > 3.12 is true.
                # That's why simplifying the constraint to >= 3.12 is correct
                requires-python = ">3.12"
                "#,
            )
            .context("Failed to write file")?;

        let root = ProjectMetadata::discover(&root, &system)?;

        assert_eq!(
            root.options
                .environment
                .unwrap_or_default()
                .python_version
                .as_deref()
                .copied()
                .map(PythonVersion::from),
            Some(PythonVersion::PY312)
        );

        Ok(())
    }

    /// `python-version` takes precedence if both `requires-python` and `python-version` are configured.
    #[test]
    fn requires_python_and_python_version() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_file_all(
                root.join("pyproject.toml"),
                r#"
                [project]
                requires-python = ">=3.12"

                [tool.ty.environment]
                python-version = "3.10"
                "#,
            )
            .context("Failed to write file")?;

        let root = ProjectMetadata::discover(&root, &system)?;

        assert_eq!(
            root.options
                .environment
                .unwrap_or_default()
                .python_version
                .as_deref()
                .copied()
                .map(PythonVersion::from),
            Some(PythonVersion::PY310)
        );

        Ok(())
    }

    #[test]
    fn requires_python_less_than() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_file_all(
                root.join("pyproject.toml"),
                r#"
                [project]
                requires-python = "<3.12"
                "#,
            )
            .context("Failed to write file")?;

        let Err(error) = ProjectMetadata::discover(&root, &system) else {
            return Err(anyhow!(
                "Expected project discovery to fail because the `requires-python` doesn't specify a lower bound (it only specifies an upper bound)."
            ));
        };

        assert_error_chain_eq(
            error,
            "Invalid `requires-python` version specifier (`/app/pyproject.toml`): value `<3.12` does not contain a lower bound. Add a lower bound to indicate the minimum compatible Python version (e.g., `>=3.13`) or specify a version in `environment.python-version`.",
        );

        Ok(())
    }

    #[test]
    fn requires_python_no_specifiers() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_file_all(
                root.join("pyproject.toml"),
                r#"
                [project]
                requires-python = ""
                "#,
            )
            .context("Failed to write file")?;

        let Err(error) = ProjectMetadata::discover(&root, &system) else {
            return Err(anyhow!(
                "Expected project discovery to fail because the `requires-python` specifiers are empty and don't define a lower bound."
            ));
        };

        assert_error_chain_eq(
            error,
            "Invalid `requires-python` version specifier (`/app/pyproject.toml`): value `` does not contain a lower bound. Add a lower bound to indicate the minimum compatible Python version (e.g., `>=3.13`) or specify a version in `environment.python-version`.",
        );

        Ok(())
    }

    #[test]
    fn requires_python_too_large_major_version() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_file_all(
                root.join("pyproject.toml"),
                r#"
                [project]
                requires-python = ">=999.0"
                "#,
            )
            .context("Failed to write file")?;

        let Err(error) = ProjectMetadata::discover(&root, &system) else {
            return Err(anyhow!(
                "Expected project discovery to fail because of the requires-python major version that is larger than 255."
            ));
        };

        assert_error_chain_eq(
            error,
            "Invalid `requires-python` version specifier (`/app/pyproject.toml`): The major version `999` is larger than the maximum supported value 255",
        );

        Ok(())
    }

    #[test]
    fn requires_python_old_version_uses_lowest_supported_version() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_file_all(
                root.join("pyproject.toml"),
                r#"
                [project]
                requires-python = "==2.7"
                "#,
            )
            .context("Failed to write file")?;

        let root = ProjectMetadata::discover(&root, &system)?;

        assert_eq!(
            root.options
                .environment
                .unwrap_or_default()
                .python_version
                .as_deref()
                .copied()
                .map(PythonVersion::from),
            Some(PythonVersion::PY37)
        );

        Ok(())
    }

    #[test]
    fn requires_python_unsupported_future_version() -> anyhow::Result<()> {
        let system = TestSystem::default();
        let root = SystemPathBuf::from("/app");

        system
            .memory_file_system()
            .write_file_all(
                root.join("pyproject.toml"),
                r#"
                [project]
                requires-python = "==44.44"
                "#,
            )
            .context("Failed to write file")?;

        let Err(error) = ProjectMetadata::discover(&root, &system) else {
            return Err(anyhow!(
                "Expected project discovery to fail because `requires-python` does not include a ty-supported version."
            ));
        };

        assert_error_chain_eq(
            error,
            "Invalid `requires-python` version specifier (`/app/pyproject.toml`): value `==44.44` does not include any Python version supported by ty. Adjust `requires-python` to include a supported Python 3 version or specify `environment.python-version` explicitly.",
        );

        Ok(())
    }

    #[track_caller]
    fn assert_error_chain_eq(error: ProjectMetadataError, message: &str) {
        let error = anyhow::Error::new(error);
        assert_eq!(format!("{error:#}").replace('\\', "/"), message);
    }

    fn with_escaped_paths<R>(f: impl FnOnce() -> R) -> R {
        let mut settings = insta::Settings::clone_current();
        settings.add_dynamic_redaction(".root", |content, _path| {
            content.as_str().unwrap().replace('\\', "/")
        });

        settings.bind(f)
    }
}
