#
#  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 uuid

import pytest

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

from tests.testutils import create_artifact_share

pytestmark = pytest.mark.integration


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


# execute_shell()
#
# Helper to run `bst shell` and first ensure that the element is built
#
# Args:
#    cli (Cli): The cli runner fixture
#    project (str): The project directory
#    command (list): The command argv list
#    config (dict): A project.conf dictionary to composite over the default
#    mount (tuple): A (host, target) tuple for the `--mount` option
#    element (str): The element to build and run a shell with
#    isolate (bool): Whether to pass --isolate to `bst shell`
#
def execute_shell(cli, project, command, *, config=None, mount=None, element="base.bst", isolate=False):
    # Ensure the element is built
    result = cli.run_project_config(project=project, project_config=config, args=["build", element])
    assert result.exit_code == 0

    args = ["shell"]
    if isolate:
        args += ["--isolate"]
    if mount is not None:
        host_path, target_path = mount
        args += ["--mount", host_path, target_path]
    args += [element, "--", *command]

    return cli.run_project_config(project=project, project_config=config, args=args)


# Test running something through a shell, allowing it to find the
# executable
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_shell(cli, datafiles):
    project = str(datafiles)

    result = execute_shell(cli, project, ["echo", "Ponies!"])
    assert result.exit_code == 0
    assert result.output == "Ponies!\n"


# Test running an executable directly
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_executable(cli, datafiles):
    project = str(datafiles)

    result = execute_shell(cli, project, ["/bin/echo", "Horseys!"])
    assert result.exit_code == 0
    assert result.output == "Horseys!\n"


# Test shell environment variable explicit assignments
@pytest.mark.parametrize("animal", [("Horse"), ("Pony")])
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
# This test seems to fail or pass depending on if this file is run or the hole test suite
def test_env_assign(cli, datafiles, animal):
    project = str(datafiles)
    expected = animal + "\n"

    result = execute_shell(
        cli, project, ["/bin/sh", "-c", "echo ${ANIMAL}"], config={"shell": {"environment": {"ANIMAL": animal}}}
    )

    assert result.exit_code == 0
    assert result.output == expected


# Test shell environment variable explicit assignments with host env var expansion
@pytest.mark.parametrize("animal", [("Horse"), ("Pony")])
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
# This test seems to fail or pass depending on if this file is run or the hole test suite
def test_env_assign_expand_host_environ(cli, datafiles, animal):
    project = str(datafiles)
    expected = "The animal is: {}\n".format(animal)

    os.environ["BEAST"] = animal

    result = execute_shell(
        cli,
        project,
        ["/bin/sh", "-c", "echo ${ANIMAL}"],
        config={"shell": {"environment": {"ANIMAL": "The animal is: ${BEAST}"}}},
    )

    assert result.exit_code == 0
    assert result.output == expected


# Test that shell environment variable explicit assignments are discarded
# when running an isolated shell
@pytest.mark.parametrize("animal", [("Horse"), ("Pony")])
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
# This test seems to faili or pass depending on if this file is run or the hole test suite
def test_env_assign_isolated(cli, datafiles, animal):
    project = str(datafiles)
    result = execute_shell(
        cli,
        project,
        ["/bin/sh", "-c", "echo ${ANIMAL}"],
        isolate=True,
        config={"shell": {"environment": {"ANIMAL": animal}}},
    )

    assert result.exit_code == 0
    assert result.output == "\n"


# Test running an executable in a runtime with no shell (i.e., no
# /bin/sh)
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
@pytest.mark.xfail(
    HAVE_SANDBOX == "buildbox-run" and BUILDBOX_RUN == "buildbox-run-userchroot",
    reason="buildbox-run-userchroot requires a shell",
)
def test_no_shell(cli, datafiles):
    project = str(datafiles)
    element_path = os.path.join(project, "elements")
    element_name = "shell/no-shell.bst"

    # Create an element that removes /bin/sh from the base runtime
    element = {
        "kind": "script",
        "depends": [{"filename": "base.bst", "type": "build"}],
        "variables": {"install-root": "/"},
        "config": {"commands": ["rm /bin/sh"]},
    }
    os.makedirs(os.path.dirname(os.path.join(element_path, element_name)), exist_ok=True)
    _yaml.roundtrip_dump(element, os.path.join(element_path, element_name))

    result = execute_shell(cli, project, ["/bin/echo", "Pegasissies!"], element=element_name)
    assert result.exit_code == 0
    assert result.output == "Pegasissies!\n"


# Test that bind mounts defined in project.conf work
@pytest.mark.parametrize("path", [("/etc/pony.conf"), ("/usr/share/pony/pony.txt"), (None)])
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
@pytest.mark.xfail(
    HAVE_SANDBOX == "buildbox-run" and BUILDBOX_RUN != "buildbox-run-bubblewrap",
    reason="Only available with bubblewrap",
)
def test_host_files(cli, datafiles, path):
    project = str(datafiles)
    ponyfile = os.path.join(project, "files", "shell-mount", "pony.txt")
    if path is None:
        result = execute_shell(cli, project, ["cat", ponyfile], config={"shell": {"host-files": [ponyfile]}})
    else:
        result = execute_shell(
            cli, project, ["cat", path], config={"shell": {"host-files": [{"host_path": ponyfile, "path": path}]}}
        )
    assert result.exit_code == 0
    assert result.output == "pony\n"


# Test that bind mounts defined in project.conf work
@pytest.mark.parametrize("path", [("/etc"), ("/usr/share/pony")])
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
@pytest.mark.xfail(
    HAVE_SANDBOX == "buildbox-run" and BUILDBOX_RUN != "buildbox-run-bubblewrap",
    reason="Only available with bubblewrap",
)
def test_host_files_expand_environ(cli, datafiles, path):
    project = str(datafiles)
    hostpath = os.path.join(project, "files", "shell-mount")
    fullpath = os.path.join(path, "pony.txt")

    os.environ["BASE_PONY"] = path
    os.environ["HOST_PONY_PATH"] = hostpath

    result = execute_shell(
        cli,
        project,
        ["cat", fullpath],
        config={
            "shell": {"host-files": [{"host_path": "${HOST_PONY_PATH}/pony.txt", "path": "${BASE_PONY}/pony.txt"}]}
        },
    )
    assert result.exit_code == 0
    assert result.output == "pony\n"


# Test that bind mounts defined in project.conf dont mount in isolation
@pytest.mark.parametrize("path", [("/etc/pony.conf"), ("/usr/share/pony/pony.txt")])
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_isolated_no_mount(cli, datafiles, path):
    project = str(datafiles)
    ponyfile = os.path.join(project, "files", "shell-mount", "pony.txt")
    result = execute_shell(
        cli,
        project,
        ["cat", path],
        isolate=True,
        config={"shell": {"host-files": [{"host_path": ponyfile, "path": path}]}},
    )
    assert result.exit_code != 0
    assert path in result.stderr
    assert "No such file or directory" in result.stderr


# Test that we warn about non-existing files on the host if the mount is not
# declared as optional, and that there is no warning if it is optional
@pytest.mark.parametrize("optional", [("mandatory"), ("optional")])
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_host_files_missing(cli, datafiles, optional):
    project = str(datafiles)
    ponyfile = os.path.join(project, "files", "shell-mount", "horsy.txt")

    option = optional == "optional"

    # Assert that we did successfully run something in the shell anyway
    result = execute_shell(
        cli,
        project,
        ["echo", "Hello"],
        config={"shell": {"host-files": [{"host_path": ponyfile, "path": "/etc/pony.conf", "optional": option}]}},
    )
    assert result.exit_code == 0
    assert result.output == "Hello\n"

    if option:
        # Assert that there was no warning about the mount
        assert ponyfile not in result.stderr
    else:
        # Assert that there was a warning about the mount
        assert ponyfile in result.stderr


# Test that bind mounts defined in project.conf work
@pytest.mark.parametrize("path", [("/etc/pony.conf"), ("/usr/share/pony/pony.txt")])
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
@pytest.mark.xfail(
    HAVE_SANDBOX == "buildbox-run" and BUILDBOX_RUN != "buildbox-run-bubblewrap",
    reason="Only available with bubblewrap",
)
def test_cli_mount(cli, datafiles, path):
    project = str(datafiles)
    ponyfile = os.path.join(project, "files", "shell-mount", "pony.txt")

    result = execute_shell(cli, project, ["cat", path], mount=(ponyfile, path))
    assert result.exit_code == 0
    assert result.output == "pony\n"


# Test that we can see the workspace files in a shell
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_workspace_visible(cli, datafiles):
    project = str(datafiles)
    workspace = os.path.join(cli.directory, "workspace")
    element_name = "workspace/workspace-mount-fail.bst"

    # Open a workspace on our build failing element
    #
    res = cli.run(project=project, args=["workspace", "open", "--directory", workspace, element_name])
    assert res.exit_code == 0

    # Ensure the dependencies of our build failing element are built
    result = cli.run(project=project, args=["build", "base.bst"])
    assert result.exit_code == 0

    # Obtain a copy of the hello.c content from the workspace
    #
    workspace_hello_path = os.path.join(cli.directory, "workspace", "hello.c")
    assert os.path.exists(workspace_hello_path)
    with open(workspace_hello_path, "r", encoding="utf-8") as f:
        workspace_hello = f.read()

    # Cat the hello.c file from a bst shell command, and assert
    # that we got the same content here
    #
    result = cli.run(project=project, args=["shell", "--build", element_name, "--", "cat", "hello.c"])
    assert result.exit_code == 0
    assert result.output == workspace_hello


# Test system integration commands can access devices in /dev
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_integration_devices(cli, datafiles):
    project = str(datafiles)
    element_name = "integration.bst"

    result = execute_shell(cli, project, ["true"], element=element_name)
    assert result.exit_code == 0


# Test that a shell can be opened from an external workspace
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize("build_shell", [("build"), ("nobuild")])
@pytest.mark.parametrize("guess_element", [True, False], ids=["guess", "no-guess"])
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_integration_external_workspace(cli, tmpdir_factory, datafiles, build_shell, guess_element):
    tmpdir = tmpdir_factory.mktemp(os.path.basename(__file__))
    project = str(datafiles)
    element_name = "autotools/amhello.bst"
    workspace_dir = os.path.join(str(tmpdir), "workspace")

    if guess_element:
        # Mutate the project.conf to use a default shell command
        project_file = os.path.join(project, "project.conf")
        config_text = "shell:\n  command: ['true']\n"
        with open(project_file, "a", encoding="utf-8") as f:
            f.write(config_text)

    result = cli.run(project=project, args=["workspace", "open", "--directory", workspace_dir, element_name])
    result.assert_success()

    result = cli.run(project=project, args=["-C", workspace_dir, "build", element_name])
    result.assert_success()

    command = ["-C", workspace_dir, "shell"]
    if build_shell == "build":
        command.append("--build")
    if not guess_element:
        command.extend([element_name, "--", "true"])
    result = cli.run(project=project, cwd=workspace_dir, args=command)
    result.assert_success()


@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_integration_partial_artifact(cli, datafiles, tmpdir, integration_cache):

    project = str(datafiles)
    element_name = "autotools/amhello.bst"

    # push to an artifact server so we can pull from it later.
    with create_artifact_share(os.path.join(str(tmpdir), "artifactshare")) as share:
        cli.configure({"artifacts": {"servers": [{"url": share.repo, "push": True}]}})
        result = cli.run(project=project, args=["build", element_name])
        result.assert_success()

        # If the build is cached then it might not push to the artifact cache
        result = cli.run(project=project, args=["artifact", "push", element_name])
        result.assert_success()

        result = cli.run(project=project, args=["shell", element_name])
        result.assert_success()

        # do a checkout and get the digest of the hello binary.
        result = cli.run(
            project=project,
            args=[
                "artifact",
                "checkout",
                "--deps",
                "none",
                "--directory",
                os.path.join(str(tmpdir), "tmp"),
                "autotools/amhello.bst",
            ],
        )
        result.assert_success()
        digest = utils.sha256sum(os.path.join(str(tmpdir), "tmp", "usr", "bin", "hello"))

        # Remove the binary from the CAS
        cachedir = cli.config["cachedir"]
        objpath = os.path.join(cachedir, "cas", "objects", digest[:2], digest[2:])
        os.unlink(objpath)

        # check shell doesn't work when it cannot pull the missing bits
        cli.configure({"artifacts": {}})
        result = cli.run(project=project, args=["shell", element_name, "--", "hello"])
        result.assert_main_error(ErrorDomain.APP, "shell-missing-deps")

        # check the artifact gets completed with access to the remote
        cli.configure({"artifacts": {"servers": [{"url": share.repo, "push": True}]}})
        result = cli.run(project=project, args=["shell", element_name, "--", "hello"])
        result.assert_success()
        assert "autotools/amhello.bst" in result.get_pulled_elements()


# Test that the sources are fetched automatically when opening a build shell
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_build_shell_fetch(cli, datafiles):
    project = str(datafiles)
    element_name = "build-shell-fetch.bst"

    # Create a file with unique contents such that it cannot be in the cache already
    test_filepath = os.path.join(project, "files", "hello.txt")
    test_message = "Hello World! {}".format(uuid.uuid4())
    with open(test_filepath, "w", encoding="utf-8") as f:
        f.write(test_message)
    checksum = utils.sha256sum(test_filepath)

    # Create an element that has this unique file as a source
    element = {
        "kind": "manual",
        "depends": ["base.bst"],
        "sources": [{"kind": "remote", "url": "project_dir:/files/hello.txt", "ref": checksum}],
    }
    _yaml.roundtrip_dump(element, os.path.join(project, "elements", element_name))

    # Ensure our dependencies are cached
    result = cli.run(project=project, args=["build", "base.bst"])
    result.assert_success()

    # Ensure our sources are not cached
    assert cli.get_element_state(project, element_name) == "fetch needed"

    # Launching a shell should fetch any uncached sources
    result = cli.run(project=project, args=["shell", "--build", element_name, "cat", "hello.txt"])
    result.assert_success()
    assert result.output == test_message


@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.skipif(not HAVE_SANDBOX, reason="Only available with a functioning sandbox")
def test_shell_artifact_failure(cli, datafiles):
    project = str(datafiles)

    # This happens to be the artifact name of "autotools/amhello.bst" if we built it, but we don't
    # need to build it for this test.
    artifact_name = "test/autotools-amhello/847d23cd0e61c54a6beca9071b64643388ad2ab783191358b2a499fe77e86563"

    result = cli.run(project=project, args=["shell", artifact_name, "--", "hello"])
    result.assert_main_error(ErrorDomain.APP, "only-buildtrees-supported")
