use std::{
    collections::{BTreeSet, HashMap},
    path::Path,
    str::FromStr,
    sync::Arc,
};

use pypi_mapping::{self, CustomMapping, MappingLocation, MappingSource, PurlSource};
use rattler_conda_types::{PackageName, Platform, RepoDataRecord};
use rattler_lock::DEFAULT_ENVIRONMENT_NAME;
use reqwest_middleware::ClientBuilder;
use tempfile::TempDir;
use url::Url;

use crate::common::{
    LockFileExt, PixiControl,
    builders::HasDependencyConfig,
    client::OfflineMiddleware,
    pypi_index::{Database as PyPIDatabase, PyPIPackage},
};
use crate::setup_tracing;
use pixi_test_utils::{MockRepoData, Package};

#[tokio::test]
async fn conda_solve_group_functionality() {
    setup_tracing();

    let mut package_database = MockRepoData::default();

    // Add a package `foo` with 3 different versions
    package_database.add_package(Package::build("foo", "1").finish());
    package_database.add_package(Package::build("foo", "2").finish());
    package_database.add_package(Package::build("foo", "3").finish());

    // Add a package `bar` with 1 version that restricts `foo` to version 2 or
    // lower.
    package_database.add_package(
        Package::build("bar", "1")
            .with_dependency("foo <3")
            .finish(),
    );

    // Write the repodata to disk
    let channel_dir = TempDir::new().unwrap();
    package_database
        .write_repodata(channel_dir.path())
        .await
        .unwrap();

    let channel = Url::from_file_path(channel_dir.path()).unwrap();
    let platform = Platform::current();
    let pixi = PixiControl::from_manifest(&format!(
        r#"
    [project]
    name = "test-solve-group"
    channels = ["{channel}"]
    platforms = ["{platform}"]

    [dependencies]
    foo = "*"

    [feature.test.dependencies]
    bar = "*"

    [environments]
    prod = {{ solve-group = "prod" }}
    test = {{ features=["test"], solve-group = "prod" }}
    "#
    ))
    .unwrap();

    // Get an up-to-date lockfile
    let lock_file = pixi.update_lock_file().await.unwrap();

    assert!(
        lock_file.contains_match_spec("default", platform, "foo ==3"),
        "default should have the highest version of foo"
    );
    assert!(
        !lock_file.contains_match_spec("default", platform, "bar"),
        "default should not contain bar"
    );

    assert!(
        lock_file.contains_match_spec("prod", platform, "foo ==2"),
        "prod should have foo==2 because it shares the solve group with test"
    );
    assert!(
        !lock_file.contains_match_spec("prod", platform, "bar"),
        "prod should not contain bar"
    );

    assert!(
        lock_file.contains_match_spec("test", platform, "foo ==2"),
        "test should have foo==2 because bar depends on foo <3"
    );
    assert!(
        lock_file.contains_match_spec("test", platform, "bar"),
        "test should contain bar"
    );
}

#[tokio::test]
async fn conda_solve_group_heterogeneous_platforms() {
    setup_tracing();

    let mut package_database = MockRepoData::default();

    // Add `foo` available on both linux-64 and win-64
    package_database.add_package(
        Package::build("foo", "1")
            .with_subdir(Platform::Linux64)
            .finish(),
    );
    package_database.add_package(
        Package::build("foo", "1")
            .with_subdir(Platform::Win64)
            .finish(),
    );

    // Add `bar` available only on linux-64
    package_database.add_package(
        Package::build("bar", "1")
            .with_subdir(Platform::Linux64)
            .finish(),
    );

    // Write the repodata to disk
    let channel_dir = TempDir::new().unwrap();
    package_database
        .write_repodata(channel_dir.path())
        .await
        .unwrap();

    let channel = Url::from_file_path(channel_dir.path()).unwrap();

    // The `linux-only` feature restricts to linux-64 and adds `bar`.
    let pixi = PixiControl::from_manifest(&format!(
        r#"
    [project]
    name = "test-heterogeneous-platforms"
    channels = ["{channel}"]
    platforms = ["linux-64", "win-64"]

    [dependencies]
    foo = "*"

    [feature.linux-only]
    platforms = ["linux-64"]

    [feature.linux-only.dependencies]
    bar = "*"

    [environments]
    full = {{ solve-group = "group1" }}
    restricted = {{ features = ["linux-only"], solve-group = "group1" }}
    "#
    ))
    .unwrap();

    // Solving should succeed for both platforms.
    let lock_file = pixi.update_lock_file().await.unwrap();

    // `full` environment: has `foo` on both platforms, no `bar`
    assert!(
        lock_file.contains_match_spec("full", Platform::Linux64, "foo ==1"),
        "full/linux-64 should have foo"
    );
    assert!(
        lock_file.contains_match_spec("full", Platform::Win64, "foo ==1"),
        "full/win-64 should have foo"
    );
    assert!(
        !lock_file.contains_conda_package("full", Platform::Win64, "bar"),
        "full/win-64 should not have bar"
    );
    assert!(
        !lock_file.contains_conda_package("full", Platform::Linux64, "bar"),
        "full/linux-64 should not have bar"
    );

    // `restricted` environment: only supports linux-64, should have both foo and bar
    assert!(
        lock_file.contains_match_spec("restricted", Platform::Linux64, "foo ==1"),
        "restricted/linux-64 should have foo"
    );
    assert!(
        lock_file.contains_match_spec("restricted", Platform::Linux64, "bar ==1"),
        "restricted/linux-64 should have bar"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[cfg_attr(
    any(not(feature = "online_tests"), not(feature = "slow_integration_tests")),
    ignore
)]
async fn test_purl_are_added_for_pypi() {
    setup_tracing();

    let pixi = PixiControl::new().unwrap();
    pixi.init().await.unwrap();
    // Add and update lockfile with this version of python
    pixi.add("boltons").await.unwrap();
    let lock_file = pixi.update_lock_file().await.unwrap();

    // Check if boltons has a purl
    lock_file
        .default_environment()
        .unwrap()
        .packages(Platform::current())
        .unwrap()
        .for_each(|dep| {
            if dep.as_conda().unwrap().record().name == PackageName::from_str("boltons").unwrap() {
                assert!(dep.as_conda().unwrap().record().purls.is_none());
            }
        });

    // Add boltons from pypi
    pixi.add("boltons")
        .set_type(pixi_core::DependencyType::PypiDependency)
        .await
        .unwrap();

    let lock_file = pixi.update_lock_file().await.unwrap();

    // Check if boltons has a purl
    lock_file
        .default_environment()
        .unwrap()
        .packages(Platform::current())
        .unwrap()
        .for_each(|dep| {
            if dep.as_conda().unwrap().record().name == PackageName::from_str("boltons").unwrap() {
                assert_eq!(
                    dep.as_conda()
                        .unwrap()
                        .record()
                        .purls
                        .as_ref()
                        .unwrap()
                        .first()
                        .unwrap()
                        .qualifiers()
                        .get("source")
                        .unwrap(),
                    PurlSource::HashMapping.as_str()
                );
            }
        });

    // Check if boltons exists only as conda dependency
    assert!(lock_file.contains_match_spec(
        DEFAULT_ENVIRONMENT_NAME,
        Platform::current(),
        "boltons"
    ));
    assert!(!lock_file.contains_pypi_package(
        DEFAULT_ENVIRONMENT_NAME,
        Platform::current(),
        "boltons"
    ));
}

#[tokio::test]
#[cfg_attr(not(feature = "online_tests"), ignore)]
async fn test_purl_are_missing_for_non_conda_forge() {
    setup_tracing();

    let pixi = PixiControl::new().unwrap();
    pixi.init().await.unwrap();

    let project = pixi.workspace().unwrap();
    let client = project.authenticated_client().unwrap();
    let foo_bar_package = Package::build("foo-bar-car", "2").finish();

    let mut repo_data_record = RepoDataRecord {
        identifier: foo_bar_package.identifier(),
        package_record: foo_bar_package.package_record,
        url: Url::parse("https://pypi.org/simple/boltons/").unwrap(),
        channel: Some("dummy-channel".to_owned()),
    };

    let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish();
    mapping_client
        .amend_purls(&MappingSource::Prefix, vec![&mut repo_data_record], None)
        .await
        .unwrap();

    // Because foo-bar-car is not from conda-forge channel
    // We verify that purls are missing for non-conda-forge packages
    assert!(
        repo_data_record
            .package_record
            .purls
            .as_ref()
            .and_then(BTreeSet::first)
            .is_none()
    );
}

#[tokio::test]
async fn test_purl_are_generated_using_custom_mapping() {
    setup_tracing();

    let pixi = PixiControl::new().unwrap();
    pixi.init().await.unwrap();

    let project = pixi.workspace().unwrap();
    let client = project.authenticated_client().unwrap();
    let foo_bar_package = Package::build("foo-bar-car", "2").finish();

    let mut repo_data_record = RepoDataRecord {
        identifier: foo_bar_package.identifier(),
        package_record: foo_bar_package.package_record,
        url: Url::parse("https://pypi.org/simple/boltons/").unwrap(),
        channel: Some("https://conda.anaconda.org/conda-forge/".to_owned()),
    };

    // We are using custom mapping
    let compressed_mapping =
        HashMap::from([("foo-bar-car".to_owned(), Some("my-test-name".to_owned()))]);
    let source = HashMap::from([(
        "https://conda.anaconda.org/conda-forge".to_owned(),
        MappingLocation::Memory(compressed_mapping),
    )]);

    let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish();
    mapping_client
        .amend_purls(
            &MappingSource::Custom(Arc::new(CustomMapping::new(source))),
            vec![&mut repo_data_record],
            None,
        )
        .await
        .unwrap();

    let first_purl = repo_data_record
        .package_record
        .purls
        .as_ref()
        .and_then(BTreeSet::first)
        .unwrap();

    // We verify that `my-test-name` is used for `foo-bar-car` package
    assert_eq!(first_purl.name(), "my-test-name")
}

#[tokio::test]
#[cfg_attr(not(feature = "online_tests"), ignore)]
async fn test_compressed_mapping_catch_not_pandoc_not_a_python_package() {
    setup_tracing();

    let pixi = PixiControl::new().unwrap();
    pixi.init().await.unwrap();

    let project = pixi.workspace().unwrap();
    let client = project.authenticated_client().unwrap();
    let foo_bar_package = Package::build("pandoc", "2").finish();

    let mut repo_data_record = RepoDataRecord {
        identifier: foo_bar_package.identifier(),
        package_record: foo_bar_package.package_record,
        url: Url::parse("https://haskell.org/pandoc/").unwrap(),
        channel: Some("https://conda.anaconda.org/conda-forge/".to_owned()),
    };

    let packages = vec![&mut repo_data_record];

    let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish();
    mapping_client
        .amend_purls(&MappingSource::Prefix, packages, None)
        .await
        .unwrap();

    // pandoc is not a python package
    // so purls for it should be empty
    assert!(repo_data_record.package_record.purls.unwrap().is_empty())
}

#[tokio::test]
#[cfg_attr(not(feature = "online_tests"), ignore)]
async fn test_dont_record_not_present_package_as_purl() {
    setup_tracing();

    let pixi = PixiControl::new().unwrap();
    pixi.init().await.unwrap();

    let project = pixi.workspace().unwrap();
    let client = project.authenticated_client().unwrap();
    // We use one package that is present in our mapping: `boltons`
    // and another one that is missing from conda and our mapping:
    // `pixi-something-new-for-test` because `pixi-something-new-for-test` is
    // from conda-forge channel we will anyway record a purl for it
    // by assumption that it's a pypi package
    let foo_bar_package = Package::build("pixi-something-new-for-test", "2").finish();
    // We use one package that is not present by hash
    // but `boltons` name is still present in compressed mapping
    // so we will record a purl for it
    let boltons_package = Package::build("boltons", "99999").finish();

    let mut repo_data_record = RepoDataRecord {
        identifier: foo_bar_package.identifier(),
        package_record: foo_bar_package.package_record,
        url: Url::parse("https://pypi.org/simple/something-new/").unwrap(),
        channel: Some("https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.1.0-py311ha891d26_1.conda".to_owned()),
    };

    let mut boltons_repo_data_record = RepoDataRecord {
        identifier: boltons_package.identifier(),
        package_record: boltons_package.package_record,
        url: Url::parse("https://pypi.org/simple/boltons/").unwrap(),
        channel: Some("https://conda.anaconda.org/conda-forge/".to_owned()),
    };

    let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish();
    mapping_client
        .amend_purls(
            project.pypi_name_mapping_source().unwrap(),
            vec![&mut repo_data_record, &mut boltons_repo_data_record],
            None,
        )
        .await
        .unwrap();

    mapping_client
        .amend_purls(
            project.pypi_name_mapping_source().unwrap(),
            vec![&mut repo_data_record, &mut boltons_repo_data_record],
            None,
        )
        .await
        .unwrap();

    let first_purl = repo_data_record
        .package_record
        .purls
        .as_ref()
        .and_then(BTreeSet::first)
        .unwrap();

    // we verify that even if this name is not present in our mapping
    // we record a purl anyways. Because we make the assumption
    // that it's a pypi package
    assert_eq!(first_purl.name(), "pixi-something-new-for-test");

    let boltons_purl = boltons_repo_data_record
        .package_record
        .purls
        .as_ref()
        .and_then(BTreeSet::first)
        .unwrap();

    // for boltons we have a mapping record
    // so we test that we also record source=conda-forge-mapping qualifier
    assert_eq!(
        boltons_purl.qualifiers().get("source").unwrap(),
        PurlSource::CompressedMapping.as_str()
    );
}

fn absolute_custom_mapping_path() -> String {
    dunce::simplified(
        &Path::new(env!("CARGO_WORKSPACE_DIR"))
            .join("tests/data/mapping_files/custom_mapping.json"),
    )
    .display()
    .to_string()
    .replace("\\", "/")
}

fn absolute_compressed_mapping_path() -> String {
    dunce::simplified(
        &Path::new(env!("CARGO_WORKSPACE_DIR"))
            .join("tests/data/mapping_files/compressed_mapping.json"),
    )
    .display()
    .to_string()
    .replace("\\", "/")
}

#[tokio::test]
async fn test_we_record_not_present_package_as_purl_for_custom_mapping() {
    setup_tracing();

    let pixi = PixiControl::from_manifest(&format!(
        r#"
    [project]
    name = "test-channel-change"
    channels = ["conda-forge"]
    platforms = ["linux-64"]
    conda-pypi-map = {{ 'conda-forge' = "{}" }}
    "#,
        absolute_compressed_mapping_path()
    ))
    .unwrap();

    let project = pixi.workspace().unwrap();

    let client = project.authenticated_client().unwrap();

    // We use one package that is present in our mapping: `boltons`
    // and another one that is missing from conda and our mapping:
    // `pixi-something-new-for-test` because `pixi-something-new-for-test` is
    // from conda-forge channel we will anyway record a purl for it
    // by assumption that it's a pypi package
    // also we are using some custom mapping
    // so we will test for other purl qualifier comparing to
    // `test_dont_record_not_present_package_as_purl` test
    let foo_bar_package = Package::build("pixi-something-new", "2").finish();
    let boltons_package = Package::build("boltons", "2").finish();

    let repo_data_record = RepoDataRecord {
        identifier: foo_bar_package.identifier(),
        package_record: foo_bar_package.package_record,
        url: Url::parse("https://pypi.org/simple/pixi-something-new-new/").unwrap(),
        channel: Some("https://conda.anaconda.org/conda-forge/".to_owned()),
    };

    let boltons_repo_data_record = RepoDataRecord {
        identifier: boltons_package.identifier(),
        package_record: boltons_package.package_record,
        url: Url::parse("https://pypi.org/simple/boltons/").unwrap(),
        channel: Some("https://conda.anaconda.org/conda-forge/".to_owned()),
    };

    let mut packages = vec![repo_data_record, boltons_repo_data_record];

    let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish();
    mapping_client
        .amend_purls(
            project.pypi_name_mapping_source().unwrap(),
            &mut packages,
            None,
        )
        .await
        .unwrap();

    let boltons_package = packages.pop().unwrap();

    let boltons_first_purl = boltons_package
        .package_record
        .purls
        .as_ref()
        .and_then(BTreeSet::first)
        .unwrap();

    println!("{boltons_first_purl}");

    // for boltons we have a mapping record
    // so we test that we also record source=project-defined-mapping qualifier
    assert_eq!(boltons_first_purl.name(), "boltons");
    assert_eq!(
        boltons_first_purl.qualifiers().get("source").unwrap(),
        PurlSource::ProjectDefinedMapping.as_str()
    );

    let package = packages.pop().unwrap();

    // With custom mapping, packages not in the mapping should NOT get purls
    // This verifies that custom mapping is exclusive - only packages explicitly
    // mapped should be considered as pypi packages
    assert!(
        package.package_record.purls.is_none()
            || package.package_record.purls.as_ref().unwrap().is_empty(),
        "pixi-something-new should not have purls when not in custom mapping"
    );
}

#[tokio::test]
async fn test_custom_mapping_channel_with_suffix() {
    setup_tracing();

    let pixi = PixiControl::from_manifest(&format!(
        r#"
     [project]
     name = "test-channel-change"
     channels = ["conda-forge"]
     platforms = ["linux-64"]
     conda-pypi-map = {{ "https://conda.anaconda.org/conda-forge/" = "{}" }}
     "#,
        absolute_custom_mapping_path()
    ))
    .unwrap();

    let project = pixi.workspace().unwrap();

    let client = project.authenticated_client().unwrap();

    let foo_bar_package = Package::build("pixi-something-new", "2").finish();

    let repo_data_record = RepoDataRecord {
        identifier: foo_bar_package.identifier(),
        package_record: foo_bar_package.package_record,
        url: Url::parse("https://pypi.org/simple/pixi-something-new-new/").unwrap(),
        channel: Some("https://conda.anaconda.org/conda-forge".to_owned()),
    };

    let mut packages = vec![repo_data_record];

    let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish();
    mapping_client
        .amend_purls(
            project.pypi_name_mapping_source().unwrap(),
            &mut packages,
            None,
        )
        .await
        .unwrap();

    let package = packages.pop().unwrap();

    assert_eq!(
        package
            .package_record
            .purls
            .as_ref()
            .and_then(BTreeSet::first)
            .unwrap()
            .qualifiers()
            .get("source")
            .unwrap(),
        PurlSource::ProjectDefinedMapping.as_str()
    );
}

#[tokio::test]
async fn test_repo_data_record_channel_with_suffix() {
    setup_tracing();

    let pixi = PixiControl::from_manifest(&format!(
        r#"
     [project]
     name = "test-channel-change"
     channels = ["conda-forge"]
     platforms = ["linux-64"]
     conda-pypi-map = {{ "https://conda.anaconda.org/conda-forge" = "{}" }}
     "#,
        absolute_custom_mapping_path(),
    ))
    .unwrap();

    let project = pixi.workspace().unwrap();

    let client = project.authenticated_client().unwrap();

    let foo_bar_package = Package::build("pixi-something-new", "2").finish();

    let repo_data_record = RepoDataRecord {
        identifier: foo_bar_package.identifier(),
        package_record: foo_bar_package.package_record,
        url: Url::parse("https://pypi.org/simple/pixi-something-new-new/").unwrap(),
        channel: Some("https://conda.anaconda.org/conda-forge/".to_owned()),
    };

    let mut packages = vec![repo_data_record];

    let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish();
    mapping_client
        .amend_purls(
            project.pypi_name_mapping_source().unwrap(),
            &mut packages,
            None,
        )
        .await
        .unwrap();

    let package = packages.pop().unwrap();
    assert_eq!(
        package
            .package_record
            .purls
            .as_ref()
            .and_then(BTreeSet::first)
            .unwrap()
            .qualifiers()
            .get("source")
            .unwrap(),
        PurlSource::ProjectDefinedMapping.as_str()
    );
}

#[tokio::test]
async fn test_path_channel() {
    setup_tracing();

    let pixi = PixiControl::from_manifest(&format!(
        r#"
     [project]
     name = "test-channel-change"
     channels = ["file:///home/user/staged-recipes/build_artifacts"]
     platforms = ["linux-64"]
     conda-pypi-map = {{"file:///home/user/staged-recipes/build_artifacts" = "{}" }}
     "#,
        absolute_custom_mapping_path()
    ))
    .unwrap();

    let project = pixi.workspace().unwrap();

    let client = project.authenticated_client().unwrap();

    let foo_bar_package = Package::build("pixi-something-new", "2").finish();

    let repo_data_record = RepoDataRecord {
        identifier: foo_bar_package.identifier(),
        package_record: foo_bar_package.package_record,
        url: Url::parse("https://pypi.org/simple/pixi-something-new-new/").unwrap(),
        channel: Some("file:///home/user/staged-recipes/build_artifacts".to_owned()),
    };

    let mut packages = vec![repo_data_record];

    let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish();
    mapping_client
        .amend_purls(
            project.pypi_name_mapping_source().unwrap(),
            &mut packages,
            None,
        )
        .await
        .unwrap();

    let package = packages.pop().unwrap();

    assert_eq!(
        package
            .package_record
            .purls
            .as_ref()
            .and_then(BTreeSet::first)
            .unwrap()
            .qualifiers()
            .get("source")
            .unwrap(),
        PurlSource::ProjectDefinedMapping.as_str()
    );
}

#[tokio::test]
async fn test_file_url_as_mapping_location() {
    setup_tracing();

    let tmp_dir = tempfile::tempdir().unwrap();
    let mapping_file = tmp_dir.path().join("custom_mapping.json");

    let _ = fs_err::write(
        &mapping_file,
        r#"
    {
        "pixi-something-new": "pixi-something-old"
    }
    "#,
    );

    let mapping_file_path_as_url = Url::from_file_path(
        mapping_file, /* .canonicalize()
                       * .expect("should be canonicalized"), */
    )
    .unwrap();

    let pixi = PixiControl::from_manifest(
        format!(
            r#"
        [project]
        name = "test-channel-change"
        channels = ["conda-forge"]
        platforms = ["linux-64"]
        conda-pypi-map = {{"conda-forge" = "{}"}}
        "#,
            mapping_file_path_as_url.as_str()
        )
        .as_str(),
    )
    .unwrap();

    let project = pixi.workspace().unwrap();

    let client = project.authenticated_client().unwrap();

    let foo_bar_package = Package::build("pixi-something-new", "2").finish();

    let repo_data_record = RepoDataRecord {
        identifier: foo_bar_package.identifier(),
        package_record: foo_bar_package.package_record,
        url: Url::parse("https://pypi.org/simple/pixi-something-new-new/").unwrap(),
        channel: Some("https://conda.anaconda.org/conda-forge/".to_owned()),
    };

    let mut packages = vec![repo_data_record];

    let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish();
    mapping_client
        .amend_purls(
            project.pypi_name_mapping_source().unwrap(),
            &mut packages,
            None,
        )
        .await
        .unwrap();

    let package = packages.pop().unwrap();

    assert_eq!(
        package
            .package_record
            .purls
            .as_ref()
            .and_then(BTreeSet::first)
            .unwrap()
            .qualifiers()
            .get("source")
            .unwrap(),
        PurlSource::ProjectDefinedMapping.as_str()
    );
}

#[tokio::test]
async fn test_disabled_mapping() {
    setup_tracing();

    let pixi = PixiControl::from_manifest(
        r#"
    [project]
    name = "test-channel-change"
    channels = ["https://prefix.dev/conda-forge"]
    platforms = ["linux-64"]
    conda-pypi-map = { }
    "#,
    )
    .unwrap();

    let project = pixi.workspace().unwrap();

    let client = project.authenticated_client().unwrap();

    let blocking_middleware = OfflineMiddleware;

    let blocked_client = ClientBuilder::from_client(client.client().clone())
        .with(blocking_middleware)
        .build();

    let boltons_package = Package::build("boltons", "2").finish();

    let boltons_repo_data_record = RepoDataRecord {
        identifier: boltons_package.identifier(),
        package_record: boltons_package.package_record,
        url: Url::parse("https://pypi.org/simple/boltons/").unwrap(),
        channel: Some("https://conda.anaconda.org/conda-forge/".to_owned()),
    };

    let mut packages = vec![boltons_repo_data_record];

    let mapping_client = pypi_mapping::MappingClient::builder(blocked_client.into()).finish();
    mapping_client
        .amend_purls(
            project.pypi_name_mapping_source().unwrap(),
            &mut packages,
            None,
        )
        .await
        .unwrap();

    let boltons_package = packages.pop().unwrap();

    let boltons_first_purl = boltons_package
        .package_record
        .purls
        .as_ref()
        .and_then(BTreeSet::first)
        .unwrap();

    // we verify that even if this name is not present in our mapping
    // we record a purl anyways. Because we make the assumption
    // that it's a pypi package
    assert_eq!(boltons_first_purl.name(), "boltons");
    assert!(boltons_first_purl.qualifiers().is_empty());
}

#[tokio::test]
async fn test_custom_mapping_ignores_backwards_compatibility() {
    setup_tracing();

    // Create local conda channel with boltons and python packages
    let mut package_database = MockRepoData::default();
    package_database.add_package(
        Package::build("python", "3.12.0")
            .with_subdir(Platform::Linux64)
            .finish(),
    );
    package_database.add_package(
        Package::build("boltons", "24.0.0")
            .with_subdir(Platform::Linux64)
            .finish(),
    );
    let channel = package_database.into_channel().await.unwrap();
    let channel_url = channel.url();

    // Create local PyPI index with boltons package
    let pypi_index = PyPIDatabase::new()
        .with(PyPIPackage::new("boltons", "24.0.0"))
        .into_simple_index()
        .expect("failed to create local simple index");

    // Create a custom mapping file that only includes specific packages
    let temp_dir = TempDir::new().unwrap();
    let mapping_file = temp_dir.path().join("map.json");
    fs_err::write(&mapping_file, r#"{}"#).unwrap();

    let pixi = PixiControl::from_manifest(&format!(
        r#"
    [workspace]
    name = "test-custom-mapping"
    channels = ["{channel_url}"]
    platforms = ["linux-64"]
    conda-pypi-map = {{ "{channel_url}" = "{mapping_file}" }}

    [dependencies]
    python = "3.12.0"
    boltons = "*"

    [pypi-dependencies]
    boltons = "*"

    [pypi-options]
    index-url = "{pypi_url}"
    "#,
        channel_url = channel_url,
        mapping_file = mapping_file
            .to_str()
            .unwrap()
            .to_string()
            .replace("\\", "/"),
        pypi_url = pypi_index.index_url(),
    ))
    .unwrap();

    // Lock the project (this triggers the amend_purls logic)
    pixi.lock().await.unwrap();

    // Get the lock file
    let lock = pixi.lock_file().await.unwrap();
    let environment = lock.environment(DEFAULT_ENVIRONMENT_NAME).unwrap();
    let conda_packages = environment.conda_packages(Platform::Linux64).unwrap();

    // Collect conda packages to a vector so we can iterate over them
    let conda_packages: Vec<_> = conda_packages.collect();

    // Find boltons in conda packages
    let boltons_package = conda_packages
        .iter()
        .find(|pkg| match pkg {
            rattler_lock::CondaPackageData::Binary(binary) => {
                binary.package_record.name.as_source() == "boltons"
            }
            _ => panic!("All packagees should be binary"),
        })
        .expect("boltons should be present in conda packages");

    // The issue: boltons should NOT have purls when using custom mapping
    // because it's not specified in our custom mapping
    // But due to backwards compatibility logic, it gets purls anyway
    let purls = match boltons_package {
        rattler_lock::CondaPackageData::Binary(binary) => &binary.package_record.purls,
        _ => panic!("All packages should be binary"),
    };

    if let Some(purls) = purls {
        assert!(
            purls.is_empty(),
            "boltons should not have purls when not specified in custom conda-pypi-map"
        );
    }
}

/// Test that environments in a solve-group can have different editability settings
/// for the same path-based PyPI package.
///
/// This test verifies that:
/// - Two environments in the same solve-group can specify the same local package
/// - One environment can have it as editable, the other as non-editable
/// - The lock file stores editable=false for both (editability is looked up from manifest at install time)
///
/// Note: With the new architecture, the lock file always stores `editable=false` (omitted in JSON).
/// The actual editability is determined from the manifest at install time, which allows different
/// environments in a solve-group to have different editability settings without affecting the lock file.
#[tokio::test]
async fn test_solve_group_per_environment_editability() {
    setup_tracing();

    // Create a fake channel with Python
    let mut package_database = MockRepoData::default();
    package_database.add_package(Package::build("python", "3.10.0").finish());

    let channel_dir = TempDir::new().unwrap();
    package_database
        .write_repodata(channel_dir.path())
        .await
        .unwrap();

    let channel = Url::from_file_path(channel_dir.path()).unwrap();
    let platform = Platform::current();

    let pixi = PixiControl::from_manifest(&format!(
        r#"
    [project]
    name = "test-editability"
    channels = ["{channel}"]
    platforms = ["{platform}"]
conda-pypi-map = {{}} # disable mapping

    [dependencies]
    python = "*"

    [feature.prod.pypi-dependencies]
    # Non-editable in prod
    my-local-pkg = {{ path = "./my-local-pkg", editable = false }}

    [feature.dev.pypi-dependencies]
    # Editable in dev
    my-local-pkg = {{ path = "./my-local-pkg", editable = true }}

    [environments]
    prod = {{ features = ["prod"], solve-group = "default" }}
    dev = {{ features = ["dev"], solve-group = "default" }}
    "#
    ))
    .unwrap();

    // Create the local package directory structure
    let project_path = pixi.workspace_path();
    let pkg_dir = project_path.join("my-local-pkg");
    fs_err::create_dir_all(&pkg_dir).unwrap();

    // Create a minimal pyproject.toml for the local package (using setuptools which is simpler)
    fs_err::write(
        pkg_dir.join("pyproject.toml"),
        r#"
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[project]
name = "my-local-pkg"
version = "0.1.0"
"#,
    )
    .unwrap();

    // Create the package source
    let src_dir = pkg_dir.join("my_local_pkg");
    fs_err::create_dir_all(&src_dir).unwrap();
    fs_err::write(src_dir.join("__init__.py"), "").unwrap();

    // Lock the project
    let lock_file = pixi.update_lock_file().await.unwrap();

    // Verify the package is present in both environments
    assert!(
        lock_file.contains_pypi_package("prod", platform, "my-local-pkg"),
        "prod environment should contain my-local-pkg"
    );
    assert!(
        lock_file.contains_pypi_package("dev", platform, "my-local-pkg"),
        "dev environment should contain my-local-pkg"
    );

    // With the new architecture, the lock file always stores editable=false
    // The actual editability is determined from the manifest at install time
    let prod_editable = lock_file
        .is_pypi_package_editable("prod", platform, "my-local-pkg")
        .expect("should find my-local-pkg in prod");
    let dev_editable = lock_file
        .is_pypi_package_editable("dev", platform, "my-local-pkg")
        .expect("should find my-local-pkg in dev");

    // Both should have editable=false in the lock file
    // The actual editability is applied at install time based on the manifest
    assert!(
        !prod_editable,
        "prod environment should have my-local-pkg with editable=false in lock file, but got editable={prod_editable}",
    );
    assert!(
        !dev_editable,
        "dev environment should have my-local-pkg with editable=false in lock file, but got editable={dev_editable}",
    );

    // The key benefit of this architecture is that changing editability in the manifest
    // does NOT require re-locking - only re-installing. Both environments share the same
    // lock file entry but can have different editability at install time.
}

#[tokio::test]
async fn test_missing_mapping_file_error_includes_path() {
    setup_tracing();

    let pixi = PixiControl::new().unwrap();
    pixi.init().await.unwrap();

    let project = pixi.workspace().unwrap();
    let client = project.authenticated_client().unwrap();

    // Use a non-existent file path for the custom mapping
    let non_existent_path = Path::new("/this/path/does/not/exist/mapping.json");

    let source = HashMap::from([(
        "https://conda.anaconda.org/conda-forge".to_owned(),
        MappingLocation::Path(non_existent_path.to_path_buf()),
    )]);

    let foo_bar_package = Package::build("foo-bar-car", "2").finish();

    let mut repo_data_record = RepoDataRecord {
        identifier: foo_bar_package.identifier(),
        package_record: foo_bar_package.package_record,
        url: Url::parse("https://pypi.org/simple/boltons/").unwrap(),
        channel: Some("https://conda.anaconda.org/conda-forge/".to_owned()),
    };

    let mapping_client = pypi_mapping::MappingClient::builder(client.clone()).finish();
    let result = mapping_client
        .amend_purls(
            &MappingSource::Custom(Arc::new(CustomMapping::new(source))),
            vec![&mut repo_data_record],
            None,
        )
        .await;

    // The operation should fail because the mapping file doesn't exist
    let err = result.expect_err("Expected an error when mapping file doesn't exist");
    insta::with_settings!({filters => vec![
        (r#"path: "([^"]+)""#, "[MAPPING_PATH]"),
        (r#"message: "[^"]+""#, "[MAPPING_MESSAGE]"),
        (r#"\bcode:\s*\d+\b"#, "[MAPPING_CODE]"),
    ]}, {
        insta::assert_debug_snapshot!(err);
    });
}
