# 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 Workflow classes."""

import abc
from typing import override
from unittest import mock

from debusine.artifacts.models import (
    ArtifactCategory,
    BareDataCategory,
    TaskTypes,
)
from debusine.client.models import LookupChildType
from debusine.db.models import CollectionItem, default_workspace
from debusine.db.playground import scenarios
from debusine.db.tasks import DBTask
from debusine.server.tasks.wait.models import ExternalDebsignData
from debusine.server.workflows import NoopWorkflow, Workflow
from debusine.server.workflows.models import (
    BaseWorkflowData,
    WorkRequestWorkflowData,
)
from debusine.server.workflows.tests.helpers import SampleWorkflow
from debusine.signing.tasks.models import DebsignData
from debusine.tasks.models import (
    ActionUpdateCollectionWithArtifacts,
    BaseDynamicTaskData,
    InputArtifactMultiple,
    InputArtifactSingle,
    LookupMultiple,
)
from debusine.test.django import TestCase, preserve_db_task_registry


class WorkflowTests(TestCase):
    """Unit tests for Workflow class."""

    scenario = scenarios.DefaultContext()

    @preserve_db_task_registry()
    def test_create(self) -> None:
        """Test instantiating a Workflow."""

        class ExampleWorkflow(
            SampleWorkflow[BaseWorkflowData, BaseDynamicTaskData]
        ):
            """Concrete workflow instance to use for tests."""

            @override
            def populate(self) -> None:
                raise NotImplementedError()

        wr = self.playground.create_workflow(
            task_data={},
            workspace=default_workspace(),
            task_name="exampleworkflow",
        )
        wf = wr.get_task()
        assert isinstance(wf, ExampleWorkflow)
        self.assertEqual(
            wf.data,
            BaseWorkflowData(
                task_configuration=(
                    self.scenario.default_task_configuration_collection.pk
                )
            ),
        )
        self.assertEqual(wf.work_request, wr)

    @preserve_db_task_registry()
    def test_registration(self) -> None:
        """Test class subclass registry."""

        class ExampleWorkflow(
            SampleWorkflow[BaseWorkflowData, BaseDynamicTaskData]
        ):
            """Concrete workflow instance to use for tests."""

            @override
            def populate(self) -> None:
                raise NotImplementedError()

        class ExampleWorkflowABC(
            SampleWorkflow[BaseWorkflowData, BaseDynamicTaskData],
            abc.ABC,
        ):
            """Abstract workflow subclass to use for tests."""

        class ExampleWorkflowName(
            SampleWorkflow[BaseWorkflowData, BaseDynamicTaskData]
        ):
            """Workflow subclass with a custom name."""

            TASK_NAME = "examplename"

            @override
            def populate(self) -> None:
                raise NotImplementedError()

        # name gets filled
        self.assertEqual(ExampleWorkflow.name, "exampleworkflow")
        self.assertEqual(ExampleWorkflowABC.name, "exampleworkflowabc")
        self.assertEqual(ExampleWorkflowName.name, "examplename")

        # Subclasses that are not ABC get registered
        self.assertIn("exampleworkflow", DBTask._sub_tasks[TaskTypes.WORKFLOW])
        self.assertNotIn(
            "exampleworkflowabc", DBTask._sub_tasks[TaskTypes.WORKFLOW]
        )
        self.assertIn("examplename", DBTask._sub_tasks[TaskTypes.WORKFLOW])

    @preserve_db_task_registry()
    def test_provides_artifact(self) -> None:
        """Test provides_artifact() update event_reactions.on_creation."""

        class ExampleWorkflow(
            SampleWorkflow[BaseWorkflowData, BaseDynamicTaskData]
        ):
            """Concrete workflow instance to use for tests."""

            @override
            def populate(self) -> None:
                raise NotImplementedError()

        workflow_root = self.playground.create_workflow(
            task_name="exampleworkflow"
        )
        workflow_root_task = workflow_root.get_task()
        assert isinstance(workflow_root_task, ExampleWorkflow)
        assert workflow_root.internal_collection is not None

        child = workflow_root.create_child_worker("noop")

        self.assert_work_request_event_reactions(child, on_creation=[])

        name = "hello_1:1.0-1|testing"
        data = {"architecture": "amd64"}
        category = ArtifactCategory.TEST
        artifact_filters = {"xxx": "yyy"}

        workflow_root_task.provides_artifact(
            child, category, name, data=data, artifact_filters=artifact_filters
        )

        promise = workflow_root.internal_collection.child_items.active().get(
            name=name
        )
        self.assertEqual(promise.child_type, CollectionItem.Types.BARE)
        self.assertEqual(promise.category, BareDataCategory.PROMISE)
        self.assertEqual(
            promise.data,
            {
                "promise_work_request_id": child.id,
                "promise_workflow_id": workflow_root.id,
                "promise_category": category,
                **data,
            },
        )
        on_success = [
            ActionUpdateCollectionWithArtifacts(
                collection="internal@collections",
                name_template=name,
                variables=data,
                artifact_filters={
                    **artifact_filters,
                    "category": category,
                },
            )
        ]
        self.assert_work_request_event_reactions(child, on_success=on_success)

        # provides_artifact is idempotent.
        artifact, _ = self.playground.create_artifact()
        assert workflow_root.internal_collection is not None
        workflow_root.internal_collection.manager.add_artifact(
            artifact,
            user=child.created_by,
            workflow=workflow_root,
            variables={},
            name=name,
            replace=True,
        )

        workflow_root_task.provides_artifact(
            child, category, name, data=data, artifact_filters=artifact_filters
        )

        item = workflow_root.internal_collection.child_items.active().get(
            name=name
        )
        self.assertEqual(item.child_type, CollectionItem.Types.ARTIFACT)
        self.assertEqual(item.category, ArtifactCategory.TEST)
        self.assertEqual(item.artifact, artifact)
        self.assert_work_request_event_reactions(child, on_success=on_success)

    @preserve_db_task_registry()
    def test_provides_artifact_bad_name(self) -> None:
        r"""Test requires_artifact() ValueError: name contains a slash."""

        class ExampleWorkflow(
            SampleWorkflow[BaseWorkflowData, BaseDynamicTaskData]
        ):
            """Concrete workflow instance to use for tests."""

            @override
            def populate(self) -> None:
                raise NotImplementedError()

        workflow_root = self.playground.create_workflow(
            task_name="exampleworkflow"
        )
        workflow_root_task = workflow_root.get_task()
        assert isinstance(workflow_root_task, Workflow)

        child = workflow_root.create_child_worker("noop")

        with self.assertRaisesRegex(
            ValueError, r'Collection item name may not contain "/"\.'
        ):
            workflow_root_task.provides_artifact(
                child, ArtifactCategory.TEST, "prefix/testing"
            )

    @preserve_db_task_registry()
    def test_provides_artifact_bad_data_key(self) -> None:
        r"""Test requires_artifact() ValueError: key starts with promise\_."""

        class ExampleWorkflow(
            SampleWorkflow[BaseWorkflowData, BaseDynamicTaskData]
        ):
            """Concrete workflow instance to use for tests."""

            @override
            def populate(self) -> None:
                raise NotImplementedError()

        workflow_root = self.playground.create_workflow(
            task_name="exampleworkflow"
        )
        workflow_root_task = workflow_root.get_task()
        assert isinstance(workflow_root_task, Workflow)

        child = workflow_root.create_child_worker("noop")

        key = "promise_key"
        msg = f'Field name "{key}" starting with "promise_" is not allowed.'
        with self.assertRaisesRegex(ValueError, msg):
            workflow_root_task.provides_artifact(
                child, ArtifactCategory.TEST, "testing", data={key: "value"}
            )

    @preserve_db_task_registry()
    def test_requires_artifact_lookup_single(self) -> None:
        """Test requires_artifact() call work_request.add_dependency()."""

        class ExampleWorkflow(
            SampleWorkflow[BaseWorkflowData, BaseDynamicTaskData]
        ):
            """Concrete workflow instance to use for tests."""

            @override
            def populate(self) -> None:
                raise NotImplementedError()

        workflow_root = self.playground.create_workflow(
            task_name="exampleworkflow"
        )
        workflow_root_task = workflow_root.get_task()
        assert isinstance(workflow_root_task, Workflow)

        # Provides the relevant artifact
        child_provides_1 = workflow_root.create_child_worker("noop")

        # Provides a non-relevant artifact (for the Lookup query of
        # the requires_artifact()
        child_provides_2 = workflow_root.create_child_worker("noop")

        child_requires = workflow_root.create_child_worker("noop")

        self.assertEqual(child_requires.dependencies.count(), 0)

        workflow_root_task.provides_artifact(
            child_provides_1, ArtifactCategory.TEST, "build-amd64"
        )
        workflow_root_task.provides_artifact(
            child_provides_2, ArtifactCategory.TEST, "build-i386"
        )
        child_provides_1.process_event_reactions("on_creation")
        child_provides_2.process_event_reactions("on_creation")

        workflow_root_task.requires_artifact(
            child_requires, "internal@collections/name:build-amd64"
        )

        self.assertEqual(child_requires.dependencies.count(), 1)
        self.assertQuerySetEqual(
            child_requires.dependencies.all(), [child_provides_1]
        )

        # Calling requires_artifact() if it was already required: is a noop
        workflow_root_task.requires_artifact(
            child_requires, "internal@collections/name:build-amd64"
        )
        self.assertEqual(child_requires.dependencies.count(), 1)

    @preserve_db_task_registry()
    def test_requires_artifact_lookup_multiple(self) -> None:
        """Test requires_artifact() call work_request.add_dependency()."""

        class ExampleWorkflow(
            SampleWorkflow[BaseWorkflowData, BaseDynamicTaskData]
        ):
            """Concrete workflow instance to use for tests."""

            @override
            def populate(self) -> None:
                raise NotImplementedError()

        workflow_root = self.playground.create_workflow(
            task_name="exampleworkflow"
        )
        workflow_root_task = workflow_root.get_task()
        assert isinstance(workflow_root_task, Workflow)
        assert workflow_root.internal_collection is not None

        # Providers that will match
        child_provides_1 = workflow_root.create_child_worker("noop")
        child_provides_2 = workflow_root.create_child_worker("noop")

        # Requirer
        child_requires = workflow_root.create_child_worker("noop")

        self.assertEqual(child_requires.dependencies.count(), 0)

        workflow_root_task.provides_artifact(
            child_provides_1, ArtifactCategory.TEST, "build-amd64"
        )
        workflow_root_task.provides_artifact(
            child_provides_2, ArtifactCategory.TEST, "build-i386"
        )
        child_provides_1.process_event_reactions("on_creation")
        child_provides_2.process_event_reactions("on_creation")

        # Add manually CollectionItem with a category != PROMISE for
        # unit testing coverage
        CollectionItem.objects.create_from_bare_data(
            BareDataCategory.PACKAGE_BUILD_LOG,
            parent_collection=workflow_root.internal_collection,
            name="not-relevant",
            data={},
            created_by_user=workflow_root.created_by,
            created_by_workflow=workflow_root,
        )

        workflow_root_task.requires_artifact(
            child_requires,
            LookupMultiple.model_validate(
                {
                    "collection": "internal@collections",
                    "child_type": LookupChildType.BARE,
                }
            ),
        )

        self.assertEqual(child_requires.dependencies.count(), 2)
        self.assertQuerySetEqual(
            child_requires.dependencies.all(),
            [child_provides_1, child_provides_2],
        )

    def test_lookup(self) -> None:
        """Test lookup of Workflow orchestrators."""
        self.assertEqual(
            DBTask.class_from_name(TaskTypes.WORKFLOW, "noop"), NoopWorkflow
        )
        self.assertEqual(Workflow.from_name("noop"), NoopWorkflow)

    @preserve_db_task_registry()
    def test_work_request_ensure_child(self) -> None:
        """Test work_request_ensure_child create or return work request."""

        class ExampleWorkflow(
            SampleWorkflow[BaseWorkflowData, BaseDynamicTaskData]
        ):
            """Concrete workflow instance to use for tests."""

            @override
            def populate(self) -> None:
                raise NotImplementedError()

        workflow_root = self.playground.create_workflow(
            task_name="exampleworkflow", task_data={}
        )

        w = workflow_root.get_task()
        assert isinstance(w, ExampleWorkflow)

        self.assertEqual(w.work_request.children.count(), 0)

        workflow_data = WorkRequestWorkflowData(
            display_name="Example step",
            step="example",
        )

        self.enterContext(
            mock.patch(
                "debusine.db.models.WorkRequest.apply_task_configuration"
            )
        )
        for method, kwargs in (
            ("worker", {"task_name": "noop", "workflow_data": workflow_data}),
            (
                "server",
                {"task_name": "servernoop", "workflow_data": workflow_data},
            ),
            (
                "internal",
                {"task_name": "workflow", "workflow_data": workflow_data},
            ),
            (
                "signing",
                {
                    "task_name": "debsign",
                    "task_data": DebsignData(unsigned=10, key="test"),
                    "workflow_data": workflow_data,
                },
            ),
            (
                "wait",
                {
                    "task_name": "externaldebsign",
                    "task_data": ExternalDebsignData(unsigned="test"),
                    "workflow_data": WorkRequestWorkflowData(
                        display_name="Example step",
                        step="example",
                        needs_input=False,
                    ),
                },
            ),
        ):
            with self.subTest(method=method, kwargs=repr(kwargs)):
                w.work_request.children.all().delete()
                factory_method = getattr(
                    w, f"work_request_ensure_child_{method}"
                )
                wr_created, created = factory_method(**kwargs)

                # One work_request got created
                self.assertEqual(w.work_request.children.count(), 1)
                self.assertTrue(created)

                # Try creating a new one (same task_name, task_data,
                # workflow_data...)
                wr_returned, created = factory_method(**kwargs)

                # No new work request created
                self.assertEqual(w.work_request.children.count(), 1)
                self.assertFalse(created)

                # Returned the same as had been created
                self.assertEqual(wr_created, wr_returned)

    @preserve_db_task_registry()
    def test_get_input_artifacts_ids(self) -> None:
        class ExampleWorkflow(
            SampleWorkflow[BaseWorkflowData, BaseDynamicTaskData]
        ):
            """Concrete workflow instance to use for tests."""

            @override
            def populate(self) -> None:
                raise NotImplementedError()

        wr = self.playground.create_workflow(task_name="exampleworkflow")
        wf = wr.get_task()

        mock_artifacts = [
            InputArtifactSingle(
                artifact_id=1, lookup=1, label="source_artifact"
            ),
            InputArtifactMultiple.from_ids(
                artifact_ids=None,
                lookup=LookupMultiple(
                    ("internal@collections/name:buildlog-amd64",)
                ),
                label="package_log",
            ),
            InputArtifactMultiple.from_ids(
                artifact_ids=[5, 6],
                lookup=LookupMultiple((5, 6)),
                label="binary_artifacts",
            ),
        ]

        with mock.patch.object(
            wf, "get_input_artifacts", return_value=mock_artifacts
        ):
            self.assertEqual(wf.get_input_artifacts_ids(), [1, 5, 6])
