# Copyright 2024 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
import re

from debusine.artifacts.models import (
    ArtifactCategory,
    BareDataCategory,
    CollectionCategory,
    DebianSourcePackage,
)
from debusine.client.models import LookupChildType
from debusine.db.models import CollectionItem, WorkRequest, default_workspace
from debusine.server.collections.lookup import LookupResult
from debusine.server.workflows import NoopWorkflow, Workflow
from debusine.server.workflows.base import ArtifactHasNoArchitecture
from debusine.server.workflows.models import (
    BaseWorkflowData,
    WorkRequestWorkflowData,
)
from debusine.signing.tasks.models import DebsignData
from debusine.tasks import BaseTask
from debusine.tasks.models import (
    ActionTypes,
    BaseDynamicTaskData,
    LookupMultiple,
    TaskTypes,
)
from debusine.tasks.tests.helper_mixin import TestTaskMixin
from debusine.test.django import TestCase
from debusine.test.utils import preserve_task_registry


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

    @preserve_task_registry()
    def test_create(self):
        """Test instantiating a Workflow."""

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

            def populate(self) -> None:
                """Unused abstract method from Workflow."""
                raise NotImplementedError()

        wr = WorkRequest(task_data={}, workspace=default_workspace())
        wf = ExampleWorkflow(wr)
        self.assertEqual(wf.data, BaseWorkflowData())
        self.assertEqual(wf.work_request, wr)
        self.assertEqual(wf.workspace, default_workspace())
        self.assertEqual(wf.work_request_id, wr.id)
        self.assertEqual(wf.workspace_name, wr.workspace.name)

    @preserve_task_registry()
    def test_registration(self):
        """Test class subclass registry."""

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

            def populate(self) -> None:
                """Unused abstract method from Workflow."""
                raise NotImplementedError()

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

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

            TASK_NAME = "examplename"

            def populate(self) -> None:
                """Unused abstract method from Workflow."""
                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", BaseTask._sub_tasks[TaskTypes.WORKFLOW]
        )
        self.assertNotIn(
            "exampleworkflowabc", BaseTask._sub_tasks[TaskTypes.WORKFLOW]
        )
        self.assertIn("examplename", BaseTask._sub_tasks[TaskTypes.WORKFLOW])

    @preserve_task_registry()
    def test_provides_artifact(self):
        """Test provides_artifact() update event_reactions.on_creation."""

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

            def populate(self) -> None:
                """Unused abstract method from Workflow."""
                raise NotImplementedError()

        workflow_root = self.playground.create_workflow()

        child = workflow_root.create_child("testing")

        workflow = ExampleWorkflow(child)

        self.assert_work_request_event_reactions(child, on_creation=[])

        name = "testing"
        data = {"architecture": "amd64"}
        category = ArtifactCategory.TEST
        artifact_filters = {"xxx": "yyy"}

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

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

    @preserve_task_registry()
    def test_provides_artifact_raise_value_error(self):
        r"""Test requires_artifact() LookupError: key starts with promise\_."""

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

            def populate(self) -> None:
                """Unused abstract method from Workflow."""
                raise NotImplementedError()

        workflow_root = self.playground.create_workflow()

        child = workflow_root.create_child("testing")

        workflow = ExampleWorkflow(child)

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

    @preserve_task_registry()
    def test_requires_artifact_lookup_single(self):
        """Test requires_artifact() call work_request.add_dependency()."""

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

            def populate(self) -> None:
                """Unused abstract method from Workflow."""
                raise NotImplementedError()

        workflow_root = self.playground.create_workflow()

        # Provides the relevant artifact
        child_provides_1 = workflow_root.create_child("provider")

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

        child_requires = workflow_root.create_child("requirer")

        workflow = ExampleWorkflow(child_provides_1)

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

        workflow.provides_artifact(
            child_provides_1, ArtifactCategory.TEST, "build-amd64"
        )
        workflow.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.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.requires_artifact(
            child_requires, "internal@collections/name:build-amd64"
        )
        self.assertEqual(child_requires.dependencies.count(), 1)

    @preserve_task_registry()
    def test_requires_artifact_lookup_multiple(self):
        """Test requires_artifact() call work_request.add_dependency()."""

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

            def populate(self) -> None:
                """Unused abstract method from Workflow."""
                raise NotImplementedError()

        workflow_root = self.playground.create_workflow()

        # Providers that will match
        child_provides_1 = workflow_root.create_child("provider")
        child_provides_2 = workflow_root.create_child("provider")

        # Requirer
        child_requires = workflow_root.create_child("requirer")

        workflow = ExampleWorkflow(child_provides_1)

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

        workflow.provides_artifact(
            child_provides_1, ArtifactCategory.TEST, "build-amd64"
        )
        workflow.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.requires_artifact(
            child_requires,
            LookupMultiple.parse_obj(
                {
                    "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):
        """Test lookup of Workflow orchestrators."""
        self.assertEqual(
            BaseTask.class_from_name(TaskTypes.WORKFLOW, "noop"), NoopWorkflow
        )
        self.assertEqual(Workflow.from_name("noop"), NoopWorkflow)

    def test_lookup_result_architecture_promise_with_architecture(self) -> None:
        """lookup_result_architecture with a binary promise."""
        collection = self.playground.create_collection(
            "test", CollectionCategory.TEST
        )
        promise = self.playground.create_bare_data_item(
            collection,
            "test",
            category=BareDataCategory.PROMISE,
            data={"architecture": "amd64"},
        )
        result = LookupResult(
            result_type=CollectionItem.Types.BARE, collection_item=promise
        )

        self.assertEqual(Workflow.lookup_result_architecture(result), "amd64")

    def test_lookup_result_architecture_promise_no_architecture(self) -> None:
        """lookup_result_architecture rejects promise without architecture."""
        collection = self.playground.create_collection(
            "test", CollectionCategory.TEST
        )
        promise = self.playground.create_bare_data_item(
            collection, "test", category=BareDataCategory.PROMISE
        )
        result = LookupResult(
            result_type=CollectionItem.Types.BARE, collection_item=promise
        )

        with self.assertRaisesRegex(
            ValueError,
            re.escape(
                f"Cannot determine architecture for lookup result: {result}"
            ),
        ):
            Workflow.lookup_result_architecture(result)

    def test_lookup_result_binary_packages_artifact(self) -> None:
        """lookup_result_architecture with a binary-packages artifact."""
        artifact = self.playground.create_minimal_binary_packages_artifact(
            "hello", "1.0-1", "1.0-1", "i386"
        )
        result = LookupResult(
            result_type=CollectionItem.Types.ARTIFACT, artifact=artifact
        )

        self.assertEqual(Workflow.lookup_result_architecture(result), "i386")

    def test_lookup_result_debian_binary_package(self):
        """lookup_result_architecture return arch from a binary package."""
        artifact, _ = self.playground.create_artifact(
            category=ArtifactCategory.BINARY_PACKAGE,
            data={
                "srcpkg_name": "hello",
                "srcpkg_version": "1.0.0",
                "deb_fields": {"Architecture": "amd64"},
                "deb_control_files": [],
            },
        )

        result = LookupResult(
            result_type=CollectionItem.Types.ARTIFACT, artifact=artifact
        )

        self.assertEqual(
            Workflow.lookup_result_architecture(result),
            "amd64",
        )

    def test_lookup_result_debian_upload(self):
        """lookup_result_architecture return arch from an upload artifact."""
        artifact, _ = self.playground.create_artifact(
            category=ArtifactCategory.UPLOAD,
            data={
                "type": "dpkg",
                "changes_fields": {
                    "Architecture": "amd64",
                    "Files": [{"name": "test.deb"}],
                },
            },
        )

        result = LookupResult(
            result_type=CollectionItem.Types.ARTIFACT, artifact=artifact
        )

        self.assertEqual(
            Workflow.lookup_result_architecture(result),
            "amd64",
        )

    def test_lookup_result_other_artifacts(self) -> None:
        """lookup_result_architecture with source package artifact."""
        artifact, _ = self.create_artifact(
            category=ArtifactCategory.SOURCE_PACKAGE,
            data={
                "name": "hello",
                "version": "1.0-1",
                "type": "dpkg",
                "dsc_fields": {"Architecture": "any"},
            },
        )
        result = LookupResult(
            result_type=CollectionItem.Types.ARTIFACT, artifact=artifact
        )

        with self.assertRaisesRegex(
            ArtifactHasNoArchitecture,
            re.escape(f"{DebianSourcePackage!r}"),
        ):
            Workflow.lookup_result_architecture(result)

    def test_lookup_result_collection(self) -> None:
        """lookup_result_architecture with a collection."""
        collection = self.playground.create_collection(
            "test", CollectionCategory.TEST
        )
        result = LookupResult(
            result_type=CollectionItem.Types.COLLECTION, collection=collection
        )

        with self.assertRaisesRegex(
            ValueError,
            re.escape(
                "Unexpected result: must have collection_item or artifact"
            ),
        ):
            Workflow.lookup_result_architecture(result)

    def test_lookup_result_architecture_collection_item_invalid(self):
        """lookup_result_architecture raise error: invalid architecture type."""
        collection = self.playground.create_collection(
            "test", CollectionCategory.TEST
        )
        promise = self.playground.create_bare_data_item(
            collection,
            "test",
            category=BareDataCategory.PROMISE,
            data={"architecture": 000},
        )
        result = LookupResult(
            result_type=CollectionItem.Types.BARE, collection_item=promise
        )

        with self.assertRaisesRegex(
            ValueError,
            "Cannot determine architecture for lookup result:",
        ):
            Workflow.lookup_result_architecture(result)

    def test_lookup_result_unexpected_result(self):
        """lookup_result_architecture raise error: unexpected result."""
        result = LookupResult(result_type=CollectionItem.Types.BARE)

        with self.assertRaisesRegex(ValueError, "^Unexpected result: .*"):
            Workflow.lookup_result_architecture(result)

    def test_work_request_ensure_child(self):
        """Test work_request_ensure_child create or return work request."""

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

            def populate(self) -> None:
                """Unused abstract method from Workflow."""
                raise NotImplementedError()

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

        w = ExampleWorkflow(workflow_root)

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

        wr_created = w.work_request_ensure_child(
            task_name="debsign",
            task_data=DebsignData(unsigned=10, key="test").dict(
                exclude_unset=True
            ),
            workflow_data=WorkRequestWorkflowData(
                display_name="Sign upload",
                step="debsign",
            ),
        )

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

        # Try creating a new one (same task_name, task_data, workflow_data...)
        wr_returned = w.work_request_ensure_child(
            task_name="debsign",
            task_data=DebsignData(unsigned=10, key="test").dict(
                exclude_unset=True
            ),
            workflow_data=WorkRequestWorkflowData(
                display_name="Sign upload",
                step="debsign",
            ),
        )

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

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