# Copyright 2021-2022 The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Unit tests for the sbuild task support on the worker client."""
import itertools
from unittest import TestCase, mock

from debusine.tasks import TaskConfigError
from debusine.tasks.sbuild import Sbuild


class SbuildTaskTests(TestCase):
    """Test the SbuildTask class."""

    SAMPLE_TASK_DATA = {
        "input": {
            "source_package_url": (
                "http://deb.debian.org/pool/pillow_8.1.2+dfsg-0.3.dsc"
            )
        },
        "distribution": "bullseye",
        "host_architecture": "amd64",
        "build_components": [
            "any",
            "all",
        ],
        "sbuild_options": [
            "--post-build-commands=/usr/local/bin/post-process %SBUILD_CHANGES",
        ],
    }

    def setUp(self):
        """
        Set a path to the ontology files used in the debusine tests.

        If the worker is moved to a separate source package, this will
        need to be updated.
        """
        self.task = Sbuild()
        self.task.logger.disabled = True

        patcher = mock.patch.object(self.task, "_call_schroot_list")
        self.schroot_list_mock = patcher.start()
        self.schroot_list_mock.return_value = "chroot:bullseye-amd64-sbuild"
        self.addCleanup(patcher.stop)

        patcher = mock.patch.object(self.task, "_call_dpkg_architecture")
        self.dpkg_architecture_mock = patcher.start()
        self.dpkg_architecture_mock.return_value = "amd64"
        self.addCleanup(patcher.stop)

    def mock_cmdline(self, cmdline: list):
        """Patch self.task to return cmdline."""
        patcher = mock.patch.object(self.task, "_cmdline")
        self.cmdline_mock = patcher.start()
        self.cmdline_mock.return_value = cmdline
        self.addCleanup(patcher.stop)

    def configure_task(
        self, task_data: dict = None, override: dict = None, remove: list = None
    ):
        """Run self.task.configure(task_data) with different inputs."""
        if task_data is None:
            task_data = self.SAMPLE_TASK_DATA.copy()
        if override:
            for key, value in override.items():
                task_data[key] = value
        if remove:
            for key in remove:
                task_data.pop(key, None)
        self.task.configure(task_data)

    def test_configure_fails_with_missing_required_data(self):
        """Configure fails with missing required keys in task_data."""
        for key in ("input", "distribution", "host_architecture"):
            with self.subTest(f"Configure with key {key} missing"):
                with self.assertRaises(TaskConfigError):
                    self.configure_task(remove=[key])

    def test_configure_fails_with_missing_source_package_url(self):
        """Configure fails with missing source_package_url."""
        task_data = self.SAMPLE_TASK_DATA.copy()
        task_data["input"] = task_data["input"].copy()
        del task_data["input"]["source_package_url"]

        with self.assertRaises(TaskConfigError):
            self.configure_task(task_data=task_data)

    def test_configure_sets_default_values(self):
        """Optional task data have good default values."""
        self.configure_task(remove=["build_components", "sbuild_options"])

        self.assertEqual(self.task.data.get("build_components"), ["any"])
        self.assertEqual(self.task.data.get("sbuild_options"), [])

    def test_configure_fails_with_bad_build_components(self):
        """Configure fails with invalid build components."""
        with self.assertRaises(TaskConfigError):
            self.configure_task(override={"build_components": ["foo", "any"]})

    def test_update_chroots_list_skips_non_desired_chroots(self):
        """source:foo and non foo-sbuild chroots are ignored."""
        self.schroot_list_mock.return_value = (
            "source:jessie-amd64-sbuild\nchroot:buster-amd64\n"
            "chroot:wanted-sbuild"
        )

        self.task._update_chroots_list()
        self.task._update_chroots_list()  # second call triggers no-op branch

        self.assertEqual(self.task.chroots, ["wanted"])

    def test_cmdline_starts_with_sbuild_no_clean(self):
        """Test fixed command line parameters."""
        self.configure_task()

        cmdline = self.task._cmdline()

        self.assertEqual(cmdline[0], "sbuild")
        self.assertEqual(cmdline[1], "--no-clean")

    def test_cmdline_ends_with_source_package_url(self):
        """Source package url is last parameter."""
        self.configure_task(
            override={"input": {"source_package_url": "foobar.dsc"}}
        )

        cmdline = self.task._cmdline()

        self.assertEqual(cmdline[-1], "foobar.dsc")

    def test_cmdline_contains_arch_parameter(self):
        """Ensure --arch parameter is computed from data."""
        self.schroot_list_mock.return_value = "chroot:bullseye-mipsel-sbuild"
        self.configure_task(override={"host_architecture": "mipsel"})

        cmdline = self.task._cmdline()

        self.assertIn("--arch=mipsel", cmdline)

    def test_cmdline_contains_dist_parameter(self):
        """Ensure --dist parameter is computed from data."""
        self.schroot_list_mock.return_value = "chroot:jessie-amd64-sbuild"
        self.configure_task(override={"distribution": "jessie"})

        cmdline = self.task._cmdline()

        self.assertIn("--dist=jessie", cmdline)

    def test_cmdline_translation_of_build_components(self):
        """Test handling of build components."""
        option_mapping = {
            "any": ["--arch-any", "--no-arch-any"],
            "all": ["--arch-all", "--no-arch-all"],
            "source": ["--source", "--no-source"],
        }
        keywords = option_mapping.keys()
        for combination in itertools.chain(
            itertools.combinations(keywords, 1),
            itertools.combinations(keywords, 2),
            itertools.combinations(keywords, 3),
        ):
            with self.subTest(f"Test build_components={combination}"):
                self.configure_task(
                    override={"build_components": list(combination)}
                )
                cmdline = self.task._cmdline()
                for key, value in option_mapping.items():
                    if key in combination:
                        self.assertIn(value[0], cmdline)
                        self.assertNotIn(value[1], cmdline)
                    else:
                        self.assertNotIn(value[0], cmdline)
                        self.assertIn(value[1], cmdline)

    def test_cmdline_contains_sbuild_options(self):
        """Ensure sbuild_options are passed just before source package url."""
        self.configure_task(override={"sbuild_options": ["--foobar"]})

        cmdline = self.task._cmdline()

        self.assertEqual("--foobar", cmdline[-2])

    def test_execute_fails_with_unsupported_distribution(self):
        """execute() fails when the requested distribution is unsupported."""
        # Default mocked setup only support bullseye
        self.configure_task(override={"distribution": "jessie"})
        with self.assertRaises(TaskConfigError):
            self.task.execute()

    def test_execute_fails_with_no_available_chroots(self):
        """execute() fails when no sbuild chroots are available."""
        self.schroot_list_mock.return_value = ""
        self.configure_task()
        with self.assertRaises(TaskConfigError):
            self.task.execute()

    def test_execute_returns_true_with_successful_command(self):
        """The execute method returns True when cmd returns 0."""
        self.mock_cmdline(["true"])

        self.configure_task()

        self.assertTrue(self.task.execute())

        self.cmdline_mock.assert_called()

    def test_execute_returns_false(self):
        """The execute method returns False when cmd returns 1."""
        self.mock_cmdline(["false"])

        self.configure_task()

        self.assertFalse(self.task.execute())

    def test_analyze_worker(self):
        """Test the analyze_worker() method."""
        self.schroot_list_mock.return_value = "chroot:jessie-arm64-sbuild"
        self.dpkg_architecture_mock.return_value = "mipsel"

        metadata = self.task.analyze_worker()

        self.assertEqual(metadata["sbuild:chroots"], ["jessie-arm64"])
        self.assertEqual(metadata["sbuild:host_architecture"], "mipsel")

    def worker_metadata(self):
        """Return worker_metadata with sbuild:version=self.task.TASK_VERSION."""
        return {
            "sbuild:version": self.task.TASK_VERSION,
            "sbuild:chroots": ["bullseye-amd64"],
            "sbuild:host_architecture": "amd64",
        }

    def test_can_run_on_good_case(self):
        """Ensure can_run_on returns True if all conditions are met."""
        worker_metadata = self.worker_metadata()
        self.configure_task()

        self.assertTrue(self.task.can_run_on(worker_metadata))

    def test_can_run_mismatched_task_version(self):
        """Ensure can_run_on returns False for mismatched versions."""
        worker_metadata = self.worker_metadata()
        worker_metadata["sbuild:version"] += 1
        self.configure_task()

        self.assertFalse(self.task.can_run_on(worker_metadata))

    def test_can_run_chroot_not_available(self):
        """Ensure can_run_on returns False when needed chroot is not there."""
        worker_metadata = self.worker_metadata()
        worker_metadata["sbuild:chroots"] = ["jessie-arm64"]
        self.configure_task()

        self.assertFalse(self.task.can_run_on(worker_metadata))

    def test_can_run_missing_distribution(self):
        """can_run_on returns False if sbuild:chroots is not in the metadata."""
        self.configure_task()

        self.assertFalse(self.task.can_run_on({"sbuild:version": 1}))
