# Copyright Spack Project Developers. See COPYRIGHT file for details.
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
import os
import pathlib
import shutil

import pytest

import spack.concretize
import spack.config
import spack.environment as ev
import spack.llnl.util.filesystem as fs
import spack.package_base
import spack.spec
import spack.stage
import spack.util.git
import spack.util.path
from spack.error import SpackError
from spack.fetch_strategy import URLFetchStrategy
from spack.main import SpackCommand

add = SpackCommand("add")
develop = SpackCommand("develop")
env = SpackCommand("env")


@pytest.mark.usefixtures("mutable_mock_env_path", "mock_packages", "mock_fetch", "mutable_config")
class TestDevelop:
    def check_develop(self, env, spec, path=None, build_dir=None):
        path = path or spec.name

        # check in memory representation
        assert spec.name in env.dev_specs
        dev_specs_entry = env.dev_specs[spec.name]
        assert dev_specs_entry["path"] == path
        assert dev_specs_entry["spec"] == str(spec)

        # check yaml representation
        dev_config = spack.config.get("develop", {})
        assert spec.name in dev_config
        yaml_entry = dev_config[spec.name]
        assert yaml_entry["spec"] == str(spec)
        if path == spec.name:
            # default paths aren't written out
            assert "path" not in yaml_entry
        else:
            assert yaml_entry["path"] == path

        if build_dir is not None:
            scope = env.scope_name
            assert build_dir == spack.config.get(
                "packages:{}:package_attributes:build_directory".format(spec.name), scope
            )

    def test_develop_no_path_no_clone(self):
        env("create", "test")
        with ev.read("test") as e:
            # develop checks that the path exists
            fs.mkdirp(os.path.join(e.path, "mpich"))
            develop("--no-clone", "mpich@1.0")
            self.check_develop(e, spack.spec.Spec("mpich@=1.0"))

    def test_develop_no_clone(self, tmp_path: pathlib.Path):
        env("create", "test")
        with ev.read("test") as e:
            develop("--no-clone", "-p", str(tmp_path), "mpich@1.0")
            self.check_develop(e, spack.spec.Spec("mpich@=1.0"), str(tmp_path))

    def test_develop_no_version(self, tmp_path: pathlib.Path):
        env("create", "test")
        with ev.read("test") as e:
            develop("--no-clone", "-p", str(tmp_path), "mpich")
            self.check_develop(e, spack.spec.Spec("mpich@=main"), str(tmp_path))

    def test_develop(self):
        env("create", "test")
        with ev.read("test") as e:
            develop("mpich@1.0")
            self.check_develop(e, spack.spec.Spec("mpich@=1.0"))

    def test_develop_no_args(self):
        env("create", "test")
        with ev.read("test") as e:
            # develop and remove it
            develop("mpich@1.0")
            shutil.rmtree(os.path.join(e.path, "mpich"))

            # test develop with no args
            develop()
            self.check_develop(e, spack.spec.Spec("mpich@=1.0"))

    def test_develop_build_directory(self):
        env("create", "test")
        with ev.read("test") as e:
            develop("-b", "test_build_dir", "mpich@1.0")
            self.check_develop(e, spack.spec.Spec("mpich@=1.0"), None, "test_build_dir")

    def test_develop_twice(self):
        env("create", "test")
        with ev.read("test") as e:
            develop("mpich@1.0")
            self.check_develop(e, spack.spec.Spec("mpich@=1.0"))

            develop("mpich@1.0")
            # disk representation isn't updated unless we write
            # second develop command doesn't change it, so we don't write
            # but we check disk representation
            e.write()
            self.check_develop(e, spack.spec.Spec("mpich@=1.0"))
            assert len(e.dev_specs) == 1

    def test_develop_update_path(self, tmp_path: pathlib.Path):
        env("create", "test")
        with ev.read("test") as e:
            develop("mpich@1.0")
            develop("-p", str(tmp_path), "mpich@1.0")
            self.check_develop(e, spack.spec.Spec("mpich@=1.0"), str(tmp_path))
            assert len(e.dev_specs) == 1

    def test_develop_update_spec(self):
        env("create", "test")
        with ev.read("test") as e:
            develop("mpich@1.0")
            develop("mpich@2.0")
            self.check_develop(e, spack.spec.Spec("mpich@=2.0"))
            assert len(e.dev_specs) == 1

    def test_develop_applies_changes(self, monkeypatch):
        env("create", "test")
        with ev.read("test") as e:
            e.add("mpich@1.0")
            e.concretize()
            e.write()

            monkeypatch.setattr(spack.stage.Stage, "steal_source", lambda x, y: None)
            develop("mpich@1.0")

            # Check modifications actually worked
            spec = next(e.roots())
            assert spec.satisfies("dev_path=*")

    def test_develop_applies_changes_parents(self, monkeypatch):
        env("create", "test")
        with ev.read("test") as e:
            e.add("hdf5^mpich@1.0")
            e.concretize()
            e.write()

            orig_hash = next(e.roots()).dag_hash()

            monkeypatch.setattr(spack.stage.Stage, "steal_source", lambda x, y: None)
            develop("mpich@1.0")

            # Check modifications actually worked
            new_hdf5 = next(e.roots())
            assert new_hdf5.dag_hash() != orig_hash
            assert new_hdf5["mpi"].satisfies("dev_path=*")

    def test_develop_applies_changes_spec_conflict(self, monkeypatch):
        env("create", "test")
        with ev.read("test") as e:
            e.add("mpich@1.0")
            e.concretize()
            e.write()

            monkeypatch.setattr(spack.stage.Stage, "steal_source", lambda x, y: None)
            with pytest.raises(ev.SpackEnvironmentDevelopError, match="conflicts with concrete"):
                develop("mpich@1.1")

    def test_develop_applies_changes_path(self, monkeypatch):
        env("create", "test")
        with ev.read("test") as e:
            e.add("mpich@1.0")
            e.concretize()
            e.write()

            # canonicalize paths relative to env
            testpath1 = spack.util.path.canonicalize_path("test/path1", e.path)
            testpath2 = spack.util.path.canonicalize_path("test/path2", e.path)

            monkeypatch.setattr(spack.stage.Stage, "steal_source", lambda x, y: None)
            # Testing that second call to develop successfully changes both config and specs
            for path in (testpath1, testpath2):
                develop("--path", path, "mpich@1.0")

                # Check modifications actually worked
                spec = next(e.roots())
                assert spec.satisfies(f"dev_path={path}")
                assert spack.config.get("develop:mpich:path") == path

    def test_develop_no_modify(self, monkeypatch):
        env("create", "test")
        with ev.read("test") as e:
            e.add("mpich@1.0")
            e.concretize()
            e.write()

            monkeypatch.setattr(spack.stage.Stage, "steal_source", lambda x, y: None)
            develop("--no-modify-concrete-specs", "mpich@1.0")

            # Check modifications were not applied
            spec = next(e.roots())
            assert not spec.satisfies("dev_path=*")

    def test_develop_canonicalize_path(self, monkeypatch):
        env("create", "test")
        with ev.read("test") as e:
            e.add("mpich@1.0")
            e.concretize()
            e.write()

            path = "../$user"
            abspath = spack.util.path.canonicalize_path(path, e.path)

            def check_path(stage, dest):
                assert dest == abspath

            monkeypatch.setattr(spack.stage.Stage, "steal_source", check_path)

            develop("-p", path, "mpich@1.0")
            self.check_develop(e, spack.spec.Spec("mpich@=1.0"), path)

            # Check modifications actually worked
            spec = next(e.roots())
            assert spec.satisfies("dev_path=%s" % abspath)

    def test_develop_canonicalize_path_no_args(self, monkeypatch):
        env("create", "test")
        with ev.read("test") as e:
            e.add("mpich@1.0")
            e.concretize()
            e.write()

            path = "$user"
            abspath = spack.util.path.canonicalize_path(path, e.path)

            def check_path(stage, dest):
                assert dest == abspath

            monkeypatch.setattr(spack.stage.Stage, "steal_source", check_path)

            # Defensive check to ensure canonicalization failures don't pollute FS
            assert abspath.startswith(e.path)

            # Create path to allow develop to modify env
            fs.mkdirp(abspath)
            develop("--no-clone", "-p", path, "mpich@1.0")
            self.check_develop(e, spack.spec.Spec("mpich@=1.0"), path)

            # Remove path to ensure develop with no args runs staging code
            os.rmdir(abspath)

            develop()
            self.check_develop(e, spack.spec.Spec("mpich@=1.0"), path)

            # Check modifications actually worked
            spec = next(e.roots())
            assert spec.satisfies("dev_path=%s" % abspath)


def _git_commit_list(git_repo_dir):
    git = spack.util.git.git()
    with fs.working_dir(git_repo_dir):
        output = git("log", "--pretty=format:%h", "-n", "20", output=str)
    return output.strip().split()


def test_develop_full_git_repo(
    mutable_mock_env_path,
    mock_git_version_info,
    install_mockery,
    mock_packages,
    monkeypatch,
    mutable_config,
    request,
):
    repo_path, filename, commits = mock_git_version_info
    monkeypatch.setattr(
        spack.package_base.PackageBase, "git", "file://%s" % repo_path, raising=False
    )

    spec = spack.concretize.concretize_one("git-test-commit@1.2")
    try:
        spec.package.do_stage()
        commits = _git_commit_list(spec.package.stage[0].source_path)
        # Outside of "spack develop" Spack will only pull exactly the commit it
        # needs, with no additional history
        assert len(commits) == 1
    finally:
        spec.package.do_clean()

    # Now use "spack develop": look at the resulting dev_path and make
    # sure the git repo pulled includes the full branch history (or rather,
    # more than just one commit).
    env("create", "test")
    with ev.read("test") as e:
        add("git-test-commit@1.2")
        e.concretize()
        e.write()

        develop("git-test-commit@1.2")
        e.write()

        spec = e.all_specs()[0]
        develop_dir = spec.variants["dev_path"].value
        commits = _git_commit_list(develop_dir)
        assert len(commits) > 1


def test_recursive(mutable_mock_env_path, install_mockery, mock_fetch):
    env("create", "test")

    with ev.read("test") as e:
        add("indirect-mpich@1.0")
        e.concretize()
        e.write()
        specs = e.all_specs()

        assert len(specs) > 1
        develop("--recursive", "mpich")

        expected_dev_specs = ["mpich", "direct-mpich", "indirect-mpich"]
        for spec in expected_dev_specs:
            assert spec in e.dev_specs

        spec = next(e.roots())
        for dep in spec.traverse():
            assert dep.satisfies("dev_path=*") == (dep.name in expected_dev_specs)


def test_develop_fails_with_multiple_concrete_versions(
    mutable_mock_env_path, install_mockery, mock_fetch, mutable_config
):
    env("create", "test")

    with ev.read("test") as e:
        add("indirect-mpich@1.0")
        add("indirect-mpich@0.9")
        mutable_config.set("concretizer:unify", False)
        e.concretize()

        with pytest.raises(SpackError) as develop_error:
            develop("indirect-mpich", fail_on_error=True)

        error_str = "has multiple concrete instances in the graph"
        assert error_str in str(develop_error.value)


def test_concretize_dev_path_with_at_symbol_in_env(
    mutable_mock_env_path, tmp_path: pathlib.Path, mock_packages
):
    spec_like = "develop-test@develop"

    develop_dir = tmp_path / "build@location"
    develop_dir.mkdir()
    env("create", "test_at_sym")

    with ev.read("test_at_sym") as e:
        add(spec_like)
        e.concretize()
        e.write()
        develop(f"--path={develop_dir}", spec_like)
        result = e.concrete_roots()

        assert len(result) == 1
        cspec = result[0]
        assert cspec.satisfies(spec_like), cspec
        assert cspec.is_develop, cspec
        assert str(develop_dir) in cspec.variants["dev_path"], cspec


def _failing_fn(*args, **kwargs):
    # This stands in for a function that should never be called as
    # part of a test.
    assert False


@pytest.mark.parametrize("_devpath_should_exist", [True, False])
@pytest.mark.disable_clean_stage_check
def test_develop_with_devpath_staging(
    monkeypatch,
    mutable_mock_env_path,
    mock_packages,
    tmp_path: pathlib.Path,
    mock_archive,
    install_mockery,
    mock_fetch,
    mock_resource_fetch,
    mock_stage,
    _devpath_should_exist,
):
    # If the specified develop path exists, a resource should not be
    # downloaded at all at install time. Otherwise, it should be.

    env("create", "test")

    develop_dir = tmp_path / "build@location"
    if _devpath_should_exist:
        develop_dir.mkdir()
        monkeypatch.setattr(URLFetchStrategy, "fetch", _failing_fn)

    spec_like = "simple-resource@1.0"

    with ev.read("test") as e:
        e.add(spec_like)
        e.concretize()
        e.write()
        develop(f"--path={develop_dir}", spec_like)

        e.install_all()

        expected_resource_path = develop_dir / "resource.tgz"
        if _devpath_should_exist:
            # If we made it here, we didn't try to download anything.
            pass
        else:
            assert os.path.exists(expected_resource_path)
