#
#  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 import _yaml
from buildstream.exceptions import ErrorDomain, LoadErrorReason
from buildstream._testing import cli  # pylint: disable=unused-import
from buildstream._testing import create_repo
from tests.testutils import generate_junction

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


def update_project(project_path, updated_configuration):
    project_conf_path = os.path.join(project_path, "project.conf")
    project_conf = _yaml.roundtrip_load(project_conf_path)

    project_conf.update(updated_configuration)

    _yaml.roundtrip_dump(project_conf, project_conf_path)


#
# Test behavior of `bst show` on a junction element
#
@pytest.mark.datafiles(DATA_DIR)
def test_simple_show(cli, tmpdir, datafiles):
    project = os.path.join(str(datafiles), "simple")
    assert cli.get_element_state(project, "subproject.bst") == "junction"


#
# Test that we can build build a pipeline with a junction
#
@pytest.mark.datafiles(DATA_DIR)
def test_simple_build(cli, tmpdir, datafiles):
    project = os.path.join(str(datafiles), "simple")

    checkoutdir = os.path.join(str(tmpdir), "checkout")

    # Build, checkout
    result = cli.run(project=project, args=["build", "target.bst"])
    result.assert_success()
    result = cli.run(project=project, args=["artifact", "checkout", "target.bst", "--directory", checkoutdir])
    result.assert_success()

    # Check that the checkout contains the expected file from the subproject
    assert os.path.exists(os.path.join(checkoutdir, "base.txt"))


#
# Test failure when there is a missing project.conf
#
@pytest.mark.datafiles(DATA_DIR)
def test_junction_missing_project_conf(cli, datafiles):
    project = os.path.join(str(datafiles), "simple")

    # Just remove the project.conf from the simple test and assert the error
    os.remove(os.path.join(project, "subproject", "project.conf"))

    result = cli.run(project=project, args=["build", "target.bst"])
    result.assert_main_error(ErrorDomain.LOAD, LoadErrorReason.INVALID_JUNCTION)
    assert "target.bst [line 4 column 2]" in result.stderr


#
# Test failure when there is a missing project.conf in a workspaced junction
#
@pytest.mark.datafiles(DATA_DIR)
def test_workspaced_junction_missing_project_conf(cli, datafiles):
    project = os.path.join(str(datafiles), "simple")

    workspace_dir = os.path.join(project, "workspace")

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

    # Remove the project.conf from the workspace directory
    os.remove(os.path.join(workspace_dir, "project.conf"))

    # Assert the same missing project.conf error
    result = cli.run(project=project, args=["build", "target.bst"])
    result.assert_main_error(ErrorDomain.LOAD, LoadErrorReason.INVALID_JUNCTION)

    # Assert that we have the expected provenance encoded into the error
    assert "target.bst [line 4 column 2]" in result.stderr


#
# Test successful builds of deeply nested targets
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "target,expected",
    [
        ("target.bst", ["sub.txt", "subsub.txt"]),
        ("deeptarget.bst", ["sub.txt", "subsub.txt", "subsubsub.txt"]),
    ],
    ids=["simple", "deep"],
)
def test_nested(cli, tmpdir, datafiles, target, expected):
    project = os.path.join(str(datafiles), "nested")
    checkoutdir = os.path.join(str(tmpdir), "checkout")

    # Build, checkout
    result = cli.run(project=project, args=["build", target])
    result.assert_success()
    result = cli.run(project=project, args=["artifact", "checkout", target, "--directory", checkoutdir])
    result.assert_success()

    # Check that the checkout contains the expected files from all subprojects
    for filename in expected:
        assert os.path.exists(os.path.join(checkoutdir, filename))


#
# Test successful builds of nested junctions with deep includes
#
@pytest.mark.datafiles(DATA_DIR)
def test_nested_include(cli, tmpdir, datafiles):
    project = os.path.join(str(datafiles), "nested-include")
    checkoutdir = os.path.join(str(tmpdir), "checkout")

    # Build, checkout
    result = cli.run(project=project, args=["build", "target.bst"])
    result.assert_success()
    result = cli.run(project=project, args=["artifact", "checkout", "target.bst", "--directory", checkoutdir])
    result.assert_success()

    # Check that the checkout contains the expected files from all subprojects
    for filename in ["sub.txt", "subsub.txt"]:
        assert os.path.exists(os.path.join(checkoutdir, filename))


#
# Test successful builds of deeply nested targets with both the top-level project
# and a subproject configured to use `project.refs`
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "target,expected",
    [
        ("target.bst", ["sub.txt", "subsub.txt"]),
        ("deeptarget.bst", ["sub.txt", "subsub.txt", "subsubsub.txt"]),
    ],
    ids=["simple", "deep"],
)
def test_nested_ref_storage(cli, tmpdir, datafiles, target, expected):
    project = os.path.join(str(datafiles), "nested")
    subproject = os.path.join(project, "subproject")
    subsubproject = os.path.join(subproject, "subsubproject")

    checkoutdir = os.path.join(str(tmpdir), "checkout")

    # Configure both the top-level project and the subproject to use project.refs / junction.refs
    update_project(project, {"ref-storage": "project.refs"})
    update_project(subproject, {"ref-storage": "project.refs"})

    # Move the subsubproject from a local directory to a repo to test `junction.refs` in the subproject
    subsubref = generate_junction(
        tmpdir, subsubproject, os.path.join(subproject, "subsubproject.bst"), store_ref=False
    )
    shutil.rmtree(subsubproject)

    # Store ref to the subsubproject repo in the subproject's `junction.refs`
    project_refs = {"projects": {"subtest": {"subsubproject.bst": [{"ref": subsubref}]}}}
    _yaml.roundtrip_dump(project_refs, os.path.join(subproject, "junction.refs"))

    # Build, checkout
    result = cli.run(project=project, args=["build", target])
    result.assert_success()
    result = cli.run(project=project, args=["artifact", "checkout", target, "--directory", checkoutdir])
    result.assert_success()

    # Check that the checkout contains the expected files from all subprojects
    for filename in expected:
        assert os.path.exists(os.path.join(checkoutdir, filename))


#
# Test missing elements/junctions in subprojects
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "target,provenance",
    [
        ("target.bst", "target.bst [line 4 column 2]"),
        ("sub-target.bst", "junction-A.bst:target.bst [line 4 column 2]"),
        ("bad-junction.bst", "bad-junction.bst [line 3 column 2]"),
        ("sub-target-bad-junction.bst", "junction-A.bst:bad-junction-target.bst [line 4 column 2]"),
    ],
    ids=["subproject-target", "subsubproject-target", "local-junction", "subproject-junction"],
)
def test_missing_files(cli, datafiles, target, provenance):
    project = os.path.join(str(datafiles), "missing-element")
    result = cli.run(project=project, args=["show", target])
    result.assert_main_error(ErrorDomain.LOAD, LoadErrorReason.MISSING_FILE)

    # Assert that we have the expected provenance encoded into the error
    assert provenance in result.stderr


#
# Test various invalid junction configuraions
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "target,domain,reason,provenance",
    [
        # Test a junction which itself has dependencies
        (
            "junction-with-deps.bst",
            ErrorDomain.LOAD,
            LoadErrorReason.INVALID_JUNCTION,
            "base-with-deps.bst [line 6 column 2]",
        ),
        # Test having a dependency directly on a junction
        ("junction-dep.bst", ErrorDomain.LOAD, LoadErrorReason.INVALID_DATA, "junction-dep.bst [line 3 column 2]"),
        # Test that we error correctly when we junction-depend on a non-junction
        (
            "junctiondep-not-a-junction.bst",
            ErrorDomain.LOAD,
            LoadErrorReason.INVALID_DATA,
            "junctiondep-not-a-junction.bst [line 3 column 2]",
        ),
        # Test that overriding a subproject junction with the junction
        # declaring the override itself will result in an error
        (
            "target-self-override.bst",
            ErrorDomain.ELEMENT,
            "override-junction-with-self",
            "subproject-self-override.bst [line 16 column 20]",
        ),
    ],
    ids=["junction-with-deps", "deps-on-junction", "use-element-as-junction", "override-with-self"],
)
def test_invalid(cli, datafiles, target, domain, reason, provenance):
    project = os.path.join(str(datafiles), "invalid")
    result = cli.run(project=project, args=["build", target])
    result.assert_main_error(domain, reason)
    assert provenance in result.stderr


@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "target,expect_exists,expect_not_exists",
    [
        ("target-default.bst", "pony.txt", "horsy.txt"),
        ("target-explicit.bst", "horsy.txt", "pony.txt"),
    ],
    ids=["check-values", "set-explicit-values"],
)
def test_options(cli, tmpdir, datafiles, target, expect_exists, expect_not_exists):
    project = os.path.join(str(datafiles), "options")
    checkoutdir = os.path.join(str(tmpdir), "checkout")

    # Build, checkout
    result = cli.run(project=project, args=["build", target])
    result.assert_success()
    result = cli.run(project=project, args=["artifact", "checkout", target, "--directory", checkoutdir])
    result.assert_success()

    assert os.path.exists(os.path.join(checkoutdir, expect_exists))
    assert not os.path.exists(os.path.join(checkoutdir, expect_not_exists))


#
# Test propagation of options through a junction
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "animal,expect_exists,expect_not_exists",
    [
        ("pony", "pony.txt", "horsy.txt"),
        ("horsy", "horsy.txt", "pony.txt"),
    ],
    ids=["pony", "horsy"],
)
def test_options_propagate(cli, tmpdir, datafiles, animal, expect_exists, expect_not_exists):
    project = os.path.join(str(datafiles), "options")
    checkoutdir = os.path.join(str(tmpdir), "checkout")

    update_project(
        project,
        {
            "options": {
                "animal": {
                    "type": "enum",
                    "description": "The kind of animal",
                    "values": ["pony", "horsy"],
                    "default": "pony",
                    "variable": "animal",
                }
            }
        },
    )

    # Build, checkout
    result = cli.run(project=project, args=["--option", "animal", animal, "build", "target-propagate.bst"])
    result.assert_success()
    result = cli.run(
        project=project,
        args=[
            "--option",
            "animal",
            animal,
            "artifact",
            "checkout",
            "target-propagate.bst",
            "--directory",
            checkoutdir,
        ],
    )
    result.assert_success()

    assert os.path.exists(os.path.join(checkoutdir, expect_exists))
    assert not os.path.exists(os.path.join(checkoutdir, expect_not_exists))


#
# A lot of testing is using local sources for the junctions for
# speed and convenience, however there are some internal optimizations
# for local sources, so we need to test some things using a real
# source which involves triggering fetches.
#
# We use the tar source for this since it is a core plugin.
#
@pytest.mark.datafiles(DATA_DIR)
def test_tar_show(cli, tmpdir, datafiles):
    project = os.path.join(str(datafiles), "use-repo")

    # Create the repo from 'baserepo' subdir
    repo = create_repo("tar", str(tmpdir))
    ref = repo.create(os.path.join(project, "baserepo"))

    # Write out junction element with tar source
    element = {"kind": "junction", "sources": [repo.source_config(ref=ref)]}
    _yaml.roundtrip_dump(element, os.path.join(project, "base.bst"))

    # Check that bst show succeeds with implicit subproject fetching and the
    # pipeline includes the subproject element
    element_list = cli.get_pipeline(project, ["target.bst"])
    assert "base.bst:target.bst" in element_list


@pytest.mark.datafiles(DATA_DIR)
def test_tar_build(cli, tmpdir, datafiles):
    project = os.path.join(str(datafiles), "use-repo")
    checkoutdir = os.path.join(str(tmpdir), "checkout")

    # Create the repo from 'baserepo' subdir
    repo = create_repo("tar", str(tmpdir))
    ref = repo.create(os.path.join(project, "baserepo"))

    # Write out junction element with tar source
    element = {"kind": "junction", "sources": [repo.source_config(ref=ref)]}
    _yaml.roundtrip_dump(element, os.path.join(project, "base.bst"))

    # Build (with implicit fetch of subproject), checkout
    result = cli.run(project=project, args=["build", "target.bst"])
    result.assert_success()
    result = cli.run(project=project, args=["artifact", "checkout", "target.bst", "--directory", checkoutdir])
    result.assert_success()

    # Check that the checkout contains the expected file from the subproject
    assert os.path.exists(os.path.join(checkoutdir, "base.txt"))


@pytest.mark.datafiles(DATA_DIR)
def test_tar_missing_project_conf(cli, tmpdir, datafiles):
    project = datafiles / "use-repo"

    # Remove the project.conf from this repo
    os.remove(datafiles / "use-repo" / "baserepo" / "project.conf")

    # Create the repo from 'base' subdir
    repo = create_repo("tar", str(tmpdir))
    ref = repo.create(os.path.join(project, "baserepo"))

    # Write out junction element with tar source
    element = {"kind": "junction", "sources": [repo.source_config(ref=ref)]}
    _yaml.roundtrip_dump(element, str(project / "base.bst"))

    result = cli.run(project=project, args=["build", "target.bst"])
    result.assert_main_error(ErrorDomain.LOAD, LoadErrorReason.INVALID_JUNCTION)

    # Assert that we have the expected provenance encoded into the error
    assert "target.bst [line 3 column 2]" in result.stderr


@pytest.mark.datafiles(DATA_DIR)
def test_build_tar_cross_junction_names(cli, tmpdir, datafiles):
    project = os.path.join(str(datafiles), "use-repo")
    checkoutdir = os.path.join(str(tmpdir), "checkout")

    # Create the repo from 'base' subdir
    repo = create_repo("tar", str(tmpdir))
    ref = repo.create(os.path.join(project, "baserepo"))

    # Write out junction element with tar source
    element = {"kind": "junction", "sources": [repo.source_config(ref=ref)]}
    _yaml.roundtrip_dump(element, os.path.join(project, "base.bst"))

    # Build (with implicit fetch of subproject), checkout
    result = cli.run(project=project, args=["build", "base.bst:target.bst"])
    result.assert_success()
    result = cli.run(project=project, args=["artifact", "checkout", "base.bst:target.bst", "--directory", checkoutdir])
    result.assert_success()

    # Check that the checkout contains the expected files from both projects
    assert os.path.exists(os.path.join(checkoutdir, "base.txt"))


@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "target",
    [
        "junction-full-path.bst",
        "element-full-path.bst",
        "subproject.bst:subsubproject.bst:subsubsubproject.bst:target.bst",
    ],
    ids=["junction", "element", "command-line"],
)
def test_full_path(cli, tmpdir, datafiles, target):
    project = os.path.join(str(datafiles), "full-path")
    checkoutdir = os.path.join(str(tmpdir), "checkout")

    # Build, checkout
    result = cli.run(project=project, args=["build", target])
    result.assert_success()
    result = cli.run(project=project, args=["artifact", "checkout", target, "--directory", checkoutdir])
    result.assert_success()

    # Check that the checkout contains the expected file from base
    assert os.path.exists(os.path.join(checkoutdir, "subsubsub.txt"))


@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "target,provenance",
    [
        ("junction-full-path-notfound.bst", "junction-full-path-notfound.bst [line 3 column 2]"),
        ("element-full-path-notfound.bst", "element-full-path-notfound.bst [line 3 column 2]"),
        ("subproject.bst:subsubproject.bst:pony.bst", None),
    ],
    ids=["junction", "element", "command-line"],
)
def test_full_path_not_found(cli, tmpdir, datafiles, target, provenance):
    project = os.path.join(str(datafiles), "full-path")

    # Build
    result = cli.run(project=project, args=["build", target])
    result.assert_main_error(ErrorDomain.LOAD, LoadErrorReason.MISSING_FILE)

    # Check that provenance was provided if expected
    if provenance:
        assert provenance in result.stderr


#
# Test overridding elements
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "target,expected",
    [
        # Override an element in a subproject, this dependency will depend on
        # the same element in the subproject as the overridden element did.
        ("override-subproject-element.bst", ["element.txt", "subelement-override.txt", "subdep.txt"]),
        # Override an element in a subproject while depending on an element which depends
        # on the overridden element, in this case we ensure that the reverse dependencies
        # of the replaced element are built against the replacement.
        ("override-subproject-dep.bst", ["element.txt", "sub.txt", "subdep-override.txt"]),
        # Override an element in a subproject with a local link element which points to another
        # element in the same subproject.
        ("override-subproject-element-with-link.bst", ["element.txt", "sub-alternative.txt", "subdep.txt"]),
        # Override a link to an element in a subproject with an alternative element
        # in the same subproject.
        ("override-subproject-element-using-link.bst", ["element.txt", "sub-alternative.txt", "subdep.txt"]),
        # Override an element in a nested subsubproject, where the intermediate project also overrides
        # the same element
        ("override-subsubproject.bst", ["element.txt", "subsub.txt", "subdep-override.txt"]),
    ],
    ids=[
        "element-with-deps",
        "dependency-of-element",
        "with-link",
        "using-link",
        "priority",
    ],
)
def test_override_element(cli, tmpdir, datafiles, target, expected):
    project = os.path.join(str(datafiles), "override-element")
    checkoutdir = os.path.join(str(tmpdir), "checkout")

    # Build, checkout
    result = cli.run(project=project, args=["build", target])
    result.assert_success()
    result = cli.run(project=project, args=["artifact", "checkout", target, "--directory", checkoutdir])
    result.assert_success()

    # Check that the checkout contains the expected file(s)
    for expect in expected:
        assert os.path.exists(os.path.join(checkoutdir, expect))


#
# Test overridding junctions
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "target,expected",
    [
        # Test that we can override a subproject junction of a subproject
        ("target-overridden-subsubproject.bst", "subsubsub.txt"),
        # Test that we can override a subproject junction of a subproject, when that junction is a link
        ("target-overridden-subsubproject-link.bst", "subsubsub.txt"),
        # Test that we can override a subproject junction of a subproject's subproject
        ("target-overridden-subsubsubproject.bst", "surprise.txt"),
        # Test that we can override a subproject junction of a subproject's subproject, which using links to address them
        ("target-overridden-subsubsubproject-link.bst", "surprise.txt"),
        # Test that we can override a subproject junction of a subproject's subproject, using various levels of links indirection
        ("target-overridden-subsubsubproject-indirect-link.bst", "surprise.txt"),
        # Test that we can override a subproject junction with a deep subproject path
        ("target-overridden-with-deepsubproject.bst", "deepsurprise.txt"),
    ],
    ids=[
        "override-subproject",
        "override-subproject-link",
        "override-subsubproject",
        "override-subsubproject-link",
        "override-subsubproject-indirect-link",
        "override-subproject-with-subsubproject",
    ],
)
def test_override_junction(cli, tmpdir, datafiles, target, expected):
    project = os.path.join(str(datafiles), "overrides")
    checkoutdir = os.path.join(str(tmpdir), "checkout")

    # Build, checkout
    result = cli.run(project=project, args=["build", target])
    result.assert_success()
    result = cli.run(project=project, args=["artifact", "checkout", target, "--directory", checkoutdir])
    result.assert_success()

    # Check that the checkout contains the expected file
    assert os.path.exists(os.path.join(checkoutdir, expected))


# Tests a situation where the same deep subproject is overridden
# more than once.
#
@pytest.mark.datafiles(DATA_DIR)
def test_override_twice(cli, tmpdir, datafiles):
    project = os.path.join(str(datafiles), "override-twice")
    checkoutdir = os.path.join(str(tmpdir), "checkout")

    # Build, checkout
    result = cli.run(project=project, args=["build", "target.bst"])
    result.assert_success()
    result = cli.run(project=project, args=["artifact", "checkout", "target.bst", "--directory", checkoutdir])
    result.assert_success()

    # Check that the checkout contains the expected file
    assert os.path.exists(os.path.join(checkoutdir, "overridden-again.txt"))


# Tests the case where we override an element in a self junction
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "target,expected_result",
    [
        ("target.bst", "pony"),
        ("self-junction.bst:target.bst", "horsy"),
    ],
    ids=["direct-target", "override-target"],
)
def test_override_self(cli, datafiles, target, expected_result):
    project = os.path.join(str(datafiles), "override-self")
    result = cli.run(
        project=project,
        silent=True,
        args=["show", "--deps", "none", "--format", "%{vars}", target],
    )
    result.assert_success()
    result_vars = _yaml.load_data(result.output)
    assert result_vars.get_str("animal") == expected_result


@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "target,expected_result",
    [
        ("subproject.bst:target.bst", "pony"),
        ("subproject.bst:self-junction.bst:target.bst", "horsy"),
        ("link.bst", "pony"),
        ("nested-link.bst", "horsy"),
    ],
    ids=[
        "direct-target",
        "override-target",
        "link-target",
        "link-override-target",
    ],
)
def test_override_self_link(cli, datafiles, target, expected_result):
    project = os.path.join(str(datafiles), "override-self-link")
    result = cli.run(
        project=project,
        silent=True,
        args=["show", "--deps", "none", "--format", "%{vars}", target],
    )
    result.assert_success()
    result_vars = _yaml.load_data(result.output)
    assert result_vars.get_str("animal") == expected_result


#
# Test conflicting junction scenarios
#
# Note here we assert 2 provenances, we want to ensure that both
# provenances leading up to the use of a project are accounted for
# in a conflicting junction error.
#
# The second provenance can be None, because there will be no
# provenance for the originally loaded project if it was the toplevel
# project, or in some cases when a full path to a deep element was
# specified directly on the command line.
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "project_dir,target,provenances",
    [
        # Test a stack element which depends directly on the same project twice
        (
            "conflicts",
            "simple-conflict.bst",
            ["simple-conflict.bst [line 5 column 2]", "simple-conflict.bst [line 4 column 2]"],
        ),
        # Test a dependency chain leading deep into a project which conflicts with the toplevel
        (
            "conflicts",
            "nested-conflict-toplevel.bst",
            ["subproject.bst:subsubproject-conflict-target.bst [line 4 column 2]"],
        ),
        # Test an attempt to override a subproject with a subproject of that same subproject through a different junction
        (
            "conflicts",
            "override-conflict.bst",
            [
                "subproject-override-conflicting-path.bst [line 13 column 23]",
                "override-conflict.bst [line 8 column 2]",
            ],
        ),
        # Same test as above, but specifying the target as a full path instead of a stack element
        (
            "conflicts",
            "subproject-override-conflicting-path.bst:subsubproject.bst:target.bst",
            ["subproject-override-conflicting-path.bst [line 13 column 23]"],
        ),
        # Test a dependency on a subproject conflicting with an include of a file from a different
        # version of the same project
        (
            "conflicts",
            "include-conflict-target.bst",
            ["include-conflict-target.bst [line 5 column 2]", "include-conflict.bst [line 4 column 7]"],
        ),
        # Test an element kind which needs to load it's plugin from a subproject, but
        # the element has a dependency on an element from a different version of the same project
        (
            "conflicts",
            "plugin-conflict.bst",
            ["project.conf [line 4 column 2]", "plugin-conflict.bst [line 4 column 2]"],
        ),
        # Test a project which subproject's the same project twice, but only lists it
        # as a duplicate via one of it's junctions.
        (
            "duplicates-simple-incomplete",
            "target.bst",
            ["target.bst [line 4 column 2]", "target.bst [line 5 column 2]"],
        ),
        # Test a project which subproject's the same project twice, but only lists it
        # as a duplicate via one of it's junctions.
        (
            "duplicates-nested-incomplete",
            "target.bst",
            ["target.bst [line 6 column 2]", "target.bst [line 4 column 2]", "target.bst [line 5 column 2]"],
        ),
        # Test a project which uses an internal subsubproject, but also uses that same subsubproject twice
        # at the toplevel, this test ensures we also get the provenance of the internal project in the error.
        (
            "internal-and-conflict",
            "target.bst",
            [
                "subproject.bst:subtarget.bst [line 10 column 2]",
                "target.bst [line 5 column 2]",
                "target.bst [line 6 column 2]",
            ],
        ),
    ],
    ids=[
        "simple",
        "nested",
        "override",
        "override-full-path",
        "include",
        "plugin",
        "incomplete-duplicates",
        "incomplete-nested-duplicates",
        "internal",
    ],
)
def test_conflict(cli, tmpdir, datafiles, project_dir, target, provenances):
    project = os.path.join(str(datafiles), project_dir)

    # Special case setup the conflicting project.conf
    if target == "plugin-conflict.bst":
        update_project(
            project,
            {
                "plugins": [
                    {
                        "origin": "junction",
                        "junction": "subproject2.bst",
                        "elements": ["found"],
                    }
                ]
            },
        )

    result = cli.run(project=project, args=["build", target])
    result.assert_main_error(ErrorDomain.LOAD, LoadErrorReason.CONFLICTING_JUNCTION)

    # Assert expected provenances
    for provenance in provenances:
        assert provenance in result.stderr


#
# Test circular references in junction override cycles
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "target,provenance1,provenance2",
    [
        # Override a subprojects subsubproject, with a subproject of the
        # subsubproject being overridden.
        (
            "target-overridden-subsubproject-circular.bst",
            "subproject-overriden-with-circular-reference.bst [line 8 column 23]",
            None,
        ),
        (
            "target-overridden-subsubproject-circular-link.bst",
            "link-subsubsubproject.bst [line 4 column 10]",
            "target-overridden-subsubproject-circular-link.bst [line 4 column 2]",
        ),
    ],
    ids=["override-self", "override-self-using-link"],
)
def test_circular_reference(cli, tmpdir, datafiles, target, provenance1, provenance2):
    project = os.path.join(str(datafiles), "circular-references")
    result = cli.run(project=project, args=["build", target])
    result.assert_main_error(ErrorDomain.LOAD, LoadErrorReason.CIRCULAR_REFERENCE)
    assert provenance1 in result.stderr
    if provenance2:
        assert provenance2 in result.stderr


#
# Test explicitly marked duplicates
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "project_dir",
    [
        # Test a project with two direct dependencies on the same project
        ("duplicates-simple"),
        # Test a project with a dependency on a project with two duplicate subprojects,
        # while additionally adding a dependency on that duplicated subproject at the toplevel
        ("duplicates-nested"),
        # Same as previous test, but duplicate the subprojects only from the toplevel,
        # ensuring that the pathing and addressing of elements works.
        ("duplicates-nested-full-path"),
        # Test a project with two direct dependencies on the same project, one of them
        # referred to via a link to the junction.
        ("duplicates-simple-link"),
        # Test a project where the toplevel duplicates a link in a subproject
        ("duplicates-nested-link1"),
        # Test a project where the toplevel duplicates a link to a nested subproject
        ("duplicates-nested-link2"),
        # Test a project which overrides the a subsubproject which is marked as a duplicate by the subproject,
        # ensure that the duplicate relationship for the subproject/subsubproject is preserved.
        ("duplicates-override-dup"),
        # Test a project which overrides a deep subproject multiple times in the hierarchy, the intermediate
        # junction to the deep subproject (which is overridden by the toplevel) marks that deep subproject as
        # a duplicate using a link element in the project.conf to mark the duplicate, this link is otherwise unused.
        ("duplicates-override-twice-link"),
    ],
    ids=[
        "simple",
        "nested",
        "nested-full-path",
        "simple-link",
        "link-in-subproject",
        "link-to-subproject",
        "overridden",
        "overridden-twice-link",
    ],
)
def test_duplicates(cli, tmpdir, datafiles, project_dir):
    project = os.path.join(str(datafiles), project_dir)

    result = cli.run(project=project, args=["build", "target.bst"])
    result.assert_success()


#
# Test errors which occur when duplicate lists refer to elements which
# don't exist.
#
# While subprojects are not loaded by virtue of searching the duplicate
# lists, we do attempt to load elements in loaded projects in order to
# ensure that we properly traverse `link` elements.
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "project_dir,provenance",
    [
        # Test a not found duplicate at the toplevel
        ("duplicates-simple-not-found", "project.conf [line 8 column 6]"),
        # Test a listed duplicate of a broken `link` target in a subproject
        ("duplicates-nested-not-found", "subproject.bst:subproject1-link.bst [line 4 column 10]"),
    ],
    ids=["simple", "broken-nested-link"],
)
def test_duplicates_not_found(cli, tmpdir, datafiles, project_dir, provenance):
    project = os.path.join(str(datafiles), project_dir)

    result = cli.run(project=project, args=["build", "target.bst"])
    result.assert_main_error(ErrorDomain.LOAD, LoadErrorReason.MISSING_FILE)

    # Check that provenance was provided if expected
    assert provenance in result.stderr


#
# Test internal projects
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "project_dir,expected_files",
    [
        # Test a project which repeats a subproject which is also
        # internal to another subproject.
        ("internal-simple", ["subsub.txt", "subsub-again.txt"]),
        # Test a project which repeats a subproject which is also
        # internal to two other subprojects.
        ("internal-double", ["subsub1.txt", "subsub2.txt", "subsub-again.txt"]),
        # Test a project which repeats a subproject which is also
        # internal to another subproject, which marks it internal using a link.
        ("internal-link", ["subsub.txt", "subsub-again.txt"]),
        # Test a project which repeats a subproject which is also internal to another
        # subproject, and also overrides that same internal subproject.
        ("internal-override", ["subsub-override.txt", "subsub-again.txt"]),
    ],
    ids=["simple", "double", "link", "override"],
)
def test_internal(cli, tmpdir, datafiles, project_dir, expected_files):
    project = os.path.join(str(datafiles), project_dir)
    checkoutdir = os.path.join(str(tmpdir), "checkout")

    result = cli.run(project=project, args=["build", "target.bst"])
    result.assert_success()
    result = cli.run(project=project, args=["artifact", "checkout", "target.bst", "--directory", checkoutdir])
    result.assert_success()

    # Check that the checkout contains the expected file
    for expected in expected_files:
        assert os.path.exists(os.path.join(checkoutdir, expected))


# This test verifies that variables declared in subproject include files
# are resolved in their respective subproject, rather than being imported
# literally and resolved in the including project.
#
@pytest.mark.datafiles(DATA_DIR)
def test_include_vars(cli, datafiles):
    project = os.path.join(str(datafiles), "include-vars")
    result = cli.run(
        project=project, silent=True, args=["show", "--deps", "none", "--format", "%{vars}", "target.bst"]
    )
    result.assert_success()
    result_vars = _yaml.load_data(result.output)
    assert result_vars.get_str("resolved") == "The animal is a horsy"


# This test verifies that project option conditional statements made
# in an include file are resolved in the context of the project where
# the include file originates.
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "use_species,expected_result",
    [
        ("True", "The species is a horsy"),
        ("False", "The animal is a horsy"),
    ],
    ids=["branch1", "branch2"],
)
def test_include_vars_optional(cli, datafiles, use_species, expected_result):
    project = os.path.join(str(datafiles), "include-vars-optional")
    result = cli.run(
        project=project,
        silent=True,
        args=["--option", "use_species", use_species, "show", "--deps", "none", "--format", "%{vars}", "target.bst"],
    )
    result.assert_success()
    result_vars = _yaml.load_data(result.output)
    assert result_vars.get_str("resolved") == expected_result


# This test verifies that project option conditional statements made
# in an include file are resolved in the context of the project where
# the include file originates.
#
@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "target",
    ["target.bst", "subproject.bst:target.bst"],
    ids=["toplevel-target", "subproject-target"],
)
@pytest.mark.parametrize(
    "animal,expected_result",
    [
        ("pony", "target pony"),
        ("horsy", "target horsy"),
    ],
    ids=["branch1", "branch2"],
)
def test_include_vars_cross_junction_element(cli, datafiles, target, animal, expected_result):
    project = os.path.join(str(datafiles), "include-complex")
    result = cli.run(
        project=project,
        silent=True,
        args=[
            "--option",
            "animal",
            animal,
            "show",
            "--deps",
            "none",
            "--format",
            "%{vars}",
            target,
        ],
    )
    result.assert_success()
    result_vars = _yaml.load_data(result.output)
    assert result_vars.get_str("target_animal_variable") == expected_result


@pytest.mark.datafiles(DATA_DIR)
@pytest.mark.parametrize(
    "target",
    ["target.bst", "subproject.bst:target.bst", "intermediate-project.bst:subproject.bst:target.bst"],
    ids=["toplevel-target", "subproject-target", "nested-link-target"],
)
@pytest.mark.parametrize(
    "animal,expected_result",
    [
        ("pony", "target pony"),
        ("horsy", "target horsy"),
    ],
    ids=["branch1", "branch2"],
)
def test_include_vars_cross_junction_element_nested(cli, datafiles, target, animal, expected_result):
    project = os.path.join(str(datafiles), "include-complex-nested")
    result = cli.run(
        project=project,
        silent=True,
        args=[
            "--option",
            "animal",
            animal,
            "show",
            "--deps",
            "none",
            "--format",
            "%{vars}",
            target,
        ],
    )
    result.assert_success()
    result_vars = _yaml.load_data(result.output)
    assert result_vars.get_str("target_animal_variable") == expected_result
