# 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 the base DBTask classes."""

import abc
from pathlib import Path
from typing import Any, ClassVar, cast, override
from unittest import mock

from django.test import override_settings

import debusine.tasks.tags as ttags
import debusine.worker.tags as wtags
from debusine.artifacts.models import TaskTypes, WorkRequestResults
from debusine.db.models import Collection, WorkRequest
from debusine.db.playground import scenarios
from debusine.db.tasks.base import DBTask, DBWorkerProxyTask
from debusine.db.tasks.models import DBInputCollectionSingle
from debusine.server.collections.debusine_task_configuration import Changes
from debusine.tasks import BaseExternalTask, TaskConfigError, inputs
from debusine.tasks.inputs.mock_resolver import MockFieldResolver
from debusine.tasks.models import BaseDynamicTaskData, BaseTaskData
from debusine.tasks.tests.helpers import SampleBaseExternalTask
from debusine.test.django import TestCase, preserve_db_task_registry
from debusine.test.test_utils import preserve_task_registry


class AbstractTaskWithInputs(SampleBaseExternalTask[BaseTaskData], abc.ABC):
    """Task class with an input field."""

    TASK_TYPE = TaskTypes.WORKER
    TASK_VERSION = 1

    test_field = inputs.DebusineFQDNInput()


class AbstractDBTaskWithInputs(DBTask[BaseTaskData], abc.ABC):
    """DBTask class with an input field."""

    TASK_TYPE = TaskTypes.WORKER
    TASK_VERSION = 1

    test_field = inputs.DebusineFQDNInput()

    @override
    def get_task_configuration_subject_context(
        self,
    ) -> tuple[str | None, str | None]:
        raise NotImplementedError()

    @override
    def _execute(self) -> WorkRequestResults:
        raise NotImplementedError()


class DBTaskTests(TestCase):
    """Tests for :py:class:`DBTask`."""

    scenario = scenarios.DefaultContext()

    @preserve_db_task_registry()
    def make_concrete_dbtask(self) -> DBTask[BaseTaskData]:
        """Instantiate a concrete instance of DBTask."""

        class ConcreteDBTask(DBTask[BaseTaskData, BaseDynamicTaskData]):
            """Concrete implementation of DBTask."""

            TASK_TYPE = TaskTypes.WORKER
            TASK_VERSION = 1

            @override
            def get_task_configuration_subject_context(
                self,
            ) -> tuple[str | None, str | None]:
                raise NotImplementedError()

            @override
            def _execute(self) -> WorkRequestResults:
                raise NotImplementedError()

        work_request = self.playground.create_worker_task(
            task_name="concretedbtask"
        )
        return work_request.get_task()

    @preserve_db_task_registry()
    def make_task_with_inputs(self) -> AbstractDBTaskWithInputs:
        class TaskWithInputs(AbstractDBTaskWithInputs):
            """Concrete version of AbstractDBTaskWithInputs."""

        work_request = self.playground.create_worker_task(
            task_name="taskwithinputs",
        )
        return cast(AbstractDBTaskWithInputs, work_request.get_task())

    def test_is_valid_task_name(self) -> None:
        self.assertTrue(DBTask.is_valid_task_name(TaskTypes.WORKER, "sbuild"))

    def test_is_valid_task_name_case_insensitive(self) -> None:
        self.assertTrue(DBTask.is_valid_task_name(TaskTypes.WORKER, "Sbuild"))

    def test_is_valid_task_name_invalid_type(self) -> None:
        self.assertFalse(
            DBTask.is_valid_task_name(
                cast(TaskTypes, "does-not-exist"), "sbuild"
            )
        )

    def test_is_valid_task_name_invalid(self) -> None:
        self.assertFalse(
            DBTask.is_valid_task_name(TaskTypes.WORKER, "not_a_real_task")
        )

    def test_task_names_worker(self) -> None:
        task_names = DBTask.task_names(TaskTypes.WORKER)
        self.assertGreater(len(task_names), 0)
        self.assertIn("noop", task_names)

    def test_worker_task_names(self) -> None:
        task_names = DBTask.worker_task_names()
        self.assertGreater(len(task_names), 0)
        self.assertIn("noop", task_names)

    def test_is_worker_task(self) -> None:
        self.assertTrue(DBTask.is_worker_task("noop"))

    def test_is_worker_task_false(self) -> None:
        self.assertFalse(DBTask.is_worker_task("nonexistent"))

    def test_class_for_name_invalid_type(self) -> None:
        with self.assertRaisesRegex(
            ValueError, "'not-existing-type' is not a registered task type"
        ):
            DBTask.class_from_name(
                cast(TaskTypes, "not-existing-type"), "non-existing-class"
            )

    def test_class_for_name_sbuild(self) -> None:
        self.assertEqual(
            DBTask.class_from_name(TaskTypes.WORKER, 'sBuIlD').name, "sbuild"
        )

    def test_class_for_name_no_class(self) -> None:
        with self.assertRaisesRegex(
            ValueError,
            "'non-existing-class' is not a registered Worker task_name",
        ):
            DBTask.class_from_name(TaskTypes.WORKER, 'non-existing-class')

    def test_get_label_no_dynamic_data(self) -> None:
        wr = self.playground.create_worker_task(task_name="noop")
        self.assertEqual(wr.get_task().get_label(), "noop")

    def test_get_label_no_parameter_summary(self) -> None:
        wr = self.playground.create_worker_task(
            task_name="noop", mark_running=True
        )
        self.assertEqual(wr.get_task().get_label(), "noop")

    def test_get_label_parameter_summary(self) -> None:
        wr = self.playground.create_worker_task(
            task_name="noop", mark_running=True
        )
        assert wr.dynamic_task_data is not None
        wr.dynamic_task_data["parameter_summary"] = "summary"
        wr.save()
        self.assertEqual(wr.get_task().get_label(), "noop: summary")

    def test_get_description(self) -> None:
        task = self.make_concrete_dbtask()
        self.assertEqual(task.get_description(), task.__doc__)

    def test_get_description_no_docstring(self) -> None:
        task = self.make_concrete_dbtask()
        task.__doc__ = None
        self.assertEqual(task.get_description(), task.get_label())

    def test_compute_scheduler_tags(self) -> None:
        self.playground.create_group("test", [self.scenario.user])
        self.playground.create_group(
            "test", [self.scenario.user], workspace=self.scenario.workspace
        )
        task = self.make_concrete_dbtask()
        ws = self.scenario.workspace
        provided, required = task.compute_scheduler_tags()
        assert provided.sets == {
            ttags.ProvenanceProvided.SYSTEM: {
                ttags.SCOPE_PREFIX + ws.scope.name,
                ttags.WORKSPACE_PREFIX + f"{ws.scope.name}:{ws.name}",
                ttags.GROUP_PREFIX + f"{ws.scope.name}::test",
                ttags.GROUP_PREFIX + f"{ws.scope.name}:{ws.name}:test",
            },
        }
        assert required.sets == {
            wtags.ProvenanceRequired.SYSTEM: {
                wtags.WORKER_TYPE_EXTERNAL,
                wtags.TASK_PREFIX
                + f"{task.TASK_TYPE.lower()}:concretedbtask:version:1",
            },
        }

    def test_get_input_artifacts_ids_uses_dynamic_data(self) -> None:
        task = self.make_concrete_dbtask()
        task.dynamic_data = mock.Mock()
        task.dynamic_data.get_input_artifacts_ids.return_value = [1, 2]

        self.assertEqual(task.get_input_artifacts_ids(), [1, 2])

    def test_get_input_artifacts_ids_from_wrapped_task(self) -> None:
        work_request = self.playground.create_worker_task(task_name="noop")
        task = work_request.get_task()
        self.assertIsInstance(task, DBWorkerProxyTask)

        with mock.patch(
            "debusine.tasks.BaseExternalTask.get_input_artifacts_ids",
            return_value=[3, 4],
        ):
            self.assertEqual(task.get_input_artifacts_ids(), [3, 4])

    def test_compute_dynamic_data_saves_fields(self) -> None:
        task = self.make_task_with_inputs()
        resolver = MockFieldResolver(
            test_field="debusine.example.org", task_configuration=None
        )
        resolver.resolve_inputs(task)
        ddata = task.compute_dynamic_data()
        self.assertEqual(
            ddata.task_input_fields,
            {"test_field": "debusine.example.org", "task_configuration": None},
        )


class DBProxyTaskTests(TestCase):
    """Tests for :py:class:`DBProxyTask`."""

    scenario = scenarios.DefaultContext()

    @preserve_task_registry()
    @preserve_db_task_registry()
    def make_task_with_inputs(
        self, dynamic_task_data: dict[str, Any] | None = None
    ) -> DBTask[BaseTaskData]:
        class TaskWithInputs(AbstractTaskWithInputs):
            """Concrete version of AbstractTaskWithInputs."""

        DBWorkerProxyTask.populate_task_registry()

        work_request = self.playground.create_worker_task(
            task_name="taskwithinputs",
        )
        if dynamic_task_data is not None:
            work_request.dynamic_task_data = dynamic_task_data
            work_request.save()
        return work_request.get_task()

    @preserve_task_registry()
    @preserve_db_task_registry()
    def test_populate_task_registry(self) -> None:
        # samplebaseexternaltask does not exist
        self.assertNotIn(
            "sampleexternaltask",
            BaseExternalTask._sub_tasks[TaskTypes.WORKER],
        )
        self.assertNotIn(
            "sampleexternaltask", DBTask._sub_tasks[TaskTypes.WORKER]
        )

        class SampleExternalTask(
            SampleBaseExternalTask[BaseTaskData],
        ):
            """Sample class to test BaseExternalTask class."""

            @override
            def run(self, execute_directory: Path) -> WorkRequestResults:
                """Unused abstract method from BaseExternalTask."""
                raise NotImplementedError()

        # samplebaseexternaltask is registered with BaseExternalTask only
        self.assertIn(
            "sampleexternaltask",
            BaseExternalTask._sub_tasks[TaskTypes.WORKER],
        )
        self.assertNotIn(
            "sampleexternaltask", DBTask._sub_tasks[TaskTypes.WORKER]
        )

        DBWorkerProxyTask.populate_task_registry()

        # samplebaseexternaltask is registered with BaseExternalTask and DBTask
        self.assertIn(
            "sampleexternaltask",
            BaseExternalTask._sub_tasks[TaskTypes.WORKER],
        )
        self.assertIn("sampleexternaltask", DBTask._sub_tasks[TaskTypes.WORKER])

    def test_get_provided_worker_tags(self) -> None:
        external_worker_get_tags = self.enterContext(
            self.spy("debusine.tasks.sbuild.Sbuild.get_provided_worker_tags")
        )
        self.mock_is_command_available({"sbuild": False})
        sbuild_task_cls = DBTask.class_from_name(TaskTypes.WORKER, "sbuild")
        self.assertEqual(
            sbuild_task_cls.get_provided_worker_tags(),
            {
                wtags.TASK_PREFIX
                + f"{sbuild_task_cls.TASK_TYPE.lower()}:{sbuild_task_cls.name}"
                f":version:{sbuild_task_cls.TASK_VERSION}",
            },
        )
        external_worker_get_tags.assert_called_once_with()

    def test_get_input_artifacts_ids_from_fields(self) -> None:
        work_request = self.playground.create_worker_task(task_name="noop")
        task = work_request.get_task()
        with (
            mock.patch(
                "debusine.tasks.BaseExternalTask.get_input_artifacts_ids",
                return_value=[1, 2, 3],
            ),
        ):
            self.assertEqual(
                task.get_input_artifacts_ids_from_fields(), [1, 2, 3]
            )

    @override_settings(DEBUSINE_FQDN="debusine.example.org")
    def test_compute_dynamic_data_saves_fields(self) -> None:
        # TODO: update test when DBTask also has a field
        task = self.make_task_with_inputs()
        task.resolve_inputs(inputs.Stage.RUNNING)
        ddata = task.compute_dynamic_data()
        assert ddata.task_input_fields == {
            "test_field": "debusine.example.org",
            "task_configuration": DBInputCollectionSingle.from_collection(
                self.scenario.default_task_configuration_collection,
                lookup="default@debusine:task-configuration",
                label="task_configuration",
            ).model_dump(),
        }

    def test_input_field_values_from_task_data(self) -> None:
        # TODO: update test when DBTask also has a field
        test_value = "test_restore_inputs.example.org"
        with self.spy(
            "debusine.tasks.inputs.DebusineFQDNInput.deserialize_value"
        ) as deserialize_method:
            task = self.make_task_with_inputs(
                dynamic_task_data={
                    "task_input_fields": {"test_field": test_value}
                }
            )
        assert hasattr(task, "task")
        assert isinstance(task.task, AbstractTaskWithInputs)
        self.assertEqual(task.task.test_field, test_value)
        assert deserialize_method.call_args_list == [
            mock.call(task.task.inputs["test_field"], test_value)
        ]

    def test_input_field_values_from_task_data_ignore_spurious(self) -> None:
        # TODO: update test when DBTask also has a field
        test_value = "test_restore_inputs.example.org"
        task = self.make_task_with_inputs(
            dynamic_task_data={
                "task_input_fields": {
                    "test_field": test_value,
                    "other_field": "invalid",
                }
            }
        )
        assert hasattr(task, "task")
        assert isinstance(task.task, AbstractTaskWithInputs)
        self.assertEqual(task.task.test_field, test_value)
        self.assertFalse(hasattr(task, "other_field"))
        self.assertFalse(hasattr(task.task, "other_field"))


class ApplyTaskConfigurationTests(TestCase):
    """test :py:meth:`DBTask.apply_task_configuration`."""

    scenario = scenarios.DefaultContext()

    work_request: ClassVar[WorkRequest]

    @classmethod
    def setUpTestData(cls) -> None:
        """Set up common test data."""
        super().setUpTestData()
        environment_item = cls.playground.create_debian_environment()
        assert environment_item.artifact is not None
        environment = environment_item.artifact
        source = cls.playground.create_source_artifact()
        cls.work_request = cls.playground.create_sbuild_work_request(
            source=source, environment=environment, architecture="amd64"
        )
        cls.work_request.save()

    @override
    def setUp(self) -> None:
        super().setUp()
        self.apply_configuration = self.enterContext(
            mock.patch(
                "debusine.server.collections.debusine_task_configuration"
                ".apply_configuration"
            )
        )
        self.sample_task_data = self.work_request.task_data.copy()
        self.sample_dynamic_data = {
            "input_source_artifact_id": 1,
            "input_extra_binary_artifacts_ids": [],
            "binnmu_maintainer": None,
        }
        self.provide_tags = {"provided"}
        self.require_tags = {"required"}
        self.provide_tag_candidates = ttags.TaskProvidedTags()
        self.provide_tag_candidates.add(
            ttags.ProvenanceProvided.SYSTEM, {"provided"}
        )
        self.require_tag_candidates = wtags.TaskRequiredTags()
        self.require_tag_candidates.add(
            wtags.ProvenanceRequired.SYSTEM, {"required"}
        )

    def patch_task[T: DBTask[Any, Any]](self, task: T) -> T:
        """Mock relevant methods called by apply_task_configuration."""
        self.get_task_configuration_subject_context = self.enterContext(
            mock.patch.object(
                task,
                "get_task_configuration_subject_context",
                return_value=("subject", "context"),
            )
        )
        self.compute_scheduler_tags = self.enterContext(
            mock.patch.object(
                task,
                "compute_scheduler_tags",
                return_value=(
                    self.provide_tag_candidates,
                    self.require_tag_candidates,
                ),
            )
        )
        return task

    def get_task(
        self, task_configuration: Collection | None = None
    ) -> DBTask[Any, Any]:
        task = self.patch_task(self.work_request.get_task())
        # Fill the task_configuration field, which is a prerequisite before
        # apply_task_configuration can be called
        if task_configuration is None:
            task.__dict__["task_configuration"] = None
        else:
            task.__dict__["task_configuration"] = (
                DBInputCollectionSingle.from_collection(
                    task_configuration,
                    lookup=BaseTaskData.DEFAULT_TASK_CONFIGURATION_LOOKUP,
                    label="task_configuration",
                )
            )
        return task

    def assertConfigured(
        self,
        task: DBTask[Any, Any],
        task_configuration: Collection | None = None,
    ) -> None:
        """The task has been configured."""
        if task_configuration is not None:
            self.apply_configuration.assert_called_once_with(
                self.work_request.task_data, task_configuration, task
            )
        else:
            self.apply_configuration.assert_not_called()
        self.compute_scheduler_tags.assert_called_once()

        self.assertIsNotNone(self.work_request.configured_task_data)
        self.assertEqual(
            self.work_request.scheduler_tags_provided, sorted(self.provide_tags)
        )
        self.assertEqual(
            self.work_request.scheduler_tags_required, sorted(self.require_tags)
        )
        self.assertIsNone(self.work_request.dynamic_task_data)

    def test_errors_unset_configured_values(self) -> None:
        task_configuration = self.scenario.default_task_configuration_collection
        self.work_request.configured_task_data = {
            **self.sample_task_data,
            "build_architecture": "arm64",
        }
        self.work_request.dynamic_task_data = {
            **self.sample_dynamic_data,
            "input_source_artifact_id": 1001,
        }
        self.work_request.scheduler_tags_provided = ["provided"]
        self.work_request.scheduler_tags_required = ["required"]

        task = self.get_task(task_configuration=task_configuration)
        self.apply_configuration.side_effect = RuntimeError("expected error")
        with self.assertRaisesRegex(RuntimeError, "expected error"):
            task.apply_task_configuration()

        self.compute_scheduler_tags.assert_not_called()
        self.assertEqual(
            self.work_request.configured_task_data, self.work_request.task_data
        )
        self.assertEqual(self.work_request.scheduler_tags_provided, [])
        self.assertEqual(self.work_request.scheduler_tags_required, [])
        self.assertIsNone(self.work_request.dynamic_task_data)

    def test_config_collection_lookup_none(self) -> None:
        task = self.get_task(task_configuration=None)
        task.apply_task_configuration()
        self.assertConfigured(task)

    def test_config_collection_lookup_succeeds(self) -> None:
        task_configuration = self.scenario.default_task_configuration_collection
        task = self.get_task(task_configuration=task_configuration)
        task.apply_task_configuration()
        self.assertConfigured(task, task_configuration=task_configuration)

    def test_apply_configuration_fails(self) -> None:
        task = self.get_task()
        task.resolve_inputs(inputs.Stage.PENDING)
        self.apply_configuration.side_effect = TaskConfigError("expected error")
        with self.assertRaisesRegex(TaskConfigError, "expected error"):
            task.apply_task_configuration()

        self.assertEqual(
            self.work_request.configured_task_data,
            self.work_request.task_data,
        )
        self.assertIsNone(self.work_request.dynamic_task_data)
        self.assertEqual(self.work_request.scheduler_tags_provided, [])
        self.assertEqual(self.work_request.scheduler_tags_required, [])

    def test_apply_configuration_contributes_tags(self) -> None:
        self.work_request.save()
        task = self.work_request.get_task()
        self.apply_configuration.return_value = Changes(
            {}, {}, provide_tags={"p"}, require_tags={"r"}
        )
        tagset_add = self.enterContext(
            self.spy("debusine.server.tags.Tagset.add")
        )
        task.resolve_inputs(inputs.Stage.RUNNING)
        task.apply_task_configuration()

        self.assertEqual(
            self.work_request.configured_task_data,
            self.work_request.task_data,
        )
        self.assertIsNone(self.work_request.dynamic_task_data)

        # Tags have been contributed
        self.assertIn("p", self.work_request.scheduler_tags_provided)
        self.assertIn("r", self.work_request.scheduler_tags_required)

        # Tags have been contributed with the correct provenance
        p_found = False
        r_found = False
        for call in tagset_add.call_args_list:
            if call.args[2] == set("p"):
                p_found = True
                self.assertEqual(
                    call.args[1], ttags.ProvenanceProvided.WORKSPACE
                )
            elif call.args[2] == set("r"):
                r_found = True
                self.assertEqual(
                    call.args[1], wtags.ProvenanceRequired.WORKSPACE
                )
        self.assertTrue(p_found)
        self.assertTrue(r_found)

    def test_compute_scheduler_tags_fails(self) -> None:
        task = self.get_task()
        self.compute_scheduler_tags.side_effect = TaskConfigError(
            "expected error"
        )
        with self.assertRaisesRegex(TaskConfigError, "expected error"):
            task.apply_task_configuration()

        self.assertEqual(
            self.work_request.configured_task_data,
            self.work_request.task_data,
        )
        self.assertEqual(self.work_request.scheduler_tags_required, [])
        self.assertEqual(self.work_request.scheduler_tags_provided, [])
        self.assertIsNone(self.work_request.dynamic_task_data)

    def test_persist_input_fields(self) -> None:
        sid = self.playground.create_debian_environment(
            codename="sid", architecture="amd64"
        ).artifact
        assert sid is not None
        source = self.playground.create_minimal_source_package_artifact()
        work_request = self.playground.create_sbuild_work_request(
            source=source, environment=sid, architecture="amd64"
        )
        self.playground.advance_work_request(work_request, mark_running=True)
        assert work_request.dynamic_task_data is not None

        fields = work_request.dynamic_task_data["task_input_fields"]
        self.assertIn("input_source_artifact", fields)
        self.assertIn("input_extra_binary_artifacts", fields)
        self.assertIn("environment", fields)
        self.assertIn("extra_repositories", fields)
        self.assertIn("debusine_fqdn", fields)
        self.assertEqual(
            fields["input_source_artifact"]["artifact_id"], source.id
        )
        self.assertEqual(fields["environment"]["artifact_id"], sid.id)
