# 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 package upload workflow."""
from typing import Any

from debusine.artifacts.models import ArtifactCategory, CollectionCategory
from debusine.db.models import (
    Artifact,
    CollectionItem,
    WorkRequest,
    WorkflowTemplate,
    default_workspace,
)
from debusine.server.workflows import (
    PackageUploadWorkflow,
    WorkflowValidationError,
)
from debusine.server.workflows.base import orchestrate_workflow
from debusine.server.workflows.models import (
    PackageUploadWorkflowDynamicData,
    WorkRequestWorkflowData,
)
from debusine.signing.tasks.models import DebsignData
from debusine.tasks.models import LookupMultiple, TaskTypes
from debusine.tasks.tests.helper_mixin import FakeTaskDatabase
from debusine.test.django import TestCase


class PackageUploadWorkflowTests(TestCase):
    """Unit tests for :py:class:`PackageUploadWorkflow`."""

    def create_package_upload_workflow(
        self,
        *,
        extra_task_data: dict[str, Any],
    ) -> PackageUploadWorkflow:
        """Create a package upload workflow."""
        task_data = {
            "target": "sftp://upload.example.org/queue/",
            "require_signature": False,
        }
        task_data.update(extra_task_data)
        wr = self.playground.create_workflow(
            task_name="package_upload", task_data=task_data
        )
        return PackageUploadWorkflow(wr)

    def create_source_package(self) -> Artifact:
        """Create a minimal `debian:source-package` artifact."""
        artifact, _ = self.playground.create_artifact(
            category=ArtifactCategory.SOURCE_PACKAGE, data={}
        )
        return artifact

    def create_binary_packages(
        self,
        srcpkg_name: str,
        srcpkg_version: str,
        version: str,
        architecture: str,
    ) -> Artifact:
        """Create a minimal `debian:binary-packages` artifact."""
        artifact, _ = self.playground.create_artifact(
            category=ArtifactCategory.BINARY_PACKAGES,
            data={
                "srcpkg_name": srcpkg_name,
                "srcpkg_version": srcpkg_version,
                "version": version,
                "architecture": architecture,
                "packages": [],
            },
        )
        return artifact

    def create_source_binary_artifacts(self) -> tuple[Artifact, Artifact]:
        """Return one source and one binary package."""
        collection = self.playground.create_collection(
            "test", CollectionCategory.TEST
        )

        source = self.create_source_package()
        binary = self.create_binary_packages("hello", "1.0-1", "1.0-1", "amd64")
        CollectionItem.objects.create_from_artifact(
            binary,
            parent_collection=collection,
            name="binary",
            created_by_user=self.get_test_user(),
            data={},
            created_by_workflow=None,
        )

        return source, binary

    def test_validate_input_source_binary_artifacts_not_set(self):
        """Invalid: source_artifact and binaries_artifacts not set."""
        w = self.create_package_upload_workflow(extra_task_data={})

        with self.assertRaisesRegex(
            WorkflowValidationError,
            '"source_artifact" or "binary_artifacts" must be set',
        ):
            w.validate_input()

    def test_validate_input_mergeuploads_set_vendor_codename_not_set(self):
        """Raise WorkflowValidationError: vendor and codename required."""
        w = self.create_package_upload_workflow(
            extra_task_data={"merge_uploads": True, "binary_artifacts": [1]}
        )

        with self.assertRaisesRegex(
            WorkflowValidationError,
            '"vendor" and "codename" are required '
            'when "merge_uploads" is set',
        ):
            w.validate_input()

    def test_validate_input_source_package_vendor_codename_not_set(self):
        """Raise WorkflowValidationError: vendor and codename required."""
        source = self.create_source_package()

        w = self.create_package_upload_workflow(
            extra_task_data={
                "merge_uploads": True,
                "source_artifact": source.id,
            }
        )

        with self.assertRaisesRegex(
            WorkflowValidationError,
            '"vendor" and "codename" are required when '
            'source artifact category is debian:source-package',
        ):
            w.validate_input()

    def test_create_orchestrator(self) -> None:
        """A PackageUploadWorkflow can be instantiated."""
        source_artifact = 2
        binary_artifacts = ["internal@collections/name:build-arm64"]
        target_distribution = "debian:bookworm"

        w = self.create_package_upload_workflow(
            extra_task_data={
                "source_artifact": source_artifact,
                "binary_artifacts": binary_artifacts,
                "target_distribution": target_distribution,
            }
        )

        self.assertEqual(w.data.source_artifact, source_artifact)
        self.assertEqual(
            w.data.binary_artifacts, LookupMultiple.parse_obj(binary_artifacts)
        )

        self.assertEqual(w.data.target_distribution, target_distribution)

    def test_compute_dynamic_data(self) -> None:
        """Dynamic data receives relevant artifact IDs."""
        binary_artifacts_lookup = LookupMultiple.parse_obj(
            [
                "internal@collections/name:build-amd64",
                "internal@collections/name:build-i386",
            ]
        )

        task_db = FakeTaskDatabase(
            single_lookups={
                # source_artifact
                (42, None): 42,
            },
            multiple_lookups={
                # binary_artifacts
                (binary_artifacts_lookup, None): [43, 44],
            },
        )

        wr = self.playground.create_workflow(
            task_name="package_upload",
            task_data={
                "source_artifact": 42,
                "binary_artifacts": [
                    "internal@collections/name:build-amd64",
                    "internal@collections/name:build-i386",
                ],
                "target": "sftp://upload.example.org/queue",
            },
        )

        workflow = PackageUploadWorkflow(wr)

        self.assertEqual(
            workflow.compute_dynamic_data(task_db),
            PackageUploadWorkflowDynamicData(
                source_artifact_id=42,
                binary_artifacts_ids=[43, 44],
            ),
        )

    def orchestrate(self, *, data: dict[str, Any]) -> WorkRequest:
        """Create a PackageUpload workflow and call orchestrate_workflow."""
        template = WorkflowTemplate.objects.create(
            name="package_upload",
            workspace=default_workspace(),
            task_name="package_upload",
        )

        wr = WorkRequest.objects.create_workflow(
            template=template,
            data={**{"target": "sftp://upload.example.org/queue"}, **data},
            created_by=self.get_test_user(),
        )

        wr.mark_running()
        orchestrate_workflow(wr)

        return wr

    def test_populate_makesourcepackageupload(self) -> None:
        """
        Workflow create two children work request.

        Work requests created:
        -makesourcepackageupload
        -package_upload
        """
        source = self.create_source_package()

        wr = self.orchestrate(
            data={
                "source_artifact": f"{source.id}@artifacts",
                "target_distribution": "debian:bookworm",
                "since_version": "1.0",
                "require_signature": False,
                "vendor": "debian",
                "codename": "bullseye",
            }
        )

        self.assertEqual(wr.children.count(), 2)

        [make_source] = wr.children.filter(task_name="makesourcepackageupload")

        self.assertEqual(
            make_source.workflow_data_json,
            {
                "display_name": "Make .changes file",
                "step": "source-package-upload",
            },
        )

        self.assertEqual(
            make_source.task_data,
            {
                "environment": "debian/match:codename=bullseye",
                "input": {"source_artifact": f"{source.id}@artifacts"},
                "since_version": "1.0",
                "target_distribution": "debian:bookworm",
            },
        )

        self.assertEqual(make_source.parent, wr)

        self.assertEqual(
            make_source.event_reactions_json,
            {
                "on_creation": [],
                "on_failure": [],
                "on_success": [
                    {
                        "action": "update-collection-with-artifacts",
                        "artifact_filters": {"category": "debian:upload"},
                        "collection": "internal@collections",
                        "name_template": "package-upload-source",
                        "variables": None,
                    }
                ],
                "on_unblock": [],
            },
        )

        [package_upload] = wr.children.filter(task_name="packageupload")
        self.assertEqual(package_upload.task_type, TaskTypes.SERVER)
        self.assertEqual(
            package_upload.workflow_data_json,
            {
                "display_name": (
                    "Package upload internal@collections/"
                    "name:package-upload-source"
                ),
                "step": (
                    "package-upload-internal@collections/"
                    "name:package-upload-source"
                ),
            },
        )
        self.assertEqual(
            package_upload.task_data,
            {
                "input": {
                    "upload": "internal@collections/name:package-upload-source"
                },
                "target": "sftp://upload.example.org/queue",
            },
        )

        self.assertQuerysetEqual(
            package_upload.dependencies.all(),
            WorkRequest.objects.filter(task_name="makesourcepackageupload"),
        )

    def test_populate_source_is_package_upload(self):
        """Category source package is UPLOAD: skip makesourcepackageupload."""
        source = self.create_source_package()
        source.category = ArtifactCategory.UPLOAD
        source.save()

        wr = self.orchestrate(
            data={
                "source_artifact": f"{source.id}@artifacts",
                "target_distribution": "debian:bookworm",
                "since_version": "1.0",
                "require_signature": False,
            }
        )

        self.assertEqual(wr.children.count(), 1)

        [package_upload] = wr.children.filter(task_name="packageupload")
        self.assertEqual(
            package_upload.workflow_data_json,
            {
                "display_name": f"Package upload {source.id}@artifacts",
                "step": f"package-upload-{source.id}@artifacts",
            },
        )
        self.assertEqual(
            package_upload.task_data,
            {
                "input": {"upload": f"{source.id}@artifacts"},
                "target": "sftp://upload.example.org/queue",
            },
        )

    def test_populate_package_uploads_binaries_only(self) -> None:
        """The workflow create children package uploads: binaries only."""
        (_, binary) = self.create_source_binary_artifacts()

        wr = self.orchestrate(
            data={
                "binary_artifacts": [binary.id],
                "target_distribution": "debian:bookworm",
                "require_signature": False,
            },
        )

        self.assertEqual(
            wr.children.filter(task_name="packageupload").count(), 1
        )

        # Other properties of the created package is tested on different
        # unit tests

    def test_populate_merge_uploads(self):
        """Test mergeuploads task is created (merge_uploads=True)."""
        (source, binary_1) = self.create_source_binary_artifacts()

        binary_2 = self.create_binary_packages(
            "hello", "1.0-1", "1.0-1", "amd64"
        )

        CollectionItem.objects.create_from_artifact(
            binary_2,
            parent_collection=binary_1.parent_collections.first(),
            name="binary-2",
            created_by_user=self.get_test_user(),
            data={},
            created_by_workflow=None,
        )

        wr = self.orchestrate(
            data={
                "source_artifact": f"{source.id}@artifacts",
                "binary_artifacts": [binary_1.id, binary_2.id],
                "require_signature": False,
                "target_distribution": "debian/match:codename=bookworm",
                "merge_uploads": True,
                "vendor": "debian",
                "codename": "bullseye",
            }
        )

        self.assertEqual(
            wr.children.filter(task_name="mergeuploads").count(), 1
        )

        [merge_uploads] = wr.children.filter(task_name="mergeuploads")

        self.assertEqual(
            merge_uploads.task_data,
            {
                "environment": "debian/match:codename=bullseye",
                "input": {
                    "uploads": [
                        "internal@collections/name:package-upload-source",
                        f"{binary_1.id}@artifacts",
                        f"{binary_2.id}@artifacts",
                    ]
                },
            },
        )

        self.assertQuerysetEqual(
            merge_uploads.dependencies.all(),
            WorkRequest.objects.filter(task_name="makesourcepackageupload"),
        )

        self.assertEqual(
            wr.children.filter(task_name="packageupload").count(), 1
        )
        [package_upload] = wr.children.filter(task_name="packageupload")
        self.assertEqual(
            package_upload.task_data,
            {
                "input": {
                    "upload": "internal@collections/name:package-upload-merged"
                },
                "target": "sftp://upload.example.org/queue",
            },
        )

    def test_upload_debsign(self):
        """Upload signed artifacts."""
        source, binary = self.create_source_binary_artifacts()
        key, _ = self.create_artifact(category=ArtifactCategory.SIGNING_KEY)

        wr = self.orchestrate(
            data={
                "source_artifact": f"{source.id}@artifacts",
                "binary_artifacts": [binary.id],
                "require_signature": False,
                "target_distribution": "debian/match:codename=bookworm",
                "merge_uploads": True,
                "key": f"{key.id}@artifacts",
            },
        )

        [signer] = wr.children.filter(task_name="debsign")
        self.assertEqual(
            signer.task_data,
            {
                "key": f"{key.id}@artifacts",
                "unsigned": "internal@collections/name:package-upload-merged",
            },
        )
        identifier = "internal@collections/name:package-upload-merged"
        self.assertEqual(
            signer.workflow_data_json,
            {
                "display_name": f"Sign upload for {identifier}",
                "step": f"debsign-{identifier}",
            },
        )

        signed_name = (
            "package-upload-signed-"
            "internal_collections_name_package-upload-merged"
        )
        self.assertEqual(
            signer.event_reactions_json,
            {
                "on_creation": [],
                "on_failure": [],
                "on_success": [
                    {
                        "action": "update-collection-with-artifacts",
                        "artifact_filters": {"category": "debian:upload"},
                        "collection": "internal@collections",
                        "name_template": signed_name,
                        "variables": None,
                    }
                ],
                "on_unblock": [],
            },
        )

        [uploader] = wr.children.filter(task_name="packageupload")
        self.assertEqual(
            uploader.task_data,
            {
                "input": {"upload": f"internal@collections/name:{signed_name}"},
                "target": "sftp://upload.example.org/queue",
            },
        )

        self.assertEqual(
            uploader.workflow_data_json,
            {
                "display_name": "Package upload "
                "internal@collections/name:package-upload-merged",
                "step": (
                    "package-upload-internal@collections/"
                    "name:package-upload-merged"
                ),
            },
        )

    def test_upload_externaldebsign(self):
        """Upload external signed artifacts."""
        source, binary = self.create_source_binary_artifacts()

        wr = self.orchestrate(
            data={
                "source_artifact": f"{source.id}@artifacts",
                "binary_artifacts": [binary.id],
                "target_distribution": "debian/match:codename=bookworm",
                "merge_uploads": True,
                "require_signature": True,
            },
        )

        [signer] = wr.children.filter(task_name="externaldebsign")
        self.assertEqual(
            signer.task_data,
            {"unsigned": "internal@collections/name:package-upload-merged"},
        )
        identifier = "internal@collections/name:package-upload-merged"
        self.assertEqual(
            signer.workflow_data_json,
            {
                "display_name": (
                    "Wait for signature on " f"upload for {identifier}"
                ),
                "step": f"external-debsign-{identifier}",
            },
        )

        signed_name = (
            "package-upload-signed-"
            "internal_collections_name_package-upload-merged"
        )
        self.assertEqual(
            signer.event_reactions_json,
            {
                "on_creation": [],
                "on_failure": [],
                "on_success": [
                    {
                        "action": "update-collection-with-artifacts",
                        "artifact_filters": {"category": "debian:upload"},
                        "collection": "internal@collections",
                        "name_template": signed_name,
                        "variables": None,
                    }
                ],
                "on_unblock": [],
            },
        )

        [uploader] = wr.children.filter(task_name="packageupload")
        self.assertEqual(
            uploader.task_data,
            {
                "input": {"upload": f"internal@collections/name:{signed_name}"},
                "target": "sftp://upload.example.org/queue",
            },
        )

    def test_orchestrate_idempotent(self):
        """Calling orchestrate twice does not create new work requests."""
        source, binary = self.create_source_binary_artifacts()

        wr = self.orchestrate(
            data={
                "source_artifact": f"{source.id}@artifacts",
                "binary_artifacts": [binary.id],
            },
        )

        children = set(wr.children.all())

        PackageUploadWorkflow(wr).populate()

        self.assertQuerysetEqual(wr.children.all(), children, ordered=False)

    def test_work_request_create_child_if_needed(self):
        """Test work_request_create_child_if_needed."""
        source = self.create_source_package()

        w = self.create_package_upload_workflow(
            extra_task_data={
                "source_artifact": source.id,
            }
        )

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

        wr_created = w.work_request_create_child_if_needed(
            task_name="debsign",
            task_data=DebsignData(unsigned=source.id, 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)

        wr_returned = w.work_request_create_child_if_needed(
            task_name="debsign",
            task_data=DebsignData(unsigned=source.id, 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)

    def test_get_label(self):
        """Test get_label()."""
        w = self.create_package_upload_workflow(
            extra_task_data={
                "source_artifact": 2,
                "target_distribution": "debian:bookworm",
            }
        )
        self.assertEqual(w.get_label(), "run package uploads")
