# Copyright Spack Project Developers. See COPYRIGHT file for details.
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
"""Test environment internals without CLI"""
import filecmp
import os
import pathlib
import pickle

import pytest

import spack.config
import spack.environment as ev
import spack.llnl.util.filesystem as fs
import spack.platforms
import spack.solver.asp
import spack.spec
from spack.environment.environment import (
    EnvironmentManifestFile,
    SpackEnvironmentViewError,
    _error_on_nonempty_view_dir,
)
from spack.environment.list import UndefinedReferenceError

pytestmark = [
    pytest.mark.not_on_windows("Envs are not supported on windows"),
    pytest.mark.usefixtures("mock_packages"),
]


class TestDirectoryInitialization:
    def test_environment_dir_from_name(self, mutable_mock_env_path):
        """Test the function mapping a managed environment name to its folder."""
        env = ev.create("test")
        environment_dir = ev.environment_dir_from_name("test")
        assert env.path == environment_dir
        with pytest.raises(ev.SpackEnvironmentError, match="environment already exists"):
            ev.environment_dir_from_name("test", exists_ok=False)

    def test_environment_dir_from_nested_name(self, mutable_mock_env_path):
        """Test the function mapping a nested managed environment name to its folder."""
        env = ev.create("group/test")
        environment_dir = ev.environment_dir_from_name("group/test")
        assert env.path == environment_dir
        with pytest.raises(ev.SpackEnvironmentError, match="environment already exists"):
            ev.environment_dir_from_name("group/test", exists_ok=False)


def test_hash_change_no_rehash_concrete(tmp_path: pathlib.Path, config):
    # create an environment
    env_path = tmp_path / "env_dir"
    env_path.mkdir(exist_ok=False)
    env = ev.create_in_dir(env_path)
    env.write()

    # add a spec with a rewritten build hash
    spec = spack.spec.Spec("mpileaks")
    env.add(spec)
    env.concretize()

    # rewrite the hash
    old_hash = env.concretized_order[0]
    new_hash = "abc"
    env.specs_by_hash[old_hash]._hash = new_hash  # type: ignore[attr-defined]
    env.concretized_order[0] = new_hash
    env.specs_by_hash[new_hash] = env.specs_by_hash[old_hash]
    del env.specs_by_hash[old_hash]
    env.write()

    # Read environment
    read_in = ev.Environment(env_path)

    # Ensure read hashes are used (rewritten hash seen on read)
    assert read_in.concretized_order
    assert read_in.concretized_order[0] in read_in.specs_by_hash
    _hash = read_in.specs_by_hash[read_in.concretized_order[0]]._hash  # type: ignore[attr-defined]
    assert _hash == new_hash


def test_env_change_spec(tmp_path: pathlib.Path, config):
    env_path = tmp_path / "env_dir"
    env_path.mkdir(exist_ok=False)
    env = ev.create_in_dir(env_path)
    env.write()

    spec = spack.spec.Spec("mpileaks@2.1~shared+debug")
    env.add(spec)
    env.write()

    change_spec = spack.spec.Spec("mpileaks@2.2")
    env.change_existing_spec(change_spec)
    (spec,) = env.added_specs()
    assert spec == spack.spec.Spec("mpileaks@2.2~shared+debug")

    change_spec = spack.spec.Spec("mpileaks~debug")
    env.change_existing_spec(change_spec)
    (spec,) = env.added_specs()
    assert spec == spack.spec.Spec("mpileaks@2.2~shared~debug")


_test_matrix_yaml = """\
spack:
  definitions:
  - compilers: ["%gcc", "%clang"]
  - desired_specs: ["mpileaks@2.1"]
  specs:
  - matrix:
    - [$compilers]
    - [$desired_specs]
"""


def test_env_change_spec_in_definition(tmp_path: pathlib.Path, mutable_mock_env_path):
    manifest_file = tmp_path / ev.manifest_name
    manifest_file.write_text(_test_matrix_yaml)
    e = ev.create("test", manifest_file)
    e.concretize()
    e.write()

    assert any(x.intersects("mpileaks@2.1%gcc") for x in e.user_specs)

    with e:
        e.change_existing_spec(spack.spec.Spec("mpileaks@2.2"), list_name="desired_specs")
    e.write()

    # Ensure changed specs are in memory
    assert any(x.intersects("mpileaks@2.2%gcc") for x in e.user_specs)
    assert not any(x.intersects("mpileaks@2.1%gcc") for x in e.user_specs)

    # Now make sure the changes can be read from the modified config
    e = ev.read("test")
    assert any(x.intersects("mpileaks@2.2%gcc") for x in e.user_specs)
    assert not any(x.intersects("mpileaks@2.1%gcc") for x in e.user_specs)


def test_env_change_spec_in_matrix_raises_error(tmp_path: pathlib.Path, mutable_mock_env_path):
    manifest_file = tmp_path / ev.manifest_name
    manifest_file.write_text(_test_matrix_yaml)
    e = ev.create("test", manifest_file)
    e.concretize()
    e.write()

    with pytest.raises(spack.environment.SpackEnvironmentError) as error:
        e.change_existing_spec(spack.spec.Spec("mpileaks@2.2"))
    assert "Cannot directly change specs in matrices" in str(error)


def test_activate_should_require_an_env():
    with pytest.raises(TypeError):
        ev.activate(env="name")

    with pytest.raises(TypeError):
        ev.activate(env=None)


def test_user_view_path_is_not_canonicalized_in_yaml(tmp_path: pathlib.Path, config):
    # When spack.yaml files are checked into version control, we
    # don't want view: ./relative to get canonicalized on disk.

    # We create a view in <tmp_path>/env_dir
    env_path = str(tmp_path / "env_dir")
    (tmp_path / "env_dir").mkdir()

    # And use a relative path to specify the view dir
    view = os.path.join(".", "view")

    # Which should always resolve to the following independent of cwd.
    absolute_view = os.path.join(env_path, "view")

    # Serialize environment with relative view path
    with fs.working_dir(str(tmp_path)):
        fst = ev.create_in_dir(env_path, with_view=view)
        fst.regenerate_views()

    # The view link should be created
    assert os.path.isdir(absolute_view)

    # Deserialize and check if the view path is still relative in yaml
    # and also check that the getter is pointing to the right dir.
    with fs.working_dir(str(tmp_path)):
        snd = ev.Environment(env_path)
        assert snd.manifest["spack"]["view"] == view
        assert os.path.samefile(snd.default_view.root, absolute_view)


def test_environment_cant_modify_environments_root(tmp_path: pathlib.Path):
    filename = str(tmp_path / "spack.yaml")
    with open(filename, "w", encoding="utf-8") as f:
        f.write(
            """\
 spack:
   config:
     environments_root: /a/black/hole
   view: false
   specs: []
 """
        )
    with fs.working_dir(str(tmp_path)):
        with pytest.raises(ev.SpackEnvironmentError):
            e = ev.Environment(str(tmp_path))
            ev.activate(e)


@pytest.mark.regression("35420")
@pytest.mark.parametrize(
    "original_content",
    [
        """\
spack:
  specs:
  - matrix:
    # test
    - - a
  concretizer:
    unify: false"""
    ],
)
def test_roundtrip_spack_yaml_with_comments(original_content, config, tmp_path: pathlib.Path):
    """Ensure that round-tripping a spack.yaml file doesn't change its content."""
    spack_yaml = tmp_path / "spack.yaml"
    spack_yaml.write_text(original_content)

    e = ev.Environment(tmp_path)
    e.manifest.flush()

    content = spack_yaml.read_text()
    assert content == original_content


def test_adding_anonymous_specs_to_env_fails(tmp_path: pathlib.Path):
    """Tests that trying to add an anonymous spec to the 'specs' section of an environment
    raises an exception
    """
    env = ev.create_in_dir(tmp_path)
    with pytest.raises(ev.SpackEnvironmentError, match="cannot add anonymous"):
        env.add("%gcc")


def test_removing_from_non_existing_list_fails(tmp_path: pathlib.Path):
    """Tests that trying to remove a spec from a non-existing definition fails."""
    env = ev.create_in_dir(tmp_path)
    with pytest.raises(ev.SpackEnvironmentError, match="'bar' does not exist"):
        env.remove("%gcc", list_name="bar")


@pytest.mark.parametrize(
    "init_view,update_value",
    [
        (True, False),
        (True, "./view"),
        (False, True),
        ("./view", True),
        ("./view", False),
        (True, True),
        (False, False),
    ],
)
def test_update_default_view(init_view, update_value, tmp_path: pathlib.Path, config):
    """Tests updating the default view with different values."""
    env = ev.create_in_dir(tmp_path, with_view=init_view)
    env.update_default_view(update_value)
    env.write(regenerate=True)
    if not isinstance(update_value, bool):
        assert env.default_view.raw_root == update_value

    expected_value = update_value
    if isinstance(init_view, str) and update_value is True:
        expected_value = init_view

    assert env.manifest.yaml_content["spack"]["view"] == expected_value


@pytest.mark.parametrize(
    "initial_content,update_value,expected_view",
    [
        (
            """
spack:
  specs:
  - mpileaks
  view:
    default:
      root: ./view-gcc
      select: ['%gcc']
      link_type: symlink
""",
            "./another-view",
            {"root": "./another-view", "select": ["%gcc"], "link_type": "symlink"},
        ),
        (
            """
spack:
  specs:
  - mpileaks
  view:
    default:
      root: ./view-gcc
      select: ['%gcc']
      link_type: symlink
""",
            True,
            {"root": "./view-gcc", "select": ["%gcc"], "link_type": "symlink"},
        ),
    ],
)
def test_update_default_complex_view(
    initial_content, update_value, expected_view, tmp_path: pathlib.Path, config
):
    spack_yaml = tmp_path / "spack.yaml"
    spack_yaml.write_text(initial_content)

    env = ev.Environment(tmp_path)
    env.update_default_view(update_value)
    env.write(regenerate=True)

    assert env.default_view.to_dict() == expected_view


@pytest.mark.parametrize("filename", [ev.manifest_name, ev.lockfile_name])
def test_cannot_initialize_in_dir_with_init_file(tmp_path: pathlib.Path, filename):
    """Tests that initializing an environment in a directory with an already existing
    spack.yaml or spack.lock raises an exception.
    """
    init_file = tmp_path / filename
    init_file.touch()
    with pytest.raises(ev.SpackEnvironmentError, match="cannot initialize"):
        ev.create_in_dir(tmp_path)


def test_cannot_initiliaze_if_dirname_exists_as_a_file(tmp_path: pathlib.Path):
    """Tests that initializing an environment using as a location an existing file raises
    an error.
    """
    dir_name = tmp_path / "dir"
    dir_name.touch()
    with pytest.raises(ev.SpackEnvironmentError, match="cannot initialize"):
        ev.create_in_dir(dir_name)


def test_cannot_initialize_if_init_file_does_not_exist(tmp_path: pathlib.Path):
    """Tests that initializing an environment passing a non-existing init file raises an error."""
    init_file = tmp_path / ev.manifest_name
    with pytest.raises(ev.SpackEnvironmentError, match="cannot initialize"):
        ev.create_in_dir(tmp_path, init_file=init_file)


def test_environment_pickle(tmp_path: pathlib.Path):
    env1 = ev.create_in_dir(tmp_path)
    obj = pickle.dumps(env1)
    env2 = pickle.loads(obj)
    assert isinstance(env2, ev.Environment)


def test_error_on_nonempty_view_dir(tmp_path: pathlib.Path):
    """Error when the target is not an empty dir"""
    with fs.working_dir(str(tmp_path)):
        os.mkdir("empty_dir")
        os.mkdir("nonempty_dir")
        with open(os.path.join("nonempty_dir", "file"), "wb"):
            pass
        os.symlink("empty_dir", "symlinked_empty_dir")
        os.symlink("does_not_exist", "broken_link")
        os.symlink("broken_link", "file")

        # This is OK.
        _error_on_nonempty_view_dir("empty_dir")

        # This is not OK.
        with pytest.raises(SpackEnvironmentViewError):
            _error_on_nonempty_view_dir("nonempty_dir")

        with pytest.raises(SpackEnvironmentViewError):
            _error_on_nonempty_view_dir("symlinked_empty_dir")

        with pytest.raises(SpackEnvironmentViewError):
            _error_on_nonempty_view_dir("broken_link")

        with pytest.raises(SpackEnvironmentViewError):
            _error_on_nonempty_view_dir("file")


def test_can_add_specs_to_environment_without_specs_attribute(tmp_path: pathlib.Path, config):
    """Sometimes users have template manifest files, and save one line in the YAML file by
    removing the empty 'specs: []' attribute. This test ensures that adding a spec to an
    environment without the 'specs' attribute, creates the attribute first instead of returning
    an error.
    """
    spack_yaml = tmp_path / "spack.yaml"
    spack_yaml.write_text(
        """
spack:
  view: true
  concretizer:
    unify: true
    """
    )
    env = ev.Environment(tmp_path)
    env.add("pkg-a")

    assert len(env.user_specs) == 1
    assert env.manifest.yaml_content["spack"]["specs"] == ["pkg-a"]


@pytest.mark.parametrize(
    "original_yaml,new_spec,expected_yaml",
    [
        (
            """spack:
  specs:
  # baz
  - zlib
""",
            "libdwarf",
            """spack:
  specs:
  # baz
  - zlib
  - libdwarf
""",
        )
    ],
)
def test_preserving_comments_when_adding_specs(
    original_yaml, new_spec, expected_yaml, config, tmp_path: pathlib.Path
):
    """Ensure that round-tripping a spack.yaml file doesn't change its content."""
    spack_yaml = tmp_path / "spack.yaml"
    spack_yaml.write_text(original_yaml)

    e = ev.Environment(str(tmp_path))
    e.add(new_spec)
    e.write()

    content = spack_yaml.read_text()
    assert content == expected_yaml


@pytest.mark.parametrize("filename", [ev.lockfile_name, "as9582g54.lock", "m3ia54s.json"])
@pytest.mark.regression("37410")
def test_initialize_from_lockfile(tmp_path: pathlib.Path, filename):
    """Some users have workflows where they store multiple lockfiles in the
    same directory, and pick one of them to create an environment depending
    on external parameters e.g. while running CI jobs. This test ensures that
    Spack can create environments from lockfiles that are not necessarily named
    'spack.lock' and can thus coexist in the same directory.
    """

    init_file = tmp_path / filename
    env_dir = tmp_path / "env_dir"
    init_file.write_text('{ "roots": [] }\n')
    ev.initialize_environment_dir(env_dir, init_file)

    assert os.path.exists(env_dir / ev.lockfile_name)
    assert filecmp.cmp(env_dir / ev.lockfile_name, init_file, shallow=False)


def test_cannot_initialize_from_bad_lockfile(tmp_path: pathlib.Path):
    """Test that we fail on an incorrectly constructed lockfile"""

    init_file = tmp_path / ev.lockfile_name
    env_dir = tmp_path / "env_dir"

    init_file.write_text("Not a legal JSON file\n")

    with pytest.raises(ev.SpackEnvironmentError, match="from lockfile"):
        ev.initialize_environment_dir(env_dir, init_file)


@pytest.mark.parametrize("filename", ["random.txt", "random.yaml", ev.manifest_name])
@pytest.mark.regression("37410")
def test_initialize_from_random_file_as_manifest(tmp_path: pathlib.Path, filename):
    """Some users have workflows where they store multiple lockfiles in the
    same directory, and pick one of them to create an environment depending
    on external parameters e.g. while running CI jobs. This test ensures that
    Spack can create environments from manifest that are not necessarily named
    'spack.yaml' and can thus coexist in the same directory.
    """

    init_file = tmp_path / filename
    env_dir = tmp_path / "env_dir"

    init_file.write_text(
        """\
spack:
  view: true
  concretizer:
    unify: true
  specs: []
"""
    )

    ev.create_in_dir(env_dir, init_file)

    assert not os.path.exists(env_dir / ev.lockfile_name)
    assert os.path.exists(env_dir / ev.manifest_name)
    assert filecmp.cmp(env_dir / ev.manifest_name, init_file, shallow=False)


def test_error_message_when_using_too_new_lockfile(tmp_path: pathlib.Path):
    """Sometimes the lockfile format needs to be bumped. When that happens, we have forward
    incompatibilities that need to be reported in a clear way to the user, in case we moved
    back to an older version of Spack. This test ensures that the error message for a too
    new lockfile version stays comprehensible across refactoring of the environment code.
    """
    init_file = tmp_path / ev.lockfile_name
    env_dir = tmp_path / "env_dir"
    init_file.write_text(
        """
{
    "_meta": {
        "file-type": "spack-lockfile",
        "lockfile-version": 100,
        "specfile-version": 3
    },
    "roots": [],
    "concrete_specs": {}
}\n
"""
    )
    ev.initialize_environment_dir(env_dir, init_file)
    with pytest.raises(ev.SpackEnvironmentError, match="You need to use a newer Spack version."):
        ev.Environment(env_dir)


@pytest.mark.regression("38240")
@pytest.mark.parametrize(
    "unify_in_lower_scope,unify_in_spack_yaml",
    [
        (True, False),
        (True, "when_possible"),
        (False, True),
        (False, "when_possible"),
        ("when_possible", False),
        ("when_possible", True),
    ],
)
def test_environment_concretizer_scheme_used(
    tmp_path: pathlib.Path, mutable_config, unify_in_lower_scope, unify_in_spack_yaml
):
    """Tests that "unify" settings in spack.yaml always take precedence over settings in lower
    configuration scopes.
    """
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(
        f"""\
spack:
  specs:
  - mpileaks
  concretizer:
    unify: {str(unify_in_spack_yaml).lower()}
"""
    )
    mutable_config.set("concretizer:unify", unify_in_lower_scope)
    assert mutable_config.get("concretizer:unify") == unify_in_lower_scope
    with ev.Environment(manifest.parent) as e:
        assert mutable_config.get("concretizer:unify") == unify_in_spack_yaml
        assert e.unify == unify_in_spack_yaml


@pytest.mark.parametrize("unify_in_config", [True, False, "when_possible"])
def test_environment_config_scheme_used(tmp_path: pathlib.Path, unify_in_config):
    """Tests that "unify" settings in lower configuration scopes is taken into account,
    if absent in spack.yaml.
    """
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(
        """\
spack:
  specs:
  - mpileaks
"""
    )

    with spack.config.override("concretizer:unify", unify_in_config):
        with ev.Environment(manifest.parent) as e:
            assert e.unify == unify_in_config


@pytest.mark.parametrize(
    "spec_str,expected_raise,expected_spec",
    [
        # vendorsb vendors "b" only when @=1.1
        ("vendorsb", False, "vendorsb@=1.0"),
        ("vendorsb@=1.1", True, None),
    ],
)
def test_conflicts_with_packages_that_are_not_dependencies(
    spec_str, expected_raise, expected_spec, tmp_path: pathlib.Path, config
):
    """Tests that we cannot concretize two specs together, if one conflicts with the other,
    even though they don't have a dependency relation.
    """
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(
        f"""\
spack:
  specs:
  - {spec_str}
  - pkg-b
  concretizer:
    unify: true
"""
    )
    with ev.Environment(manifest.parent) as e:
        if expected_raise:
            with pytest.raises(spack.solver.asp.UnsatisfiableSpecError):
                e.concretize()
        else:
            e.concretize()
            assert any(s.satisfies(expected_spec) for s in e.concrete_roots())


@pytest.mark.regression("39455")
@pytest.mark.parametrize(
    "possible_mpi_spec,unify", [("mpich", False), ("mpich", True), ("zmpi", False), ("zmpi", True)]
)
def test_requires_on_virtual_and_potential_providers(
    possible_mpi_spec, unify, tmp_path: pathlib.Path, config
):
    """Tests that in an environment we can add packages explicitly, even though they provide
    a virtual package, and we require the provider of the same virtual to be another package,
    if they are added explicitly by their name.
    """
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(
        f"""\
    spack:
      specs:
      - {possible_mpi_spec}
      - mpich2
      - mpileaks
      packages:
        mpi:
          require: mpich2
      concretizer:
        unify: {unify}
    """
    )
    with ev.Environment(manifest.parent) as e:
        e.concretize()
        assert e.matching_spec(possible_mpi_spec)
        assert e.matching_spec("mpich2")

        mpileaks = e.matching_spec("mpileaks")
        assert mpileaks.satisfies("^mpich2")
        assert mpileaks["mpi"].satisfies("mpich2")
        assert not mpileaks.satisfies(f"^{possible_mpi_spec}")


@pytest.mark.regression("39387")
@pytest.mark.parametrize(
    "spec_str", ["mpileaks +opt", "mpileaks  +opt   ~shared", "mpileaks  ~shared   +opt"]
)
def test_manifest_file_removal_works_if_spec_is_not_normalized(tmp_path: pathlib.Path, spec_str):
    """Tests that we can remove a spec from a manifest file even if its string
    representation is not normalized.
    """
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(
        f"""\
spack:
  specs:
  - {spec_str}
"""
    )
    s = spack.spec.Spec(spec_str)
    spack_yaml = EnvironmentManifestFile(tmp_path)
    # Doing a round trip str -> Spec -> str normalizes the representation
    spack_yaml.remove_user_spec(str(s))
    spack_yaml.flush()

    assert spec_str not in manifest.read_text()


@pytest.mark.regression("39387")
@pytest.mark.parametrize(
    "duplicate_specs,expected_number",
    [
        # Swap variants, versions, etc. add spaces
        (["foo +bar ~baz", "foo ~baz    +bar"], 3),
        (["foo @1.0 ~baz %gcc", "foo ~baz @1.0%gcc"], 3),
        # Item 1 and 3 are exactly the same
        (["zlib +shared", "zlib      +shared", "zlib +shared"], 4),
    ],
)
def test_removing_spec_from_manifest_with_exact_duplicates(
    duplicate_specs, expected_number, tmp_path: pathlib.Path
):
    """Tests that we can remove exact duplicates from a manifest file.

    Note that we can't get in a state with duplicates using only CLI, but this might happen
    on user edited spack.yaml files.
    """
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(
        f"""\
    spack:
      specs: [{", ".join(duplicate_specs)} , "zlib"]
    """
    )

    with ev.Environment(tmp_path) as env:
        assert len(env.user_specs) == expected_number
        env.remove(duplicate_specs[0])
        env.write()

    assert "+shared" not in manifest.read_text()
    assert "zlib" in manifest.read_text()
    with ev.Environment(tmp_path) as env:
        assert len(env.user_specs) == 1


@pytest.mark.regression("35298")
def test_variant_propagation_with_unify_false(tmp_path: pathlib.Path, config):
    """Spack distributes concretizations to different processes, when unify:false is selected and
    the number of roots is 2 or more. When that happens, the specs to be concretized need to be
    properly reconstructed on the worker process, if variant propagation was requested.
    """
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(
        """
    spack:
      specs:
      - parent-foo ++foo
      - pkg-c
      concretizer:
        unify: false
    """
    )
    with ev.Environment(tmp_path) as env:
        env.concretize()

    root = env.matching_spec("parent-foo")
    for node in root.traverse():
        assert node.satisfies("+foo")


def test_env_with_include_defs(mutable_mock_env_path):
    """Test environment with included definitions file."""
    env_path = mutable_mock_env_path
    env_path.mkdir()
    defs_file = env_path / "definitions.yaml"
    defs_file.write_text(
        """definitions:
- core_specs: [libdwarf, libelf]
- compilers: ['%gcc']
"""
    )

    spack_yaml = env_path / ev.manifest_name
    spack_yaml.write_text(
        f"""spack:
  include:
  - {defs_file.as_uri()}

  definitions:
  - my_packages: [zlib]

  specs:
  - matrix:
    - [$core_specs]
    - [$compilers]
  - $my_packages
"""
    )

    e = ev.Environment(env_path)
    with e:
        e.concretize()


def test_env_with_include_def_missing(mutable_mock_env_path):
    """Test environment with included definitions file that is missing a definition."""
    env_path = mutable_mock_env_path
    env_path.mkdir()
    filename = "missing-def.yaml"
    defs_file = env_path / filename
    defs_file.write_text("definitions:\n- my_compilers: ['%gcc']\n")

    spack_yaml = env_path / ev.manifest_name
    spack_yaml.write_text(
        f"""spack:
  include:
  - {defs_file.as_uri()}

  specs:
  - matrix:
    - [$core_specs]
    - [$my_compilers]
"""
    )

    with pytest.raises(UndefinedReferenceError, match=r"which is not defined"):
        _ = ev.Environment(env_path)


@pytest.mark.regression("41292")
def test_deconcretize_then_concretize_does_not_error(mutable_mock_env_path):
    """Tests that, after having deconcretized a spec, we can reconcretize an environment which
    has 2 or more user specs mapping to the same concrete spec.
    """
    mutable_mock_env_path.mkdir()
    spack_yaml = mutable_mock_env_path / ev.manifest_name
    spack_yaml.write_text(
        """spack:
      specs:
      # These two specs concretize to the same hash
      - pkg-c
      - pkg-c@1.0
      # Spec used to trigger the bug
      - pkg-a
      concretizer:
        unify: true
    """
    )
    e = ev.Environment(mutable_mock_env_path)
    with e:
        e.concretize()
        e.deconcretize(spack.spec.Spec("pkg-a"), concrete=False)
        e.concretize()
    assert len(e.concrete_roots()) == 3
    all_root_hashes = {x.dag_hash() for x in e.concrete_roots()}
    assert len(all_root_hashes) == 2


@pytest.mark.regression("44216")
def test_root_version_weights_for_old_versions(mutable_mock_env_path):
    """Tests that, when we select two old versions of root specs that have the same version
    optimization penalty, both are considered.
    """
    mutable_mock_env_path.mkdir()
    spack_yaml = mutable_mock_env_path / ev.manifest_name
    spack_yaml.write_text(
        """spack:
      specs:
      # allow any version, but the most recent
      - bowtie@:1.3
      # allows only the third most recent, so penalty is 2
      - gcc@1
      concretizer:
        unify: true
    """
    )
    e = ev.Environment(mutable_mock_env_path)
    with e:
        e.concretize()

    bowtie = [x for x in e.concrete_roots() if x.name == "bowtie"][0]
    gcc = [x for x in e.concrete_roots() if x.name == "gcc"][0]

    assert bowtie.satisfies("@=1.3.0")
    assert gcc.satisfies("@=1.0")


def test_env_view_on_empty_dir_is_fine(tmp_path: pathlib.Path, config, temporary_store):
    """Tests that creating a view pointing to an empty dir is not an error."""
    view_dir = tmp_path / "view"
    view_dir.mkdir()
    env = ev.create_in_dir(tmp_path, with_view="view")
    env.add("mpileaks")
    env.concretize()
    env.install_all(fake=True)
    env.regenerate_views()
    assert view_dir.is_symlink()


def test_env_view_on_non_empty_dir_errors(tmp_path: pathlib.Path, config, temporary_store):
    """Tests that creating a view pointing to a non-empty dir errors."""
    view_dir = tmp_path / "view"
    view_dir.mkdir()
    (view_dir / "file").write_text("")
    env = ev.create_in_dir(tmp_path, with_view="view")
    env.add("mpileaks")
    env.concretize()
    env.install_all(fake=True)
    with pytest.raises(ev.SpackEnvironmentError, match="because it is a non-empty dir"):
        env.regenerate_views()


@pytest.mark.parametrize(
    "matrix_line", [("^zmpi", "^mpich"), ("~shared", "+shared"), ("shared=False", "+shared-libs")]
)
@pytest.mark.regression("40791")
def test_stack_enforcement_is_strict(tmp_path: pathlib.Path, matrix_line, config):
    """Ensure that constraints in matrices are applied strictly after expansion, to avoid
    inconsistencies between abstract user specs and concrete specs.
    """
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(
        f"""\
spack:
  definitions:
    - packages: [libelf, mpileaks]
    - install:
        - matrix:
            - [$packages]
            - [{", ".join(item for item in matrix_line)}]
  specs:
    - $install
  concretizer:
    unify: false
"""
    )
    # Here we raise different exceptions depending on whether we solve serially or not
    with pytest.raises(Exception):
        with ev.Environment(tmp_path) as e:
            e.concretize()


def test_only_roots_are_explicitly_installed(tmp_path: pathlib.Path, config, temporary_store):
    """When installing specific non-root specs from an environment, we continue to mark them
    as implicitly installed. What makes installs explicit is that they are root of the env."""
    env = ev.create_in_dir(tmp_path)
    env.add("mpileaks")
    env.concretize()
    mpileaks = env.concrete_roots()[0]
    callpath = mpileaks["callpath"]
    env.install_specs([callpath], fake=True)
    assert callpath in temporary_store.db.query(explicit=False)
    env.install_specs([mpileaks], fake=True)
    assert temporary_store.db.query(explicit=True) == [mpileaks]


def test_environment_from_name_or_dir(mutable_mock_env_path):
    test_env = ev.create("test")

    name_env = ev.environment_from_name_or_dir(test_env.name)
    assert name_env.name == test_env.name
    assert name_env.path == test_env.path

    dir_env = ev.environment_from_name_or_dir(test_env.path)
    assert dir_env.name == test_env.name
    assert dir_env.path == test_env.path

    nested_test_env = ev.create("group/test")

    nested_name_env = ev.environment_from_name_or_dir(nested_test_env.name)
    assert nested_name_env.name == nested_test_env.name
    assert nested_name_env.path == nested_test_env.path

    nested_dir_env = ev.environment_from_name_or_dir(nested_test_env.path)
    assert nested_dir_env.name == nested_test_env.name
    assert nested_dir_env.path == nested_test_env.path

    with pytest.raises(ev.SpackEnvironmentError, match="no such environment"):
        _ = ev.environment_from_name_or_dir("fake-env")


def test_env_include_configs(mutable_mock_env_path):
    """check config and package values using new include schema"""
    env_path = mutable_mock_env_path
    env_path.mkdir()

    this_os = spack.platforms.host().default_os
    config_root = env_path / this_os
    config_root.mkdir()
    config_path = str(config_root / "config.yaml")
    with open(config_path, "w", encoding="utf-8") as f:
        f.write(
            """\
config:
  verify_ssl: False
"""
        )

    packages_path = str(env_path / "packages.yaml")
    with open(packages_path, "w", encoding="utf-8") as f:
        f.write(
            """\
packages:
  python:
    require:
    - spec: "@3.11:"
"""
        )

    spack_yaml = env_path / ev.manifest_name
    spack_yaml.write_text(
        f"""\
spack:
  include:
  - path: {config_path}
    optional: true
  - path: {packages_path}
"""
    )

    e = ev.Environment(env_path)
    with e.manifest.use_config():
        assert not spack.config.get("config:verify_ssl")
        python_reqs = spack.config.get("packages")["python"]["require"]
        req_specs = set(x["spec"] for x in python_reqs)
        assert req_specs == set(["@3.11:"])


def test_using_multiple_compilers_on_a_node_is_discouraged(tmp_path: pathlib.Path, mutable_config):
    """Tests that when we specify %<compiler> Spack tries to use that compiler for all the
    languages needed by that node.
    """
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(
        """\
spack:
  specs:
    - mpileaks%clang ^mpich%gcc
  concretizer:
    unify: true
"""
    )
    with ev.Environment(tmp_path) as e:
        e.concretize()
        mpileaks = e.concrete_roots()[0]

    assert not mpileaks.satisfies("%gcc") and mpileaks.satisfies("%clang")
    assert len(mpileaks.dependencies(virtuals=("c", "cxx"))) == 1

    mpich = mpileaks["mpich"]
    assert mpich.satisfies("%gcc") and not mpich.satisfies("%clang")
    assert len(mpich.dependencies(virtuals=("c", "cxx"))) == 1


@pytest.mark.parametrize(
    ["spack_yaml", "expected", "not_expected"],
    [
        # Define a toolchain in spack.yaml
        (
            """\
spack:
  specs:
    - mpileaks %llvm-toolchain
  toolchains:
    llvm-toolchain:
    - spec: "%[virtuals=c] llvm"
      when: "%c"
    - spec: "%[virtuals=cxx] llvm"
      when: "%cxx"
  concretizer:
    unify: true
""",
            ["%[virtuals=c] llvm", "^[virtuals=mpi] mpich"],
            ["%[virtuals=c] gcc"],
        ),
        # Use a toolchain in a default requirement
        (
            """\
    spack:
      specs:
        - mpileaks
      toolchains:
        llvm-toolchain:
        - spec: "%[virtuals=c] llvm"
          when: "%c"
        - spec: "%[virtuals=cxx] llvm"
          when: "%cxx"
        - spec: "%[virtuals=mpi] zmpi"
          when: "%mpi"
      packages:
        all:
          require:
          - "%llvm-toolchain"
      concretizer:
        unify: true
    """,
            ["%[virtuals=c] llvm", "%[virtuals=mpi] zmpi", "^callpath %[virtuals=c] llvm"],
            ["%[virtuals=c] gcc"],
        ),
    ],
)
def test_toolchain_definitions_are_allowed(
    spack_yaml, expected, not_expected, tmp_path: pathlib.Path, mutable_config
):
    """Tests that we can use toolchain definitions in spack.yaml files."""
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(spack_yaml)
    with ev.Environment(tmp_path) as e:
        e.concretize()
        mpileaks = e.concrete_roots()[0]

    for c in expected:
        assert mpileaks.satisfies(c)

    for c in not_expected:
        assert not mpileaks.satisfies(c)


MIXED_TOOLCHAIN = """
    - spec: "%[virtuals=c] llvm"
      when: "%c"
    - spec: "%[virtuals=cxx] llvm"
      when: "%cxx"
    - spec: "%[virtuals=fortran] gcc"
      when: "%fortran"
    - spec: "%[virtuals=mpi] mpich"
      when: "%mpi"
"""


@pytest.mark.parametrize("unify", ["true", "false", "when_possible"])
def test_single_toolchain_and_matrix(unify, tmp_path: pathlib.Path, mutable_config):
    """Tests that toolchains can be used with matrices in environments"""
    spack_yaml = f"""
spack:
  specs:
  - matrix:
    - [mpileaks,  dt-diamond-right]
    - ["%mixed-toolchain"]
  toolchains:
    mixed-toolchain:
    {MIXED_TOOLCHAIN}
  concretizer:
    unify: {unify}
"""
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(spack_yaml)
    with ev.Environment(tmp_path) as e:
        e.concretize()
        roots = e.concrete_roots()

    expected = [
        "%[when='%c' virtuals=c] llvm",
        "%[when='%cxx' virtuals=cxx] llvm",
        "%[when='%fortran' virtuals=fortran] gcc",
        "%[when='%mpi' virtuals=mpi] mpich",
    ]
    for c in expected:
        assert all(s.satisfies(c) for s in roots)

    not_expected = ["^zmpi", "%[virtuals=c] gcc"]
    for c in not_expected:
        assert all(not s.satisfies(c) for s in roots)


GCC_ZMPI = """
    - spec: "%[virtuals=c] gcc"
      when: "%c"
    - spec: "%[virtuals=cxx] gcc"
      when: "%cxx"
    - spec: "%[virtuals=fortran] gcc"
      when: "%fortran"
    - spec: "%[virtuals=mpi] zmpi"
      when: "%mpi"
"""


@pytest.mark.parametrize("unify", ["false", "when_possible"])
def test_toolchains_as_matrix_dimension(unify, tmp_path: pathlib.Path, mutable_config):
    """Tests expanding a matrix using different toolchains as the last dimension"""
    spack_yaml = f"""
spack:
  specs:
  - matrix:
    - [mpileaks,  dt-diamond-right]
    - ["%mixed-toolchain", "%gcc-zmpi"]
  toolchains:
    mixed-toolchain:
    {MIXED_TOOLCHAIN}
    gcc-zmpi:
    {GCC_ZMPI}
  concretizer:
    unify: {unify}
"""
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(spack_yaml)
    with ev.Environment(tmp_path) as e:
        e.concretize()
        roots = e.concrete_roots()

    mpileaks_gcc = [s for s in roots if s.satisfies("mpileaks %[virtuals=c] gcc")][0]
    mpileaks_clang = [s for s in roots if s.satisfies("mpileaks %[virtuals=c] clang")][0]

    # GCC-MPICH toolchain
    assert not mpileaks_gcc.satisfies("%[virtuals=mpi] mpich")
    assert mpileaks_gcc.satisfies("%[virtuals=mpi] zmpi")

    # Mixed toolchain
    assert mpileaks_clang.satisfies("%[virtuals=mpi] mpich")
    assert not mpileaks_clang.satisfies("%[virtuals=mpi] zmpi")
    assert mpileaks_clang["mpich"].satisfies("%[virtuals=fortran] gcc")


@pytest.mark.parametrize("unify", ["true", "false", "when_possible"])
def test_using_toolchain_as_requirement(unify, tmp_path: pathlib.Path, mutable_config):
    """Tests using a toolchain as a default requirement in an environment"""
    spack_yaml = f"""
spack:
  specs:
  - mpileaks
  - dt-diamond-right
  toolchains:
    mixed-toolchain:
    {MIXED_TOOLCHAIN}
  packages:
    all:
      require:
      - "%mixed-toolchain"
  concretizer:
    unify: {unify}
"""
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(spack_yaml)
    with ev.Environment(tmp_path) as e:
        e.concretize()
        roots = e.concrete_roots()

    mpileaks = [s for s in roots if s.satisfies("mpileaks")][0]

    assert mpileaks.satisfies("%[virtuals=mpi] mpich")
    assert mpileaks.satisfies("^[virtuals=mpi] mpich")

    mpich = mpileaks["mpi"]
    assert mpich.satisfies("%[virtuals=c] llvm")
    assert mpich.satisfies("%[virtuals=cxx] llvm")
    assert mpich.satisfies("%[virtuals=fortran] gcc")


@pytest.mark.parametrize("unify", ["false", "when_possible"])
def test_using_toolchain_as_preferences(unify, tmp_path: pathlib.Path, mutable_config):
    """Tests using a toolchain as a strong preference in an environment"""
    spack_yaml = f"""
spack:
  specs:
  - dt-diamond-right %gcc-zmpi
  toolchains:
    mixed-toolchain:
    {MIXED_TOOLCHAIN}
    gcc-zmpi:
    {GCC_ZMPI}
  packages:
    all:
      prefer:
      - "%mixed-toolchain"
  concretizer:
    unify: {unify}
"""
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(spack_yaml)
    with ev.Environment(tmp_path) as e:
        e.concretize()
        roots = e.concrete_roots()

    dt = [s for s in roots if s.satisfies("dt-diamond-right")][0]
    assert dt.satisfies("%[virtuals=c] gcc")


@pytest.mark.parametrize("unify", ["true", "false", "when_possible"])
def test_mixing_toolchains_in_an_input_spec(unify, tmp_path: pathlib.Path, mutable_config):
    """Tests using a toolchain as a strong preference in an environment"""
    spack_yaml = f"""
spack:
  specs:
  - mpileaks %mixed-toolchain ^libelf %gcc-zmpi
  toolchains:
    mixed-toolchain:
    {MIXED_TOOLCHAIN}
    gcc-zmpi:
    {GCC_ZMPI}
  concretizer:
    unify: {unify}
"""
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(spack_yaml)
    with ev.Environment(tmp_path) as e:
        e.concretize()
        roots = e.concrete_roots()

    mpileaks = [s for s in roots if s.satisfies("mpileaks")][0]
    assert mpileaks.satisfies("%[virtuals=mpi] mpich")
    assert mpileaks.satisfies("^[virtuals=mpi] mpich")
    assert mpileaks.satisfies("%[virtuals=c] llvm")

    libelf = mpileaks["libelf"]
    assert libelf.satisfies("%[virtuals=c] gcc")  # libelf only depends on c


def test_reuse_environment_dependencies(tmp_path: pathlib.Path, mutable_config):
    """Tests reusing specs from a separate, and concrete, environment."""
    base = tmp_path / "base"
    base.mkdir()

    # Concretize the first environment asking for a non-default spec. In this way we'll know
    # that reuse from the derived environment is not accidental.
    manifest_base = base / "spack.yaml"
    manifest_base.write_text(
        """
spack:
  specs:
  - pkg-a@1.0
  packages:
    pkg-b:
      require:
      - "@0.9"
"""
    )
    with ev.Environment(base) as e:
        e.concretize()
        # We need the spack.lock for reuse in the derived environment
        e.write(regenerate=False)
        base_pkga = e.concrete_roots()[0]

    # Create a second environment, reuse from the previous one and check pkg-a is the same
    derived = tmp_path / "derived"
    derived.mkdir()
    manifest_derived = derived / "spack.yaml"
    manifest_derived.write_text(
        f"""
spack:
  specs:
  - pkg-a
  concretizer:
    reuse:
      from:
      - type: environment
        path: {base}
"""
    )
    with ev.Environment(derived) as e:
        e.concretize()
        derived_pkga = e.concrete_roots()[0]

    assert base_pkga.dag_hash() == derived_pkga.dag_hash()


@pytest.mark.parametrize(
    "spack_yaml",
    [
        # Use a plain requirement for callpath
        """
spack:
  specs:
  - mpileaks %%c,cxx=gcc
  - mpileaks %%c,cxx=llvm
  packages:
    callpath:
      require:
      - "%c=gcc"
  concretizer:
    unify: false
""",
        # Propagate a toolchain
        """
spack:
  specs:
  - mpileaks %%c,cxx=gcc
  - mpileaks %%llvm_toolchain
  toolchains:
    llvm_toolchain:
    - spec: "%c=llvm"
      when: "%c"
    - spec: "%cxx=llvm"
      when: "%cxx"
  packages:
    callpath:
      require:
      - "%c=gcc"
  concretizer:
    unify: false
""",
        # Override callpath from input spec
        """
spack:
  specs:
  - mpileaks %%c,cxx=gcc ^callpath %c=gcc
  - mpileaks %%llvm_toolchain ^callpath %c=gcc
  toolchains:
    llvm_toolchain:
    - spec: "%c=llvm"
      when: "%c"
    - spec: "%cxx=llvm"
      when: "%cxx"
  concretizer:
    unify: false
""",
    ],
)
def test_dependency_propagation_in_environments(spack_yaml, tmp_path, mutable_config):
    """Tests that we can enforce compiler preferences using %% in environments."""
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(spack_yaml)
    with ev.Environment(tmp_path) as e:
        e.concretize()
        roots = e.concrete_roots()

    mpileaks_gcc = [s for s in roots if s.satisfies("mpileaks %c=gcc")][0]
    for c in ("%[when=%c]c=gcc", "%[when=%cxx]cxx=gcc"):
        assert all(x.satisfies(c) for x in mpileaks_gcc.traverse() if x.name != "callpath")

    mpileaks_llvm = [s for s in roots if s.satisfies("mpileaks %c=llvm")][0]
    for c in ("%[when=%c]c=llvm", "%[when=%cxx]cxx=llvm"):
        assert all(x.satisfies(c) for x in mpileaks_llvm.traverse() if x.name != "callpath")

    assert mpileaks_gcc["callpath"].satisfies("%c=gcc")
    assert mpileaks_llvm["callpath"].satisfies("%c=gcc")


@pytest.mark.parametrize(
    "spack_yaml,exception_nodes",
    [
        # trilinos and its link/run subdag are compiled with clang, all other nodes use gcc
        (
            """
spack:
  specs:
  - trilinos %%c,cxx=clang
  packages:
    c:
      prefer:
      - gcc
    cxx:
      prefer:
      - gcc
""",
            set(),
        ),
        # callpath and its link/run subdag are compiled with clang, all other nodes use gcc
        (
            """
spack:
  specs:
  - trilinos ^callpath %%c,cxx=clang
  packages:
    c:
      prefer:
      - gcc
    cxx:
      prefer:
      - gcc
""",
            {"trilinos", "mpich", "py-numpy"},
        ),
        # trilinos and its link/run subdag, with the exception of mpich, are compiled with clang.
        # All other nodes use gcc.
        (
            """
spack:
  specs:
  - trilinos %%c,cxx=clang ^mpich %c=gcc
  packages:
    c:
      prefer:
      - gcc
    cxx:
      prefer:
      - gcc
""",
            {"mpich"},
        ),
        (
            """
spack:
  specs:
  - trilinos %%c,cxx=clang
  packages:
    c:
      prefer:
      - gcc
    cxx:
      prefer:
      - gcc
    mpich:
      require:
      - "%c=gcc"
""",
            {"mpich"},
        ),
    ],
)
def test_double_percent_semantics(spack_yaml, exception_nodes, tmp_path, mutable_config):
    """Tests semantics of %% in environments, when combined with other features.

    The test assumes clang is the propagated compiler, and gcc is the preferred compiler.
    """
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(spack_yaml)
    with ev.Environment(tmp_path) as e:
        e.concretize()
        trilinos = e.concrete_roots()[0]

    runtime_nodes = [
        x for x in trilinos.traverse(deptype=("link", "run")) if x.name not in exception_nodes
    ]
    remaining_nodes = [x for x in trilinos.traverse() if x not in runtime_nodes]

    for x in runtime_nodes:
        error_msg = f"\n{x.tree()} does not use clang while expected to"
        assert x.satisfies("%[when=%c]c=clang %[when=%cxx]cxx=clang"), error_msg

    for x in remaining_nodes:
        error_msg = f"\n{x.tree()} does not use gcc while expected to"
        assert x.satisfies("%[when=%c]c=gcc %[when=%cxx]cxx=gcc"), error_msg


def test_cannot_use_double_percent_with_require(tmp_path, mutable_config):
    """Tests that %% cannot be used with a requirement on languages, since they'll conflict."""
    # trilinos wants to use clang, but we require gcc, so Spack will error
    spack_yaml = """
spack:
  specs:
  - trilinos %%c,cxx=clang
  packages:
    c:
      require:
      - gcc
    cxx:
      require:
      - gcc
"""
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(spack_yaml)
    with ev.Environment(tmp_path) as e:
        with pytest.raises(spack.solver.asp.UnsatisfiableSpecError, match="failed to concretize"):
            e.concretize()


@pytest.mark.parametrize(
    "spack_yaml",
    [
        # Specs with reuse on
        """
spack:
  specs:
  - trilinos
  - mpileaks
  concretizer:
    reuse: true
""",
        # Package with conditional dependency
        """
spack:
  specs:
  - ascent+adios2
  - fftw+mpi
""",
        """
spack:
  specs:
  - ascent~adios2
  - fftw~mpi
""",
        """
spack:
  specs:
  - ascent+adios2
  - fftw~mpi
""",
    ],
)
def test_static_analysis_in_environments(spack_yaml, tmp_path, mutable_config):
    """Tests that concretizations with and without static analysis produce the same results."""
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(spack_yaml)
    with ev.Environment(tmp_path) as e:
        e.concretize()
        no_static_analysis = {x.dag_hash() for x in e.concrete_roots()}

    mutable_config.set("concretizer:static_analysis", True)
    with ev.Environment(tmp_path) as e:
        e.concretize()
        static_analysis = {x.dag_hash() for x in e.concrete_roots()}

    assert no_static_analysis == static_analysis


@pytest.mark.regression("51606")
def test_ids_when_using_toolchain_twice_in_a_spec(tmp_path, mutable_config):
    """Tests that using the same toolchain twice in a spec constructs different objects"""
    spack_yaml = """
spack:
  toolchains:
    llvmtc:
    - spec: "%c=llvm"
      when: "%c"
    - spec: "%cxx=llvm"
      when: "%cxx"
    gnu:
    - spec: "%c=gcc@10"
      when: "%c"
    - spec: "%cxx=gcc@10"
      when: "%cxx"
    # This is missing the conditional when= on purpose
    - spec: "%fortran=gcc@10"
"""
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(spack_yaml)
    with ev.Environment(tmp_path):
        # We rely on this behavior when emitting facts for the solver
        s = spack.spec.Spec("mpileaks %gnu ^callpath %gnu")
        assert id(s["gcc"]) != id(s["callpath"]["gcc"])


def test_installed_specs_disregards_deprecation(tmp_path, mutable_config):
    """Tests that installed specs disregard deprecation. This is to avoid weird ordering issues,
    where an old version that _is not_ declared in package.py is considered as _not_ deprecated,
    and is preferred to a newer version that is explicitly marked as deprecated.
    """
    spack_yaml = """
spack:
  specs:
  - mpileaks
  packages:
    c:
      require:
      - gcc
    cxx:
      require:
      - gcc
    gcc::
      externals:
      - spec: gcc@7.3.1 languages:='c,c++,fortran'
        prefix: /path
        extra_attributes:
          compilers:
            c: /path/bin/gcc
            cxx: /path/bin/g++
            fortran: /path/bin/gfortran
      - spec: gcc@=12.4.0 languages:='c,c++,fortran'
        prefix: /usr
        extra_attributes:
          compilers:
            c: /usr/bin/gcc
            cxx: /usr/bin/g++
            fortran: /usr/bin/gfortran
"""
    manifest = tmp_path / "spack.yaml"
    manifest.write_text(spack_yaml)
    with ev.Environment(tmp_path) as e:
        e.concretize()
        mpileaks = e.concrete_roots()[0]

    for node in mpileaks.traverse():
        if node.satisfies("%c"):
            assert node.satisfies("%c=gcc@12"), node.tree()
            assert not node.satisfies("%c=gcc@7"), node.tree()
