# Copyright © 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 DebusineTaskConfiguration collections."""

import unittest
from typing import Any, ClassVar, override
from unittest import mock
from unittest.mock import sentinel

import debusine.tasks.tags as ttags
import debusine.worker.tags as wtags
from debusine.artifacts.models import (
    BareDataCategory,
    CollectionCategory,
    DebusineTaskConfiguration,
    TaskTypes,
)
from debusine.db.models import Collection, CollectionItem
from debusine.db.playground import scenarios
from debusine.db.tasks import DBTask
from debusine.server.collections import DebusineTaskConfigurationManager
from debusine.server.collections.base import ItemAdditionError
from debusine.server.collections.debusine_task_configuration import (
    Changes,
    ConfigurationLookup,
    apply_configuration,
    build_configuration,
)
from debusine.tasks import TaskConfigError
from debusine.tasks.models import SbuildData, SbuildInput
from debusine.test.django import TestCase


class TaskConfigTestCase(TestCase):
    """Base class for tests that apply task configuration."""

    scenario = scenarios.DefaultContext()
    collection: ClassVar[Collection]
    manager: ClassVar[DebusineTaskConfigurationManager]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.collection = cls.scenario.default_task_configuration_collection
        cls.manager = DebusineTaskConfigurationManager(
            collection=cls.collection
        )

    @classmethod
    def _add_config(cls, entry: DebusineTaskConfiguration) -> CollectionItem:
        """Add a config entry to config_collection."""
        return cls.manager.add_bare_data(
            BareDataCategory.TASK_CONFIGURATION,
            user=cls.scenario.user,
            data=entry,
        )


class DebusineTaskConfigurationManagerTests(TaskConfigTestCase):
    """Tests for DebusineTaskConfigurationManager."""

    def test_add_bare_data_no_data(self) -> None:
        """`add_bare_data` requires item data."""
        with self.assertRaisesRegex(
            ItemAdditionError,
            "Adding to debusine:task-configuration requires data",
        ):
            self.manager.add_bare_data(
                BareDataCategory.TASK_CONFIGURATION,
                user=self.scenario.user,
            )

    def test_add_bare_data_refuses_name(self) -> None:
        """`add_bare_data` refuses an explicitly-specified name."""
        with self.assertRaisesRegex(
            ItemAdditionError,
            "Cannot use an explicit item name when adding to "
            "debusine:task-configuration",
        ):
            self.manager.add_bare_data(
                BareDataCategory.TASK_CONFIGURATION,
                user=self.scenario.user,
                data=DebusineTaskConfiguration(
                    task_type=TaskTypes.WORKER,
                    task_name="sbuild",
                    subject="base-files",
                    context="sid",
                ),
                name="override",
            )

    def test_add_bare_data_raise_item_addition_error(self) -> None:
        """`add_bare_data` raises an error for duplicate names."""
        data = DebusineTaskConfiguration(template="test")
        self.manager.add_bare_data(
            BareDataCategory.TASK_CONFIGURATION,
            user=self.scenario.user,
            data=data,
        )

        with self.assertRaisesRegex(
            ItemAdditionError, "db_collectionitem_unique_active_name"
        ):
            self.manager.add_bare_data(
                BareDataCategory.TASK_CONFIGURATION,
                user=self.scenario.user,
                data=data,
            )

    def test_add_bare_data_replace(self) -> None:
        """`add_bare_data` can replace an existing bare data item."""
        data = DebusineTaskConfiguration(template="test")

        item_old = self.manager.add_bare_data(
            BareDataCategory.TASK_CONFIGURATION,
            user=self.scenario.user,
            data=data,
        )

        item_new = self.manager.add_bare_data(
            BareDataCategory.TASK_CONFIGURATION,
            user=self.scenario.user,
            data=data,
            replace=True,
        )

        serialized_data = data.model_dump(mode="json", exclude_unset=True)

        item_old.refresh_from_db()
        self.assertEqual(item_old.name, "template:test")
        self.assertEqual(item_old.child_type, CollectionItem.Types.BARE)
        self.assertEqual(item_old.data, serialized_data)
        self.assertEqual(item_old.removed_by_user, self.scenario.user)
        self.assertIsNotNone(item_old.removed_at)
        self.assertEqual(item_new.name, "template:test")
        self.assertEqual(item_new.child_type, CollectionItem.Types.BARE)
        self.assertEqual(item_new.data, serialized_data)
        self.assertIsNone(item_new.removed_at)

    def test_add_bare_data_replace_nonexistent(self) -> None:
        """Replacing a nonexistent bare data item is allowed."""
        data = DebusineTaskConfiguration(template="test")

        item = self.manager.add_bare_data(
            BareDataCategory.TASK_CONFIGURATION,
            user=self.scenario.user,
            data=data,
            replace=True,
        )

        self.assertEqual(item.name, "template:test")
        self.assertEqual(item.child_type, CollectionItem.Types.BARE)
        self.assertEqual(
            item.data, data.model_dump(mode="json", exclude_unset=True)
        )

    def test_remove_item_bare_data(self) -> None:
        """`remove_item` removes a bare data item."""
        data = DebusineTaskConfiguration(template="test")
        item = self.manager.add_bare_data(
            BareDataCategory.TASK_CONFIGURATION,
            user=self.scenario.user,
            data=data,
        )

        self.manager.remove_item(item, user=self.scenario.user)

        self.assertEqual(item.removed_by_user, self.scenario.user)
        self.assertIsNotNone(item.removed_at)

    def test_lookup_unexpected_format(self) -> None:
        """`lookup` raises `LookupError` for an unexpected format."""
        with self.assertRaisesRegex(
            LookupError, r'^Unexpected lookup format: "foo:bar"'
        ):
            self.manager.lookup("foo:bar")

    def test_lookup_return_none(self) -> None:
        """`lookup` returns None if there are no matches."""
        self.assertIsNone(self.manager.lookup("name:does-not-exist"))

    def test_lookup_by_name(self) -> None:
        """Lookup by name works."""
        data = DebusineTaskConfiguration(template="test")
        item = self.manager.add_bare_data(
            BareDataCategory.TASK_CONFIGURATION,
            user=self.scenario.user,
            data=data,
        )
        self.assertEqual(self.manager.lookup("name:template:test"), item)


class ConfigurationLookupTests(TaskConfigTestCase):
    """Tests for :py:class:`ConfigurationLookup`."""

    def lookup(
        self,
        task_type: TaskTypes = TaskTypes.WORKER,
        task_name: str = "noop",
        subject: str | None = None,
        context: str | None = None,
        tags_provided: set[str] | None = None,
        tags_required: set[str] | None = None,
    ) -> ConfigurationLookup:
        """Return the ConfigurationLookup to test."""
        lookup = ConfigurationLookup(self.collection)
        lookup.set_task_type_name_constraints(task_type, task_name)
        lookup.set_subject_context_constraints(subject, context)
        lookup.set_tags_provided(tags_provided or set())
        lookup.set_tags_required(tags_required or set())
        return lookup

    def test_set_task(self) -> None:
        # Build a mock task
        task = mock.MagicMock(spec=DBTask)
        task.TASK_TYPE = sentinel.type
        task.name = sentinel.name
        task.get_task_configuration_subject_context = mock.MagicMock(
            return_value=(sentinel.subject, sentinel.context)
        )
        provided = ttags.TaskProvidedTags()
        provided.add(ttags.ProvenanceProvided.SYSTEM, {"test", "provided"})
        required = wtags.TaskRequiredTags()
        required.add(wtags.ProvenanceRequired.SYSTEM, {"test", "required"})
        task.compute_scheduler_tags = mock.MagicMock(
            return_value=(provided, required)
        )

        lookup = ConfigurationLookup(self.collection)
        with (
            self.spy_object(lookup, "set_task_type_name_constraints") as set_tn,
            self.spy_object(
                lookup, "set_subject_context_constraints"
            ) as set_sc,
            self.spy_object(lookup, "set_tags_provided") as set_tp,
            self.spy_object(lookup, "set_tags_required") as set_tr,
        ):
            lookup.set_task(task)

        set_tn.assert_called_once_with(sentinel.type, sentinel.name)
        set_sc.assert_called_once_with(sentinel.subject, sentinel.context)
        set_tp.assert_called_once_with({"test", "provided"})
        set_tr.assert_called_once_with({"test", "required"})

    def test_candidates_annotations(self) -> None:
        self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER, task_name="noop"
            )
        )
        self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER, task_name="noop", subject="subject"
            )
        )
        self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER, task_name="noop", context="context"
            )
        )
        self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                subject="provided",
                tags_provided=["a", "b"],
            )
        )
        self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                subject="required",
                tags_required=["c", "d"],
            )
        )
        self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                subject="exprs",
                tags_provided="a or b",
                tags_required="c or d",
            )
        )
        with (
            mock.patch(
                "debusine.server.collections.debusine_task_configuration.ConfigurationLookup.set_subject_context_constraints"
            ),
            mock.patch(
                "debusine.server.collections.debusine_task_configuration.ConfigurationLookup.set_tags_provided"
            ),
            mock.patch(
                "debusine.server.collections.debusine_task_configuration.ConfigurationLookup.set_tags_required"
            ),
        ):
            items = list(self.lookup().candidates.order_by("id"))

        self.assertEqual(items[0].tags_provided, [])  # type: ignore[attr-defined]
        self.assertEqual(items[0].tags_required, [])  # type: ignore[attr-defined]
        self.assertIsNone(items[0].subject)  # type: ignore[attr-defined]
        self.assertIsNone(items[0].context)  # type: ignore[attr-defined]

        self.assertEqual(items[1].tags_provided, [])  # type: ignore[attr-defined]
        self.assertEqual(items[1].tags_required, [])  # type: ignore[attr-defined]
        self.assertEqual(items[1].subject, "subject")  # type: ignore[attr-defined]
        self.assertIsNone(items[1].context)  # type: ignore[attr-defined]

        self.assertEqual(items[2].tags_provided, [])  # type: ignore[attr-defined]
        self.assertEqual(items[2].tags_required, [])  # type: ignore[attr-defined]
        self.assertIsNone(items[2].subject)  # type: ignore[attr-defined]
        self.assertEqual(items[2].context, "context")  # type: ignore[attr-defined]

        self.assertEqual(items[3].tags_provided, ["a", "b"])  # type: ignore[attr-defined]
        self.assertEqual(items[3].tags_required, [])  # type: ignore[attr-defined]
        self.assertEqual(items[3].subject, "provided")  # type: ignore[attr-defined]
        self.assertIsNone(items[3].context)  # type: ignore[attr-defined]

        self.assertEqual(items[4].tags_provided, [])  # type: ignore[attr-defined]
        self.assertEqual(items[4].tags_required, ["c", "d"])  # type: ignore[attr-defined]
        self.assertEqual(items[4].subject, "required")  # type: ignore[attr-defined]
        self.assertIsNone(items[4].context)  # type: ignore[attr-defined]

        self.assertEqual(items[5].tags_provided, [])  # type: ignore[attr-defined]
        self.assertEqual(items[5].tags_required, [])  # type: ignore[attr-defined]
        self.assertEqual(items[5].subject, "exprs")  # type: ignore[attr-defined]
        self.assertIsNone(items[5].context)  # type: ignore[attr-defined]

        self.assertEqual(len(items), 6)

    def test_task_type_name_filter(self) -> None:
        noop = self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER, task_name="noop"
            )
        )
        self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER, task_name="other"
            )
        )
        self.assertEqual([x.id for x in self.lookup().candidates], [noop.id])

    def test_subject_context_filter(self) -> None:
        none = self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER, task_name="noop"
            )
        )
        subj = self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER, task_name="noop", subject="subject"
            )
        )
        cont = self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER, task_name="noop", context="context"
            )
        )
        both = self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                subject="subject",
                context="context",
            )
        )

        for subject, context, expected in (
            (None, None, [none]),
            ("subject", None, [none, subj]),
            (None, "context", [none, cont]),
            ("subject", "context", [none, cont, subj, both]),
        ):
            with self.subTest(subject=subject, context=context):
                self.assertQuerySetEqual(
                    self.lookup(subject=subject, context=context).candidates,
                    expected,
                    ordered=False,
                )

    def test_tags_filter(self) -> None:
        none = self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                subject="none",
            )
        )
        prov = self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                subject="prov",
                tags_provided=["p"],
            )
        )
        req = self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                subject="req",
                tags_required=["r"],
            )
        )
        both = self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                subject="both",
                tags_provided=["p"],
                tags_required=["r"],
            )
        )
        self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                subject="enotmatch",
                tags_provided="a or b",
                tags_required="c or d",
            )
        )
        eprov = self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                subject="eprov",
                tags_provided="p and not q",
            )
        )
        ereq = self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                subject="ereq",
                tags_required="r and not q",
            )
        )
        eboth = self._add_config(
            DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                subject="eboth",
                tags_provided="p and not q",
                tags_required="r and not q",
            )
        )

        for provided, required, expected in (
            ("", "", [none]),
            ("p", "", [none, prov, eprov]),
            ("pqr", "", [none, prov]),
            ("", "r", [none, req, ereq]),
            ("", "pqr", [none, req]),
            ("p", "r", [none, prov, req, both, eprov, ereq, eboth]),
            ("pqr", "pqr", [none, prov, req, both]),
        ):
            with (
                self.subTest(provided=provided, required=required),
                mock.patch(
                    "debusine.server.collections.debusine_task_configuration.ConfigurationLookup.set_subject_context_constraints"
                ),
            ):
                self.assertEqual(
                    [
                        x.subject
                        for x in self.lookup(
                            tags_provided=set(provided),
                            tags_required=set(required),
                        ).iter_matching_entries()
                    ],
                    [x.data["subject"] for x in expected],
                )

    def test_candidates_deleted_entries(self) -> None:
        entry = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER, task_name="noop"
        )
        self.manager.remove_item(
            self._add_config(entry), user=self.scenario.user
        )
        active = self._add_config(entry)
        self.assertQuerySetEqual(self.lookup().candidates, [active])

    def test_iter_matching_entries_ordering_path(self) -> None:
        self.enterContext(
            mock.patch(
                "debusine.server.collections.debusine_task_configuration.ConfigurationLookup.set_subject_context_constraints"
            )
        )
        self._add_config(
            entry1 := DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER, task_name="noop", path="03.yaml"
            )
        )
        self._add_config(
            entry2 := DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                path="02.yaml",
                subject="subject",
            )
        )
        self._add_config(
            entry3 := DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER, task_name="noop", context="context"
            )
        )
        assert list(self.lookup().iter_matching_entries()) == [
            entry3,
            entry2,
            entry1,
        ]

    def test_iter_matching_entries_ordering_position(self) -> None:
        self.enterContext(
            mock.patch(
                "debusine.server.collections.debusine_task_configuration.ConfigurationLookup.set_subject_context_constraints"
            )
        )
        self._add_config(
            entry1 := DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                path="f.yaml",
                position=3,
            )
        )
        self._add_config(
            entry2 := DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                path="f.yaml",
                position=2,
                subject="subject",
            )
        )
        self._add_config(
            entry3 := DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                context="context",
                path="f.yaml",
            )
        )
        assert list(self.lookup().iter_matching_entries()) == [
            entry3,
            entry2,
            entry1,
        ]

    def test_iter_matching_entries_ordering_legacy(self) -> None:
        self.enterContext(
            mock.patch(
                "debusine.server.collections.debusine_task_configuration.ConfigurationLookup.set_subject_context_constraints"
            )
        )
        self._add_config(
            entry1 := DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER, task_name="noop"
            )
        )
        self._add_config(
            entry2 := DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                subject="subject",
            )
        )
        self._add_config(
            entry3 := DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                context="context",
            )
        )
        self._add_config(
            entry4 := DebusineTaskConfiguration(
                task_type=TaskTypes.WORKER,
                task_name="noop",
                subject="subject",
                context="context",
            )
        )
        assert list(self.lookup().iter_matching_entries()) == [
            entry1,
            entry3,
            entry2,
            entry4,
        ]

    def test_iter_matching_entries_corrupted(self) -> None:
        """Lookup raises if the item contains invalid data."""
        entry = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER, task_name="noop"
        )
        item = self._add_config(entry)
        item.data["invalid_field"] = "value"
        item.save()
        with self.assertRaisesRegex(
            TaskConfigError,
            rf"(?s)debusine:task-configuration item {item.id} does not validate"
            r".+Extra inputs are not permitted",
        ):
            list(self.lookup().iter_matching_entries())

    def test_lookup_templates_empty(self) -> None:
        """Lookup templates with use_templates is empty."""
        entry = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER, task_name="noop"
        )
        self.assertEqual(
            list(self.lookup().lookup_templates(entry)),
            [],
        )

    def test_lookup_templates_missing_item(self) -> None:
        """Lookup templates with use_templates is empty."""
        entry = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER,
            task_name="noop",
            use_templates=["missing"],
        )
        with self.assertRaisesRegex(
            TaskConfigError,
            r"Worker:noop:: references missing template 'missing'",
        ):
            list(self.lookup().lookup_templates(entry))

    def test_lookup_templates_found(self) -> None:
        """Lookup templates with one template."""
        template = DebusineTaskConfiguration(
            template="template",
        )
        self._add_config(template)

        entry = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER,
            task_name="noop",
            use_templates=["template"],
        )
        self.assertEqual(
            list(self.lookup().lookup_templates(entry)),
            [template],
        )

    def test_lookup_templates_corrupted(self) -> None:
        template = DebusineTaskConfiguration(
            template="template",
        )
        item = self._add_config(template)
        item.data["corrupted"] = True
        item.save()

        entry = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER,
            task_name="noop",
            use_templates=["template"],
        )
        with self.assertRaisesRegex(
            TaskConfigError,
            r"(?s)debusine:task-configuration item does not validate"
            r".+Extra inputs are not permitted",
        ):
            list(self.lookup().lookup_templates(entry))

    def test_lookup_templates_diamond(self) -> None:
        """Lookup templates with a diamond dependency."""
        base = DebusineTaskConfiguration(
            template="base",
        )
        self._add_config(base)
        a = DebusineTaskConfiguration(template="a", use_templates=["base"])
        self._add_config(a)
        b = DebusineTaskConfiguration(template="b", use_templates=["base"])
        self._add_config(b)
        ab = DebusineTaskConfiguration(template="ab", use_templates=["a", "b"])
        self._add_config(ab)

        entry = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER,
            task_name="noop",
            use_templates=["ab"],
        )
        self.assertEqual(
            list(self.lookup().lookup_templates(entry)),
            [base, a, base, b, ab],
        )

    def test_lookup_templates_cycle(self) -> None:
        """Lookup templates with a dependency cycle."""
        a = DebusineTaskConfiguration(template="a", use_templates=["b"])
        self._add_config(a)
        b = DebusineTaskConfiguration(template="b", use_templates=["a"])
        self._add_config(b)

        entry = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER,
            task_name="noop",
            use_templates=["a"],
        )
        with self.assertRaisesRegex(
            TaskConfigError,
            r"template:b: template lookup cycle detected: a→b→a",
        ):
            list(self.lookup().lookup_templates(entry))

    def test_iter_items_no_templates(self) -> None:
        entry = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER, task_name="noop"
        )
        self._add_config(entry)
        self.assertEqual(
            list(self.lookup().iter_items()),
            [entry],
        )

    def test_iter_items_empty(self) -> None:
        self.assertEqual(list(self.lookup().iter_items()), [])

    def test_iter_items_templates(self) -> None:
        a = DebusineTaskConfiguration(template="a")
        entry = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER,
            task_name="noop",
            use_templates=["a"],
        )
        self._add_config(entry)
        with mock.patch(
            "debusine.server.collections"
            ".debusine_task_configuration.ConfigurationLookup.lookup_templates",
            return_value=iter((a, a, a)),
        ):
            self.assertEqual(
                list(self.lookup().iter_items()),
                [entry, a, a, a],
            )


class BuildConfigurationTests(unittest.TestCase):
    """Test :py:func:build_configuration`."""

    def entry(
        self, name: str = "test", **kwargs: Any
    ) -> DebusineTaskConfiguration:
        return DebusineTaskConfiguration(template=name, **kwargs)

    def test_empty(self) -> None:
        """Run with no entries."""
        self.assertEqual(
            build_configuration(()),
            Changes(
                default_values={},
                override_values={},
                provide_tags=set(),
                require_tags=set(),
            ),
        )

    def test_one(self) -> None:
        """Run with one entry."""
        entry = self.entry(
            delete_values=["val0"],
            default_values={"val1": 1},
            override_values={"val2": 2},
            lock_values=["val3"],
        )
        self.assertEqual(
            build_configuration([entry]),
            Changes(
                default_values={"val1": 1},
                override_values={"val2": 2},
                provide_tags=set(),
                require_tags=set(),
            ),
        )

    def test_default_and_override(self) -> None:
        """Run with one entry with same default and override keys."""
        entry = self.entry(
            default_values={"val1": 1},
            override_values={"val1": 11},
        )
        self.assertEqual(
            build_configuration([entry]),
            Changes(
                default_values={"val1": 1},
                override_values={"val1": 11},
                provide_tags=set(),
                require_tags=set(),
            ),
        )

    def test_stack(self) -> None:
        """Run with two entries."""
        entry1 = self.entry(
            default_values={"val1": 1, "base1": 1},
            override_values={"val1": 2, "base2": 2},
        )
        entry2 = self.entry(
            default_values={"val1": 11},
            override_values={"val1": 22},
        )
        self.assertEqual(
            build_configuration([entry1, entry2]),
            Changes(
                default_values={"val1": 11, "base1": 1},
                override_values={"val1": 22, "base2": 2},
                provide_tags=set(),
                require_tags=set(),
            ),
        )

    def test_delete_values(self) -> None:
        """Test delete_values."""
        entry1 = self.entry(
            default_values={"val1": 1, "val2": 2},
            override_values={"val1": 11, "val2": 22},
        )
        entry2 = self.entry(
            delete_values=["val1"],
        )
        self.assertEqual(
            build_configuration([entry1, entry2]),
            Changes(
                default_values={"val2": 2},
                override_values={"val2": 22},
                provide_tags=set(),
                require_tags=set(),
            ),
        )

    def test_delete_and_set_values(self) -> None:
        """Test deleting and then setting values."""
        entry1 = self.entry(
            default_values={"val1": 1, "val2": 2},
            override_values={"val1": 11, "val2": 22},
        )
        entry2 = self.entry(
            delete_values=["val1"],
            default_values={"val1": 111},
            override_values={"val1": 112},
        )
        self.assertEqual(
            build_configuration([entry1, entry2]),
            Changes(
                default_values={"val1": 111, "val2": 2},
                override_values={"val1": 112, "val2": 22},
                provide_tags=set(),
                require_tags=set(),
            ),
        )

    def test_lock_values(self) -> None:
        """Test locked_values."""
        entry1 = self.entry(
            default_values={"val1": 1, "val2": 2},
            override_values={"val1": 11, "val2": 22},
            lock_values=["val1"],
        )
        entry2 = self.entry(
            delete_values=["val1", "val2"],
            default_values={"val1": 111, "val3": 3},
            override_values={"val1": 112, "val3": 33},
        )
        self.assertEqual(
            build_configuration([entry1, entry2]),
            Changes(
                default_values={"val1": 1, "val3": 3},
                override_values={"val1": 11, "val3": 33},
                provide_tags=set(),
                require_tags=set(),
            ),
        )

    def test_tags(self) -> None:
        entry1 = self.entry(
            provide_tags=["pa", "pb"], require_tags=["ra", "rb"]
        )
        entry2 = self.entry(
            provide_tags=["pa", "pc"], require_tags=["ra", "rc"]
        )
        self.assertEqual(
            build_configuration([entry1, entry2]),
            Changes(
                default_values={},
                override_values={},
                provide_tags={"pa", "pb", "pc"},
                require_tags={"ra", "rb", "rc"},
            ),
        )


class ApplyConfigurationTests(TaskConfigTestCase):
    """Test :py:func:apply_configuration`."""

    @override
    def setUp(self) -> None:
        super().setUp()
        self.task = mock.MagicMock(spec=DBTask)
        self.task.TASK_TYPE = TaskTypes.WORKER
        self.task.name = "noop"
        self.task.get_task_configuration_subject_context.return_value = (
            None,
            None,
        )
        self.config_tags_provided = ttags.TaskProvidedTags()
        self.config_tags_required = wtags.TaskRequiredTags()
        self.task.compute_scheduler_tags.return_value = (
            self.config_tags_provided,
            self.config_tags_required,
        )

    def test_no_config(self) -> None:
        """Test without configuration to apply."""
        task_data: dict[str, Any] = {}
        apply_configuration(task_data, self.collection, self.task)
        self.assertEqual(task_data, {"task_configuration": self.collection.pk})

    def test_with_config(self) -> None:
        """Test with overrides to apply."""
        entry = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER,
            task_name="noop",
            default_values={"default": "added"},
            override_values={"override": "overridden"},
        )
        self._add_config(entry)

        task_data: dict[str, Any] = {
            "key": "value",
            "override": "original",
        }
        apply_configuration(task_data, self.collection, self.task)
        self.assertEqual(
            task_data,
            {
                "key": "value",
                "override": "overridden",
                "default": "added",
                "task_configuration": self.collection.pk,
            },
        )

    def test_defaults_with_none(self) -> None:
        """Test setting defaults on existing values set to None."""
        entry = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER,
            task_name="noop",
            default_values={"default": "added"},
        )
        self._add_config(entry)

        task_data: dict[str, Any] = {"default": None}
        apply_configuration(task_data, self.collection, self.task)
        self.assertEqual(
            task_data,
            {"default": "added", "task_configuration": self.collection.pk},
        )

    def test_defaults_preserves_existing_value(self) -> None:
        entry = DebusineTaskConfiguration(
            task_type=TaskTypes.WORKER,
            task_name="noop",
            default_values={"default": "added"},
        )
        self._add_config(entry)

        task_data: dict[str, Any] = {"default": "already set"}
        apply_configuration(task_data, self.collection, self.task)
        self.assertEqual(
            task_data,
            {
                "default": "already set",
                "task_configuration": self.collection.pk,
            },
        )

    def test_scheduler_tags_passed_to_lookup(self) -> None:
        for tags_provided, tags_required in (
            ([], []),
            (["a"], []),
            ([], ["a"]),
            (["a"], ["b"]),
        ):
            with (
                self.subTest(provided=tags_provided, required=tags_required),
                mock.patch(
                    "debusine.server.collections.debusine_task_configuration"
                    ".ConfigurationLookup.set_tags_provided"
                ) as set_tags_provided,
                mock.patch(
                    "debusine.server.collections.debusine_task_configuration"
                    ".ConfigurationLookup.set_tags_required"
                ) as set_tags_required,
            ):
                self.config_tags_provided.sets = {
                    ttags.ProvenanceProvided.SYSTEM: set(tags_provided),
                }
                self.config_tags_required.sets = {
                    wtags.ProvenanceRequired.SYSTEM: set(tags_required),
                }
                apply_configuration({}, self.collection, self.task)
                set_tags_provided.assert_called_once_with(set(tags_provided))
                set_tags_required.assert_called_once_with(set(tags_required))

    def test_issue1423(self) -> None:
        source = self.playground.create_minimal_source_package_artifact()
        environment = self.playground.create_debian_environment(
            codename="bookworm"
        )
        assert environment.artifact is not None
        bookworm = environment.artifact
        coll = self.playground.create_collection(
            "overlay-not-automatic", CollectionCategory.TASK_CONFIGURATION
        )
        coll.manager.add_bare_data(
            BareDataCategory.TASK_CONFIGURATION,
            user=self.scenario.user,
            data=DebusineTaskConfiguration(
                override_values={"build_dep_resolver": "aptitude"},
                task_name="sbuild",
                task_type=TaskTypes.WORKER,
            ),
        )

        wr = self.playground.create_worker_task(
            task_name="sbuild",
            task_data=SbuildData(
                input=SbuildInput(source_artifact=source.pk),
                backend="unshare",
                environment=bookworm.pk,
                build_components=["any"],
                build_architecture="amd64",
                extra_repositories=[
                    {
                        "url": "http://deb.debian.org/debian",
                        "suite": "bookworm",
                        "components": [
                            "contrib",
                            "non-free",
                            "non-free-firmware",
                        ],
                    },
                    {
                        "url": "http://deb.debian.org/debian",
                        "suite": "bookworm-backports",
                        "components": [
                            "main",
                            "contrib",
                            "non-free",
                            "non-free-firmware",
                        ],
                    },
                    {
                        "url": "http://incoming.debian.org/debian-buildd",
                        "suite": "buildd-bookworm-backports",
                        "components": [
                            "main",
                            "contrib",
                            "non-free",
                            "non-free-firmware",
                        ],
                    },
                ],
                task_configuration="overlay-not-automatic",
            ),
        )
        self.assertIsNone(wr.configured_task_data)
        self.playground.advance_work_request(wr, mark_pending=True)
        assert wr.configured_task_data is not None
        self.assertNotIn("build_dep_resolver", wr.task_data)
        self.assertEqual(
            wr.configured_task_data["build_dep_resolver"], "aptitude"
        )
