# 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 ``update_suites`` workflow."""

import logging
from typing import override

from django.utils import timezone

import debusine.tasks.tags as ttags
from debusine.artifacts.models import CollectionCategory, TaskTypes
from debusine.assets import KeyPurpose
from debusine.assets.models import SigningKeyData
from debusine.db.models import (
    Collection,
    CollectionItem,
    WorkRequest,
    Workspace,
)
from debusine.db.playground import scenarios
from debusine.server.workflows import (
    UpdateSuitesWorkflow,
    WorkflowValidationError,
)
from debusine.server.workflows.base import orchestrate_workflow
from debusine.server.workflows.tests.helpers import WorkflowTestCase
from debusine.tasks.models import BaseDynamicTaskData


class UpdateSuitesWorkflowTests(WorkflowTestCase[UpdateSuitesWorkflow]):
    """Unit tests for :py:class:`UpdateSuitesWorkflow`."""

    scenario = scenarios.DefaultContext()

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.playground.add_user(
            cls.scenario.workspace_owners, cls.scenario.user
        )

    def create_binary_package_item(self, suite: Collection) -> CollectionItem:
        """Create a minimal debian:binary-package item in the suite."""
        return CollectionItem.objects.create_from_artifact(
            self.playground.create_minimal_binary_package_artifact(),
            parent_collection=suite,
            name="hello_1.0_amd64",
            data={},
            created_by_user=self.playground.get_default_user(),
        )

    def create_release_item(self, suite: Collection) -> CollectionItem:
        """Create a minimal debian:repository-index item in the suite."""
        return CollectionItem.objects.create_from_artifact(
            self.playground.create_repository_index("Release"),
            parent_collection=suite,
            name="index:Release",
            data={"path": "Release"},
            created_by_user=self.playground.get_default_user(),
        )

    def test_compute_scheduler_tags(self) -> None:
        self.assertComputesSchedulerTags(
            provide_system={ttags.GROUP_PREFIX + "debusine:System:owner"}
        )

    def test_compute_dynamic_data(self) -> None:
        self.assertEqual(
            self.compute_dynamic_data(self.task, strip_task_inputs=True),
            BaseDynamicTaskData(),
        )

    def test_validate_input(self) -> None:
        self.task.validate_input()

    def test_validate_input_not_workspace_owner(self) -> None:
        """Only a workspace owner may run this workflow."""
        self.scenario.workspace_owners.users.remove(self.scenario.user)
        workflow = self.playground.create_workflow(
            task_name="update_suites",
            workspace=self.scenario.workspace,
            validate=False,
        )

        with self.assertRaisesRegex(
            WorkflowValidationError,
            'Only a workspace owner may run the "update_suites" workflow',
        ):
            self.get_workflow(workflow).validate_input()

    def test_find_stale_suites(self) -> None:
        """Suites with changes after their latest Release file are selected."""
        workspaces = [
            self.playground.create_workspace(name=name, public=True)
            for name in ("one", "two")
        ]
        for workspace in workspaces:
            self.playground.create_group_role(
                workspace, Workspace.Roles.OWNER, [self.scenario.user]
            )
        suites = {
            name: self.playground.create_collection(
                name, CollectionCategory.SUITE, workspace=workspace
            )
            for name, workspace in (
                ("empty-unchanged", workspaces[0]),
                ("empty-item-created", workspaces[0]),
                ("release-unchanged", workspaces[0]),
                ("release-item-created", workspaces[0]),
                ("release-item-removed", workspaces[0]),
                ("different-workspace", workspaces[1]),
            )
        }
        self.create_binary_package_item(suites["release-unchanged"])
        release_item_removed_binary = self.create_binary_package_item(
            suites["release-item-removed"]
        )
        self.create_release_item(suites["release-unchanged"])
        self.create_release_item(suites["release-item-created"])
        self.create_release_item(suites["release-item-removed"])
        self.create_binary_package_item(suites["empty-item-created"])
        self.create_binary_package_item(suites["release-item-created"])
        release_item_removed_binary.removed_at = timezone.now()
        release_item_removed_binary.save()
        self.create_binary_package_item(suites["different-workspace"])
        workflow = self.playground.create_workflow(
            task_name="update_suites", workspace=workspaces[0]
        )

        self.assertQuerySetEqual(
            self.get_workflow(workflow)._find_stale_suites(),
            [
                suites[name]
                for name in sorted(
                    [
                        "empty-item-created",
                        "release-item-created",
                        "release-item-removed",
                    ]
                )
            ],
        )

    def test_find_stale_suites_force_basic_indexes(self) -> None:
        """``force_basic_indexes`` selects all suites in the workspace."""
        workspaces = [
            self.playground.create_workspace(name=name, public=True)
            for name in ("one", "two")
        ]
        for workspace in workspaces:
            self.playground.create_group_role(
                workspace, Workspace.Roles.OWNER, [self.scenario.user]
            )
        suites = {
            name: self.playground.create_collection(
                name, CollectionCategory.SUITE, workspace=workspace
            )
            for name, workspace in (
                ("one", workspaces[0]),
                ("two", workspaces[0]),
                ("three", workspaces[0]),
                ("four", workspaces[1]),
            )
        }
        workflow = self.playground.create_workflow(
            task_name="update_suites",
            task_data={"force_basic_indexes": True},
            workspace=workspaces[0],
        )

        self.assertQuerySetEqual(
            self.get_workflow(workflow)._find_stale_suites(),
            [suites[name] for name in sorted(["one", "two", "three"])],
        )

    def test_find_stale_suites_only_suites(self) -> None:
        """``only_suites`` limits to suites with the given names."""
        suites = {
            name: self.playground.create_collection(
                name, CollectionCategory.SUITE
            )
            for name in ("one", "two", "three")
        }
        for suite in suites.values():
            self.create_binary_package_item(suite)
        workflow = self.playground.create_workflow(
            task_name="update_suites", task_data={"only_suites": ["one", "two"]}
        )

        self.assertQuerySetEqual(
            self.get_workflow(workflow)._find_stale_suites(),
            [suites[name] for name in sorted(["one", "two"])],
        )

    def test_populate(self) -> None:
        """``populate`` creates child work requests for all stale suites."""
        workspace = self.scenario.workspace
        archive = self.playground.create_singleton_collection(
            CollectionCategory.ARCHIVE
        )
        suites = [
            self.playground.create_collection(name, CollectionCategory.SUITE)
            for name in ("one", "two")
        ]
        for suite in suites:
            self.create_binary_package_item(suite)
        workflow = self.playground.create_workflow(
            task_name="update_suites", workspace=workspace
        )

        workflow.set_current()
        self.assertTrue(orchestrate_workflow(workflow))

        self.assertQuerySetEqual(
            workflow.children.values_list(
                "task_type",
                "task_name",
                "task_data",
                "workflow_data_json",
                "status",
                "dependencies__task_type",
                "dependencies__task_name",
            ),
            [
                (
                    TaskTypes.SIGNING,
                    "generatekey",
                    {
                        "purpose": KeyPurpose.OPENPGP_REPOSITORY,
                        "description": f"Archive signing key for {workspace}",
                    },
                    {
                        "display_name": f"Generate signing key for {workspace}",
                        "step": "generate-signing-key",
                    },
                    WorkRequest.Statuses.PENDING,
                    None,
                    None,
                ),
                (
                    TaskTypes.INTERNAL,
                    "workflow",
                    {},
                    {
                        "display_name": "Record generated key",
                        "step": "generated-key",
                    },
                    WorkRequest.Statuses.BLOCKED,
                    TaskTypes.SIGNING,
                    "generatekey",
                ),
                (
                    TaskTypes.WORKFLOW,
                    "update_suite",
                    {"suite_collection": suites[0].id},
                    {"display_name": "Update one", "step": "update-one"},
                    WorkRequest.Statuses.BLOCKED,
                    TaskTypes.INTERNAL,
                    "workflow",
                ),
                (
                    TaskTypes.WORKFLOW,
                    "update_suite",
                    {"suite_collection": suites[1].id},
                    {"display_name": "Update two", "step": "update-two"},
                    WorkRequest.Statuses.BLOCKED,
                    TaskTypes.INTERNAL,
                    "workflow",
                ),
            ],
        )

        # Completing each step allows the next to proceed.
        generate_key = workflow.children.get(
            task_type=TaskTypes.SIGNING, task_name="generatekey"
        )
        signing_key = self.playground.create_signing_key_asset(
            purpose=KeyPurpose.OPENPGP_REPOSITORY,
            fingerprint="ABC123",
            public_key="Fake public key",
            description=f"Generate signing key for {workspace}",
            workspace=workspace,
            created_by_work_request=generate_key,
        )
        self.playground.advance_work_request(
            generate_key, result=WorkRequest.Results.SUCCESS
        )

        generated_key = workflow.children.workflow_callbacks().get()
        self.assertEqual(generated_key.status, WorkRequest.Statuses.PENDING)

        self.assertTrue(orchestrate_workflow(workflow))

        generated_key.refresh_from_db()
        self.assertEqual(generated_key.status, WorkRequest.Statuses.COMPLETED)
        archive.refresh_from_db()
        self.assertTrue(signing_key.usage.filter(workspace=workspace).exists())
        signing_key_data = signing_key.data_model
        assert isinstance(signing_key_data, SigningKeyData)
        self.assertEqual(
            archive.data, {"signing_keys": [signing_key_data.fingerprint]}
        )
        update_suite_children = workflow.children.filter(
            task_type=TaskTypes.WORKFLOW, task_name="update_suite"
        )
        self.assertQuerySetEqual(
            update_suite_children.values_list("task_data", "status"),
            [
                (
                    {"suite_collection": suite.id},
                    WorkRequest.Statuses.RUNNING,
                )
                for suite in suites
            ],
        )
        for child in update_suite_children:
            self.assertTrue(child.children.exists())

    def test_populate_no_archive(self) -> None:
        """``populate`` skips the case where the workspace has no archive."""
        workspace = self.scenario.workspace
        workspace.collections.filter(
            category=CollectionCategory.ARCHIVE
        ).delete()
        workflow = self.playground.create_workflow(
            task_name="update_suites", workspace=workspace
        )

        with self.assertLogsContains(
            f"Workspace {workspace} has no archive; not updating suites.",
            logger="debusine.server.workflows.update_suites",
            level=logging.INFO,
        ):
            self.assertTrue(orchestrate_workflow(workflow))

        self.assertFalse(workflow.children.exists())

    def test_populate_not_exported(self) -> None:
        """``populate`` skips suites that are marked as not to be exported."""
        workspace = self.scenario.workspace
        self.playground.create_singleton_collection(CollectionCategory.ARCHIVE)
        suites = [
            self.playground.create_collection(
                name, CollectionCategory.SUITE, data=data
            )
            for name, data in (("one", {}), ("two", {"exported": False}))
        ]
        for suite in suites:
            self.create_binary_package_item(suite)
        workflow = self.playground.create_workflow(
            task_name="update_suites", workspace=workspace
        )

        workflow.set_current()
        orchestrate_workflow(workflow)

        self.assertQuerySetEqual(
            workflow.children.filter(
                task_type=TaskTypes.WORKFLOW, task_name="update_suite"
            ).values_list("task_data__suite_collection", flat=True),
            [suites[0].id],
        )

    def test_populate_already_exists(self) -> None:
        """``populate`` skips work requests that already exist."""
        workspace = self.scenario.workspace
        self.playground.create_singleton_collection(CollectionCategory.ARCHIVE)
        suites = [
            self.playground.create_collection(name, CollectionCategory.SUITE)
            for name in ("one", "two")
        ]
        self.create_binary_package_item(suites[0])
        workflow = self.playground.create_workflow(
            task_name="update_suites", workspace=workspace, mark_running=True
        )

        self.assertTrue(orchestrate_workflow(workflow))

        expected_children = [
            (
                TaskTypes.SIGNING,
                "generatekey",
                {
                    "purpose": KeyPurpose.OPENPGP_REPOSITORY,
                    "description": f"Archive signing key for {workspace}",
                },
                {
                    "display_name": f"Generate signing key for {workspace}",
                    "step": "generate-signing-key",
                },
                WorkRequest.Statuses.PENDING,
                None,
                None,
            ),
            (
                TaskTypes.INTERNAL,
                "workflow",
                {},
                {
                    "display_name": "Record generated key",
                    "step": "generated-key",
                },
                WorkRequest.Statuses.BLOCKED,
                TaskTypes.SIGNING,
                "generatekey",
            ),
            (
                TaskTypes.WORKFLOW,
                "update_suite",
                {"suite_collection": suites[0].id},
                {"display_name": "Update one", "step": "update-one"},
                WorkRequest.Statuses.BLOCKED,
                TaskTypes.INTERNAL,
                "workflow",
            ),
        ]
        self.assertQuerySetEqual(
            workflow.children.values_list(
                "task_type",
                "task_name",
                "task_data",
                "workflow_data_json",
                "status",
                "dependencies__task_type",
                "dependencies__task_name",
            ),
            expected_children,
        )

        self.create_binary_package_item(suites[1])

        self.assertTrue(orchestrate_workflow(workflow))

        expected_children.append(
            (
                TaskTypes.WORKFLOW,
                "update_suite",
                {"suite_collection": suites[1].id},
                {"display_name": "Update two", "step": "update-two"},
                WorkRequest.Statuses.BLOCKED,
                TaskTypes.INTERNAL,
                "workflow",
            )
        )
        self.assertQuerySetEqual(
            workflow.children.values_list(
                "task_type",
                "task_name",
                "task_data",
                "workflow_data_json",
                "status",
                "dependencies__task_type",
                "dependencies__task_name",
            ),
            expected_children,
        )

    def test_populate_signing_keys_already_exist(self) -> None:
        """``populate`` only generates signing keys if they don't exist."""
        workspace = self.scenario.workspace
        archive = self.playground.create_singleton_collection(
            CollectionCategory.ARCHIVE
        )
        archive.data = {"signing_keys": ["ABC123"]}
        archive.save()
        suites = [
            self.playground.create_collection(name, CollectionCategory.SUITE)
            for name in ("one", "two")
        ]
        for suite in suites:
            self.create_binary_package_item(suite)
        workflow = self.playground.create_workflow(
            task_name="update_suites", workspace=workspace, mark_running=True
        )

        self.assertTrue(orchestrate_workflow(workflow))

        self.assertQuerySetEqual(
            workflow.children.values_list(
                "task_type",
                "task_name",
                "task_data",
                "workflow_data_json",
                "status",
                "dependencies__task_type",
                "dependencies__task_name",
            ),
            [
                (
                    TaskTypes.WORKFLOW,
                    "update_suite",
                    {"suite_collection": suite.id},
                    {
                        "display_name": f"Update {suite.name}",
                        "step": f"update-{suite.name}",
                    },
                    WorkRequest.Statuses.RUNNING,
                    None,
                    None,
                )
                for suite in suites
            ],
        )
        for child in workflow.children.all():
            self.assertTrue(child.children.exists())
