#
#  Licensed under the Apache License, Version 2.0 (the "License");
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.
#

# Pylint doesn't play well with fixtures and dependency injection from pytest
# pylint: disable=redefined-outer-name

import os
import shutil

import pytest

from buildstream._testing import cli, cli_integration, Cli  # pylint: disable=unused-import
from buildstream.exceptions import ErrorDomain
from buildstream._testing._utils.site import HAVE_SANDBOX

from tests.testutils import ArtifactShare


pytestmark = pytest.mark.integration


DATA_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "project")


#
# Ensure that we didn't get a build tree if we didn't ask for one
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_buildtree_unused(cli_integration, datafiles):
    # We can only test the non interacitve case
    # The non interactive case defaults to not using buildtrees
    # for `bst shell --build`
    project = str(datafiles)
    element_name = "build-shell/buildtree.bst"

    res = cli_integration.run(project=project, args=["--cache-buildtrees", "always", "build", element_name])
    res.assert_success()

    res = cli_integration.run(project=project, args=["shell", "--build", element_name, "--", "cat", "test"])
    res.assert_shell_error()


#
# Ensure we can use a buildtree from a successful build
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_buildtree_from_success(cli_integration, datafiles):
    # Test that if we ask for a build tree it is there.
    project = str(datafiles)
    element_name = "build-shell/buildtree.bst"

    res = cli_integration.run(project=project, args=["--cache-buildtrees", "always", "build", element_name])
    res.assert_success()

    res = cli_integration.run(
        project=project, args=["shell", "--build", "--use-buildtree", element_name, "--", "cat", "test"]
    )
    res.assert_success()
    assert "Hi" in res.output


#
# Ensure we can use a buildtree from a failed build
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_buildtree_from_failure(cli_integration, datafiles):
    # Test that we can use a build tree after a failure
    project = str(datafiles)
    element_name = "build-shell/buildtree-fail.bst"

    res = cli_integration.run(project=project, args=["build", element_name])
    res.assert_main_error(ErrorDomain.STREAM, None)

    # Assert that file has expected contents
    res = cli_integration.run(
        project=project, args=["shell", "--build", element_name, "--use-buildtree", "--", "cat", "test"]
    )
    res.assert_success()
    assert "WARNING using a buildtree from a failed build" in res.stderr
    assert "Hi" in res.output


###########################################################################
#                        Custom fixture ahead                             #
###########################################################################
#
# There are a lot of scenarios to test with launching shells with various states
# of local cache, which all require that artifacts be built in an artifact share.
#
# We want to use @pytest.mark.parametrize() here so that we can more coherently test
# specific scenarios, but testing each of these in a separate test is very expensive.
#
# For this reason, we use some module scope fixtures which will prepare the
# ArtifactShare() object by building and pushing to it, and the same ArtifactShare()
# object is shared across all tests which need the ArtifactShare() to be in that
# given state.
#
# This means we only need to download (fetch) the external alpine runtime and
# push it to our internal ArtifactShare() once, but we can reuse it for many
# parametrized tests.
#
# It is important that none of the tests using these fixtures access the
# module scope ArtifactShare() instances with "push" access, as tests
# should not be modifying the state of the shared data.
#
###########################################################################


# create_built_artifact_share()
#
# A helper function to create an ArtifactShare object with artifacts
# prebuilt, this can be shared across multiple tests which access
# the artifact share in a read-only fashion.
#
# Args:
#    tmpdir (str): The temp directory to be used
#    cache_buildtrees (bool): Whether to cache buildtrees when building
#    integration_cache (IntegrationCache): The session wide integration cache so that we
#                                          can reuse the sources from previous runs
#
def create_built_artifact_share(tmpdir, cache_buildtrees, integration_cache):
    element_name = "build-shell/buildtree.bst"

    # Replicate datafiles behavior and do work entirely in the temp directory
    project = os.path.join(tmpdir, "project")
    shutil.copytree(DATA_DIR, project)

    # Create the share to be hosted from this temp directory
    share = ArtifactShare(os.path.join(tmpdir, "artifactcache"))

    # Create a Cli instance to build and populate the share
    cli = Cli(os.path.join(tmpdir, "cache"))
    cli.configure(
        {"artifacts": {"servers": [{"url": share.repo, "push": True}]}, "sourcedir": integration_cache.sources}
    )

    # Optionally cache build trees
    args = []
    if cache_buildtrees:
        args += ["--cache-buildtrees", "always"]
    args += ["build", element_name]

    # Build
    result = cli.run(project=project, args=args)
    result.assert_success()

    # Assert that the artifact is indeed in the share
    assert cli.get_element_state(project, element_name) == "cached"
    artifact_name = cli.get_artifact_name(project, "test", element_name)
    assert share.get_artifact(artifact_name)

    return share


# share_with_buildtrees()
#
# A module scope fixture which prepares an ArtifactShare() instance
# which will have all dependencies of "build-shell/buildtree.bst" built and
# cached with buildtrees also cached.
#
@pytest.fixture(scope="module")
def share_with_buildtrees(tmp_path_factory, integration_cache):
    # Get a temporary directory for this module scope fixture
    tmpdir = tmp_path_factory.mktemp("artifact_share_with_buildtrees")

    # Create our ArtifactShare instance which will persist for the duration of
    # the class scope fixture.
    share = create_built_artifact_share(tmpdir, True, integration_cache)
    try:
        yield share
    finally:
        share.close()


# share_without_buildtrees()
#
# A module scope fixture which prepares an ArtifactShare() instance
# which will have all dependencies of "build-shell/buildtree.bst" built
# but without caching any buildtrees.
#
@pytest.fixture(scope="module")
def share_without_buildtrees(tmp_path_factory, integration_cache):
    # Get a temporary directory for this module scope fixture
    tmpdir = tmp_path_factory.mktemp("artifact_share_without_buildtrees")

    # Create our ArtifactShare instance which will persist for the duration of
    # the class scope fixture.
    share = create_built_artifact_share(tmpdir, False, integration_cache)
    try:
        yield share
    finally:
        share.close()


# maybe_pull_deps()
#
# Convenience function for optionally pulling element dependencies
# in the following parametrized tests.
#
# Args:
#    cli (Cli): The Cli object
#    project (str): The project path
#    element_name (str): The element name
#    pull_deps (str): The argument for `--deps`, or None
#    pull_buildtree (bool): Whether to also pull buildtrees
#
def maybe_pull_deps(cli, project, element_name, pull_deps, pull_buildtree):

    # Optionally pull the buildtree along with `bst artifact pull`
    if pull_deps:
        args = []
        if pull_buildtree:
            args += ["--pull-buildtrees"]
        args += ["artifact", "pull", "--deps", pull_deps, element_name]

        # Pull from cache
        result = cli.run(project=project, args=args)
        result.assert_success()


#
# Test behavior of launching a shell and requesting to use a buildtree, with
# various states of local cache (ranging from nothing cached to everything cached)
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
@pytest.mark.parametrize(
    "pull_deps,pull_buildtree,expect_error",
    [
        # Don't pull at all
        (None, False, "missing-buildtree-artifact-not-cached"),
        # Pull only dependencies
        ("build", False, "missing-buildtree-artifact-not-cached"),
        # Pull all elements including the shell element, but without the buildtree
        ("all", False, "missing-buildtree-artifact-buildtree-not-cached"),
        # Pull all elements including the shell element, and pull buildtrees
        ("all", True, None),
        # Pull only the artifact, but without the buildtree
        ("none", False, "missing-buildtree-artifact-buildtree-not-cached"),
        # Pull only the artifact with its buildtree
        ("none", True, None),
    ],
    ids=[
        "no-pull",
        "pull-only-deps",
        "pull-without-buildtree",
        "pull-with-buildtree",
        "pull-target-without-buildtree",
        "pull-target-with-buildtree",
    ],
)
def test_shell_use_cached_buildtree(share_with_buildtrees, datafiles, cli, pull_deps, pull_buildtree, expect_error):
    project = str(datafiles)
    element_name = "build-shell/buildtree.bst"

    cli.configure({"artifacts": {"servers": [{"url": share_with_buildtrees.repo}]}})

    # Optionally pull the buildtree along with `bst artifact pull`
    maybe_pull_deps(cli, project, element_name, pull_deps, pull_buildtree)

    # Disable access to the artifact server after pulling, so that `bst shell` cannot automatically
    # pull the missing bits, this should be equivalent to the missing bits being missing in a
    # remote server
    cli.configure({"artifacts": {}})

    # Run the shell without asking it to pull any buildtree, just asking to use a buildtree
    result = cli.run(project=project, args=["shell", "--build", element_name, "--use-buildtree", "--", "cat", "test"])

    if expect_error:
        result.assert_main_error(ErrorDomain.APP, expect_error)
    else:
        result.assert_success()
        assert "Hi" in result.output


#
# Test behavior of launching a shell and requesting to use a buildtree, while allowing
# BuildStream to download any missing bits from the artifact server on the fly (which
# it will do by default) again with various states of local cache (ranging from nothing
# cached to everything cached)
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
@pytest.mark.parametrize(
    "pull_deps,pull_buildtree",
    [
        # Don't pull at all
        (None, False),
        # Pull only dependencies
        ("build", False),
        # Pull all elements including the shell element, but without the buildtree
        ("all", False),
        # Pull all elements including the shell element, and pull buildtrees
        ("all", True),
    ],
    ids=["no-pull", "pull-only-deps", "pull-without-buildtree", "pull-with-buildtree"],
)
def test_shell_pull_cached_buildtree(share_with_buildtrees, datafiles, cli, pull_deps, pull_buildtree):
    project = str(datafiles)
    element_name = "build-shell/buildtree.bst"

    cli.configure({"artifacts": {"servers": [{"url": share_with_buildtrees.repo}]}})

    # Optionally pull the buildtree along with `bst artifact pull`
    maybe_pull_deps(cli, project, element_name, pull_deps, pull_buildtree)

    # Run the shell and request that required artifacts and buildtrees should be pulled
    result = cli.run(
        project=project,
        args=[
            "--pull-buildtrees",
            "shell",
            "--build",
            element_name,
            "--use-buildtree",
            "--",
            "cat",
            "test",
        ],
    )

    # In this case, we should succeed every time, regardless of what was
    # originally available in the local cache.
    #
    result.assert_success()
    assert "Hi" in result.output

    # Check that the working directory is writable
    result = cli.run(
        project=project,
        args=[
            "shell",
            "--build",
            element_name,
            "--use-buildtree",
            "--",
            "touch",
            "foo",
        ],
    )
    result.assert_success()


#
# Test behavior of shelling into a buildtree by its artifact name
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_shell_pull_artifact_cached_buildtree(share_with_buildtrees, datafiles, cli):
    project = str(datafiles)
    artifact_name = "test/build-shell-buildtree/f2f21aa730dcc49f682bb8406ef4705a51213ccf9739dc43b8b4fdc2d310e3f6"

    cli.configure({"artifacts": {"servers": [{"url": share_with_buildtrees.repo}]}})

    # Run the shell and request that required artifacts and buildtrees should be pulled
    result = cli.run(
        project=project,
        args=[
            "--pull-buildtrees",
            "shell",
            "--build",
            "--use-buildtree",
            artifact_name,
            "--",
            "cat",
            "test",
        ],
    )

    # In this case, we should succeed every time, regardless of what was
    # originally available in the local cache.
    #
    result.assert_success()
    assert "Hi" in result.output

    # Check that the working directory is writable
    result = cli.run(
        project=project,
        args=[
            "shell",
            "--build",
            "--use-buildtree",
            artifact_name,
            "--",
            "touch",
            "foo",
        ],
    )
    result.assert_success()


#
# Test behavior of launching a shell and requesting to use a buildtree.
#
# In this case we download everything we need first, but the buildtree was never cached at build time
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_shell_use_uncached_buildtree(share_without_buildtrees, datafiles, cli):
    project = str(datafiles)
    element_name = "build-shell/buildtree.bst"

    cli.configure({"artifacts": {"servers": [{"url": share_without_buildtrees.repo}]}})

    # Pull everything we would need
    maybe_pull_deps(cli, project, element_name, "all", True)

    # Run the shell without asking it to pull any buildtree, just asking to use a buildtree
    result = cli.run(project=project, args=["shell", "--build", element_name, "--use-buildtree", "--", "cat", "test"])

    # Sorry, a buildtree was never cached for this element
    result.assert_main_error(ErrorDomain.APP, "missing-buildtree-artifact-created-without-buildtree")


@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_shell_script_element(datafiles, cli_integration):
    project = str(datafiles)
    element_name = "build-shell/script.bst"

    result = cli_integration.run(project=project, args=["--cache-buildtrees", "always", "build", element_name])
    result.assert_success()

    # Run the shell and use the cached buildtree on this script element
    result = cli_integration.run(
        project=project, args=["shell", "--build", element_name, "--use-buildtree", "--", "cat", "/test"]
    )

    result.assert_success()
    assert "Hi" in result.output


@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
@pytest.mark.parametrize(
    "element_name,expect_success",
    [
        # Build shell into a compose element which succeeded
        ("build-shell/compose-success.bst", True),
        # Build shell into a compose element with failed integration commands
        ("build-shell/compose-fail.bst", False),
    ],
    ids=["integration-success", "integration-fail"],
)
def test_shell_compose_element(datafiles, cli_integration, element_name, expect_success):
    project = str(datafiles)

    # Build the element so it's in the local cache, ensure caching of buildtrees at build time
    result = cli_integration.run(project=project, args=["--cache-buildtrees", "always", "build", element_name])
    if expect_success:
        result.assert_success()
    else:
        result.assert_main_error(ErrorDomain.STREAM, None)

    # Ensure that the shell works regardless of success expectations
    #
    result = cli_integration.run(
        project=project, args=["shell", "--build", element_name, "--use-buildtree", "--", "echo", "Hi"]
    )
    result.assert_success()
    assert "Hi" in result.output

    # Check the file created with integration commands
    #
    result = cli_integration.run(
        project=project,
        args=["shell", "--build", element_name, "--use-buildtree", "--", "cat", "/integration-success"],
    )
    if expect_success:
        result.assert_success()
        assert "Hi" in result.output
    else:
        # Here the exit code is determined by `cat`, and will be non-zero.
        #
        # We cannot use result.assert_main_error() because that explicitly expects -1
        assert result.exit_code != 0
