# 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 SbuildWorkflow class."""
from typing import Any

from django.test import TestCase

from debusine.artifacts.models import (
    ArtifactCategory,
    BareDataCategory,
    CollectionCategory,
)
from debusine.db.models import (
    CollectionItem,
    WorkRequest,
    WorkflowTemplate,
    default_workspace,
)
from debusine.server.collections.tests.utils import CollectionTestMixin
from debusine.server.workflows import SbuildWorkflow, WorkflowValidationError
from debusine.server.workflows.base import orchestrate_workflow
from debusine.server.workflows.models import SbuildWorkflowDynamicData
from debusine.tasks.models import ActionTypes, BackendType, TaskTypes
from debusine.tasks.tests.helper_mixin import FakeTaskDatabase


class SbuildWorkflowTests(CollectionTestMixin, TestCase):
    """Unit tests for SbuildWorkflow class."""

    def create_sbuild_template(
        self, name: str = "sbuild", task_data: dict[str, Any] | None = None
    ):
        """Create a sbuild WorkflowTemplate."""
        return WorkflowTemplate.objects.create(
            name=name,
            workspace=default_workspace(),
            task_name="sbuild",
            task_data=task_data or {},
        )

    def test_create_orchestrator(self):
        """Test instantiating a SbuildWorkflow."""
        task_data = {
            "input": {
                "source_artifact": 42,
            },
            "target_distribution": "debian:bookworm",
            "architectures": ["all", "amd64"],
        }
        wr = WorkRequest(
            task_data=task_data,
            workspace=default_workspace(),
        )
        w = SbuildWorkflow(wr)
        self.assertEqual(w.data.input.source_artifact, 42)
        self.assertEqual(w.data.target_distribution, "debian:bookworm")
        self.assertEqual(w.data.backend, BackendType.UNSHARE)
        self.assertEqual(w.data.architectures, ["all", "amd64"])
        self.assertIsNone(w.data.binnmu)

        task_data["binnmu"] = {
            "changelog": "Rebuild for 64bit time_t",
            "suffix": "+b1",
        }
        wr = WorkRequest(
            task_data=task_data,
            workspace=default_workspace(),
        )
        w = SbuildWorkflow(wr)
        self.assertEqual(w.data.binnmu.suffix, "+b1")

    def test_compute_dynamic_data(self) -> None:
        """Dynamic data receives relevant artifact/collection IDs."""
        task_db = FakeTaskDatabase(
            single_lookups={
                # input.source_artifact
                (42, None): 42,
                # build_logs_collection
                ("debian", CollectionCategory.PACKAGE_BUILD_LOGS): 1,
            }
        )
        wr = WorkRequest(
            task_data={
                "input": {"source_artifact": 42},
                "target_distribution": "debian:bookworm",
                "architectures": ["all"],
                "build_logs_collection": "debian",
            },
            workspace=default_workspace(),
        )
        o = SbuildWorkflow(wr)

        self.assertEqual(
            o.compute_dynamic_data(task_db),
            SbuildWorkflowDynamicData(
                input_source_artifact_id=42, build_logs_collection_id=1
            ),
        )

    def test_compute_dynamic_data_no_build_logs_collection(self) -> None:
        """`compute_dynamic_data` tolerates an unset build_logs_collection."""
        task_db = FakeTaskDatabase(
            single_lookups={
                # input.source_artifact
                (42, None): 42,
            }
        )
        wr = WorkRequest(
            task_data={
                "input": {"source_artifact": 42},
                "target_distribution": "debian:bookworm",
                "architectures": ["all"],
            },
            workspace=default_workspace(),
        )
        o = SbuildWorkflow(wr)

        self.assertEqual(
            o.compute_dynamic_data(task_db),
            SbuildWorkflowDynamicData(
                input_source_artifact_id=42, build_logs_collection_id=None
            ),
        )

    def test_validate_input(self):
        """Test that validate_input passes a valid case."""
        user = self.get_test_user()

        tarball_item = self.playground.create_debian_environment()

        source = self.create_source_package(
            "hello", "1.0", dsc_fields={"Architecture": "all"}
        )

        template = self.create_sbuild_template()
        wr = WorkRequest.objects.create_workflow(
            template=template,
            data={
                "input": {
                    "source_artifact": source.pk,
                },
                "target_distribution": "debian:bookworm",
                "architectures": ["all", "amd64"],
            },
            created_by=user,
        )
        self.assertEqual(wr.status, WorkRequest.Statuses.PENDING)

        o = SbuildWorkflow(wr)
        self.assertEqual(o.source_package, source)
        self.assertEqual(o.architectures, {"all"})
        self.assertEqual(o._get_environment("amd64"), tarball_item.artifact)
        o.validate_input()

        # environment_variant
        data = {
            "codename": "bookworm",
            "architecture": "amd64",
            "variant": "buildd",
        }
        tarball, _ = self.create_artifact(
            category=ArtifactCategory.SYSTEM_TARBALL,
            data={**data, "with_dev": True},
        )
        # add with explicit :variant
        tarball_item = self.playground.create_debian_environment(
            environment=tarball_item.artifact,
            variables={"variant": "buildd", "backend": "unshare"},
        )

        wr = WorkRequest.objects.create_workflow(
            template=template,
            data={
                "input": {
                    "source_artifact": source.pk,
                },
                "target_distribution": "debian:bookworm",
                "architectures": ["all", "amd64"],
                "environment_variant": "buildd",
            },
            created_by=user,
        )
        o = SbuildWorkflow(wr)
        self.assertEqual(o._get_environment("amd64"), tarball_item.artifact)
        o.validate_input()

    def test_computation_of_architecture_intersection(self) -> None:
        """Test computation of architectures to build."""
        user = self.get_test_user()

        template = self.create_sbuild_template()

        dsc_archs = {
            "any": {"amd64", "arm64"},
            "all": {
                "all",
            },
            "any all": {"all", "amd64", "arm64"},
            "arm64": {
                "arm64",
            },
            "linux-any": {"amd64", "arm64"},
            "gnu-any-any": {"amd64", "arm64"},
        }
        for dsc_arch, expected_archs in dsc_archs.items():
            source = self.create_source_package(
                "hello", "1.0", dsc_fields={"Architecture": dsc_arch}
            )

            for wr_archs in (["amd64"], ["all"], ["arm64", "all"]):
                with self.subTest("", dsc_arch=dsc_arch, wr_archs=wr_archs):
                    o = SbuildWorkflow(
                        WorkRequest.objects.create_workflow(
                            template=template,
                            data={
                                "input": {
                                    "source_artifact": source.pk,
                                },
                                "target_distribution": "debian:bookworm",
                                "architectures": wr_archs,
                            },
                            created_by=user,
                        ),
                    )
                    archs = expected_archs.intersection(wr_archs)
                    if archs:
                        self.assertSetEqual(o.architectures, archs)
                    else:
                        with self.assertRaisesRegex(
                            WorkflowValidationError,
                            "None of the workflow architectures are supported"
                            " by this package",
                        ):
                            o.architectures

    def test_create_work_request_schroot(self) -> None:
        """Test creating a Sbuild workflow workrequest with schroot."""
        # Create a source package
        source = self.create_source_package(
            "hello", "1.0", dsc_fields={"Architecture": "all amd64 s390x"}
        )

        template = self.create_sbuild_template()
        wr = WorkRequest.objects.create_workflow(
            template=template,
            data={
                "input": {
                    "source_artifact": source.pk,
                },
                "backend": "schroot",
                "target_distribution": "debian:bookworm",
                "architectures": ["all", "amd64", "s390x"],
            },
            created_by=self.get_test_user(),
        )
        self.assertEqual(wr.status, WorkRequest.Statuses.PENDING)
        wr.mark_running()
        orchestrate_workflow(wr)

        expected_children = [
            (
                "all",
                {
                    "build_components": ["all"],
                    "build_profiles": None,
                    "host_architecture": "amd64",
                },
            ),
            (
                "amd64",
                {
                    "build_components": ["any"],
                    "build_profiles": None,
                    "host_architecture": "amd64",
                },
            ),
            (
                "s390x",
                {
                    "build_components": ["any"],
                    "build_profiles": None,
                    "host_architecture": "s390x",
                },
            ),
        ]

        children = list(WorkRequest.objects.filter(parent=wr).order_by("id"))
        self.assertEqual(len(children), len(expected_children))

        for child, expected_child in zip(children, expected_children):
            expected_arch, expected_data = expected_child
            self.assertEqual(child.status, WorkRequest.Statuses.PENDING)
            self.assertEqual(child.task_type, TaskTypes.WORKER)
            self.assertEqual(child.task_name, "sbuild")
            self.assertEqual(
                child.task_data,
                {
                    'backend': 'schroot',
                    'distribution': "bookworm",
                    'environment': None,
                    'input': {
                        'extra_binary_artifacts': [],
                        'source_artifact': source.pk,
                    },
                    'binnmu': None,
                    **expected_data,
                },
            )
            promise = CollectionItem.objects.get(
                parent_collection=wr.internal_collection,
                child_type=CollectionItem.Types.BARE,
                category=BareDataCategory.PROMISE,
                name=f"build-{expected_arch}",
            )
            self.assertEqual(
                promise.data,
                {
                    "promise_work_request_id": child.id,
                    "promise_workflow_id": wr.id,
                    "promise_category": ArtifactCategory.UPLOAD,
                    "architecture": expected_arch,
                },
            )
            self.assert_work_request_event_reactions(
                child,
                on_success=[
                    {
                        "action": ActionTypes.UPDATE_COLLECTION_WITH_ARTIFACTS,
                        "artifact_filters": {
                            "category": ArtifactCategory.UPLOAD
                        },
                        "collection": "internal@collections",
                        "name_template": f"build-{expected_arch}",
                        "variables": {"architecture": expected_arch},
                    },
                ],
            )
            self.assertQuerysetEqual(child.dependencies.all(), [])
            self.assertEqual(
                child.workflow_data_json,
                {
                    "display_name": f"Build {expected_arch}",
                    "step": f"build-{expected_arch}",
                },
            )

        # populate() idempotence
        orchestrator = SbuildWorkflow(wr)
        orchestrator.populate()
        children = list(WorkRequest.objects.filter(parent=wr))
        self.assertEqual(len(children), len(expected_children))

    def test_create_work_request_unshare(self) -> None:
        """Test creating a Sbuild workflow workrequest with unshare."""
        environments = self.playground.create_debian_environments_collection()
        tarballs = {
            arch: self.create_artifact(
                category=ArtifactCategory.SYSTEM_TARBALL,
                data={
                    "codename": "bookworm",
                    "architecture": arch,
                    "variant": "apt",
                    "with_dev": True,
                },
            )[0]
            for arch in ("amd64", "s390x")
        }
        for arch in ("amd64", "s390x"):
            environments.manager.add_artifact(
                tarballs[arch],
                user=self.get_test_user(),
                variables={"backend": "unshare"},
            )

        # Create a source package
        source = self.create_source_package(
            "hello", "1.0", dsc_fields={"Architecture": "all amd64 s390x"}
        )

        template = self.create_sbuild_template()
        wr = WorkRequest.objects.create_workflow(
            template=template,
            data={
                "input": {
                    "source_artifact": source.pk,
                },
                "backend": "unshare",
                "target_distribution": "debian:bookworm",
                "architectures": ["all", "amd64", "s390x"],
            },
            created_by=self.get_test_user(),
        )
        self.assertEqual(wr.status, WorkRequest.Statuses.PENDING)
        wr.mark_running()
        orchestrate_workflow(wr)

        expected_children = [
            (
                "all",
                {
                    "build_components": ["all"],
                    "build_profiles": None,
                    "host_architecture": "amd64",
                },
            ),
            (
                "amd64",
                {
                    "build_components": ["any"],
                    "build_profiles": None,
                    "host_architecture": "amd64",
                },
            ),
            (
                "s390x",
                {
                    "build_components": ["any"],
                    "build_profiles": None,
                    "host_architecture": "s390x",
                },
            ),
        ]

        children = list(WorkRequest.objects.filter(parent=wr).order_by("id"))
        self.assertEqual(len(children), len(expected_children))

        for child, expected_child in zip(children, expected_children):
            expected_arch, expected_data = expected_child
            self.assertEqual(child.status, WorkRequest.Statuses.PENDING)
            self.assertEqual(child.task_type, TaskTypes.WORKER)
            self.assertEqual(child.task_name, "sbuild")
            self.assertEqual(
                child.task_data,
                {
                    'backend': 'unshare',
                    'environment': 'debian/match:codename=bookworm',
                    'distribution': None,
                    'input': {
                        'extra_binary_artifacts': [],
                        'source_artifact': source.pk,
                    },
                    'binnmu': None,
                    **expected_data,
                },
            )
            promise = CollectionItem.objects.get(
                parent_collection=wr.internal_collection,
                child_type=CollectionItem.Types.BARE,
                category=BareDataCategory.PROMISE,
                name=f"build-{expected_arch}",
            )
            self.assertEqual(
                promise.data,
                {
                    "promise_work_request_id": child.id,
                    "promise_workflow_id": wr.id,
                    "promise_category": ArtifactCategory.UPLOAD,
                    "architecture": expected_arch,
                },
            )
            self.assert_work_request_event_reactions(
                child,
                on_success=[
                    {
                        "action": ActionTypes.UPDATE_COLLECTION_WITH_ARTIFACTS,
                        "artifact_filters": {
                            "category": ArtifactCategory.UPLOAD
                        },
                        "collection": "internal@collections",
                        "name_template": f"build-{expected_arch}",
                        "variables": {"architecture": expected_arch},
                    }
                ],
            )
            self.assertQuerysetEqual(child.dependencies.all(), [])
            self.assertEqual(
                child.workflow_data_json,
                {
                    "display_name": f"Build {expected_arch}",
                    "step": f"build-{expected_arch}",
                },
            )

        # populate() idempotence
        orchestrator = SbuildWorkflow(wr)
        orchestrator.populate()
        children = list(WorkRequest.objects.filter(parent=wr))
        self.assertEqual(len(children), len(expected_children))

    def test_create_work_request_retain_build_logs(self) -> None:
        """Test retaining build logs in a collection."""
        environments = self.playground.create_debian_environments_collection()
        tarball, _ = self.create_artifact(
            category=ArtifactCategory.SYSTEM_TARBALL,
            data={
                "codename": "bookworm",
                "architecture": "amd64",
                "variant": "apt",
                "with_dev": True,
            },
        )
        environments.manager.add_artifact(
            tarball, user=self.get_test_user(), variables={"backend": "unshare"}
        )
        build_logs = self.playground.create_collection(
            name="debian", category=CollectionCategory.PACKAGE_BUILD_LOGS
        )

        # Create a source package
        source = self.create_source_package(
            "hello", "1.0", dsc_fields={"Architecture": "amd64"}
        )

        template = self.create_sbuild_template()
        wr = WorkRequest.objects.create_workflow(
            template=template,
            data={
                "input": {"source_artifact": source.pk},
                "backend": "unshare",
                "target_distribution": "debian:bookworm",
                "architectures": ["amd64"],
                "build_logs_collection": "debian",
            },
            created_by=self.get_test_user(),
        )
        self.assertEqual(wr.status, WorkRequest.Statuses.PENDING)
        wr.mark_running()
        orchestrate_workflow(wr)

        child = WorkRequest.objects.get(parent=wr)
        self.assertEqual(child.status, WorkRequest.Statuses.PENDING)
        self.assertEqual(child.task_type, TaskTypes.WORKER)
        self.assertEqual(child.task_name, "sbuild")
        self.assertEqual(
            child.task_data,
            {
                "input": {
                    "extra_binary_artifacts": [],
                    "source_artifact": source.pk,
                },
                "host_architecture": "amd64",
                "distribution": None,
                "build_components": ["any"],
                "build_profiles": None,
                "binnmu": None,
                "backend": "unshare",
                "environment": "debian/match:codename=bookworm",
            },
        )
        promise = CollectionItem.objects.get(
            parent_collection=wr.internal_collection,
            child_type=CollectionItem.Types.BARE,
            category=BareDataCategory.PROMISE,
            name="build-amd64",
        )
        self.assertEqual(
            promise.data,
            {
                "promise_work_request_id": child.id,
                "promise_workflow_id": wr.id,
                "promise_category": ArtifactCategory.UPLOAD,
                "architecture": "amd64",
            },
        )
        self.assert_work_request_event_reactions(
            child,
            on_creation=[
                {
                    "action": ActionTypes.UPDATE_COLLECTION_WITH_DATA,
                    "collection": build_logs.id,
                    "category": BareDataCategory.PACKAGE_BUILD_LOG,
                    "name_template": None,
                    "data": {
                        "work_request_id": child.id,
                        "vendor": "debian",
                        "codename": "bookworm",
                        "architecture": "amd64",
                        "srcpkg_name": "hello",
                        "srcpkg_version": "1.0",
                    },
                },
            ],
            on_unblock=[],
            on_success=[
                {
                    "action": ActionTypes.UPDATE_COLLECTION_WITH_ARTIFACTS,
                    "artifact_filters": {"category": ArtifactCategory.UPLOAD},
                    "collection": "internal@collections",
                    "name_template": "build-amd64",
                    "variables": {"architecture": "amd64"},
                },
                {
                    "action": ActionTypes.UPDATE_COLLECTION_WITH_ARTIFACTS,
                    "collection": build_logs.id,
                    "name_template": None,
                    "variables": {
                        "work_request_id": child.id,
                        "vendor": "debian",
                        "codename": "bookworm",
                        "architecture": "amd64",
                        "srcpkg_name": "hello",
                        "srcpkg_version": "1.0",
                    },
                    "artifact_filters": {
                        "category": ArtifactCategory.PACKAGE_BUILD_LOG
                    },
                },
            ],
            on_failure=[],
        )
        self.assertQuerysetEqual(child.dependencies.all(), [])
        self.assertEqual(
            child.workflow_data_json,
            {"display_name": "Build amd64", "step": "build-amd64"},
        )

    def test_create_work_request_retry_delays(self) -> None:
        """Test adding automatic retries with delays."""
        environments = self.playground.create_debian_environments_collection()
        tarball, _ = self.create_artifact(
            category=ArtifactCategory.SYSTEM_TARBALL,
            data={
                "codename": "bookworm",
                "architecture": "amd64",
                "variant": "apt",
                "with_dev": True,
            },
        )
        environments.manager.add_artifact(
            tarball, user=self.get_test_user(), variables={"backend": "unshare"}
        )
        source = self.create_source_package(
            "hello", "1.0", dsc_fields={"Architecture": "amd64"}
        )
        template = self.create_sbuild_template()
        wr = WorkRequest.objects.create_workflow(
            template=template,
            data={
                "input": {"source_artifact": source.pk},
                "backend": "unshare",
                "target_distribution": "debian:bookworm",
                "architectures": ["amd64"],
                "retry_delays": ["1h", "1d"],
            },
            created_by=self.get_test_user(),
        )
        self.assertEqual(wr.status, WorkRequest.Statuses.PENDING)
        wr.mark_running()
        orchestrate_workflow(wr)

        child = WorkRequest.objects.get(parent=wr)
        self.assertEqual(child.status, WorkRequest.Statuses.PENDING)
        self.assertEqual(child.task_type, TaskTypes.WORKER)
        self.assertEqual(child.task_name, "sbuild")
        self.assertEqual(
            child.task_data,
            {
                "input": {
                    "extra_binary_artifacts": [],
                    "source_artifact": source.pk,
                },
                "host_architecture": "amd64",
                "distribution": None,
                "build_components": ["any"],
                "build_profiles": None,
                "binnmu": None,
                "backend": "unshare",
                "environment": "debian/match:codename=bookworm",
            },
        )
        self.assert_work_request_event_reactions(
            child,
            on_creation=[],
            on_unblock=[],
            on_success=[
                {
                    "action": ActionTypes.UPDATE_COLLECTION_WITH_ARTIFACTS,
                    "artifact_filters": {"category": ArtifactCategory.UPLOAD},
                    "collection": "internal@collections",
                    "name_template": "build-amd64",
                    "variables": {"architecture": "amd64"},
                }
            ],
            on_failure=[
                {
                    "action": ActionTypes.RETRY_WITH_DELAYS,
                    "delays": ["1h", "1d"],
                }
            ],
        )

    def test_source_package_error_handling(self):
        """Test source_package error handling."""
        template = self.create_sbuild_template()

        def create_orchestrator(
            source,
            target_distribution="debian:bookworm",
            architectures=["amd64"],
            **kwargs,
        ):
            user = self.get_test_user()
            workflow = WorkRequest.objects.create_workflow(
                template=template,
                data={
                    "input": {
                        "source_artifact": source.pk,
                    },
                    "target_distribution": target_distribution,
                    "architectures": architectures,
                    **kwargs,
                },
                created_by=user,
            )
            orchestrator = SbuildWorkflow(workflow)
            return orchestrator

        invalid_artifact, _ = self.create_artifact(category="test")
        orchestrator = create_orchestrator(invalid_artifact)
        with self.assertRaisesRegex(
            ValueError, "source artifact is type 'test'"
        ):
            orchestrator.source_package

        source = self.create_source_package(
            "hello", "1.0", dsc_fields={"Architecture": ""}
        )
        orchestrator = create_orchestrator(source)
        with self.assertRaisesRegex(
            WorkflowValidationError, "package architecture list is empty"
        ):
            orchestrator.architectures

        source = self.create_source_package(
            "hello", "1.0", dsc_fields={"Architecture": "any-armhf"}
        )
        orchestrator = create_orchestrator(
            source, architectures=["kfreebsd-i386"]
        )
        with self.assertRaisesRegex(
            WorkflowValidationError,
            "None of the workflow architectures are supported by this package",
        ):
            orchestrator.architectures

        # wildcards support
        source = self.create_source_package(
            "hello", "1.0", dsc_fields={"Architecture": "any-amd64"}
        )
        orchestrator = create_orchestrator(
            source, architectures=["i386", "hurd-amd64"]
        )
        self.assertEqual(orchestrator.architectures, {"hurd-amd64"})

        # wildcards are for packages, not workflows
        source = self.create_source_package(
            "hello", "1.0", dsc_fields={"Architecture": "amd64"}
        )
        orchestrator = create_orchestrator(source, architectures=["any"])
        with self.assertRaisesRegex(
            WorkflowValidationError,
            "None of the workflow architectures are supported by this package",
        ):
            orchestrator.architectures

        orchestrator = create_orchestrator(source, backend="qemu")

        self.playground.create_debian_environments_collection()
        with self.assertRaisesRegex(
            WorkflowValidationError, "environment not found for"
        ):
            orchestrator._get_environment("any")

        orchestrator = create_orchestrator(source, target_distribution="Ubuntu")
        with self.assertRaisesRegex(
            WorkflowValidationError,
            "target_distribution must be in vendor:codename format",
        ):
            orchestrator.validate_input()

    def test_label(self) -> None:
        """Test get_label."""
        wr = WorkRequest(
            task_data={
                "input": {
                    "source_artifact": 42,
                },
                "target_distribution": "debian:bookworm",
                "architectures": ["all", "amd64"],
            },
            workspace=default_workspace(),
        )
        w = SbuildWorkflow(wr)
        self.assertEqual(w.get_label(), "build a package")
