# 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 Debsign task."""

import os
import shutil
import subprocess
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, override
from unittest import mock

from django.test import TestCase as DjangoTestCase
from django.test import TransactionTestCase, override_settings
from nacl.public import PrivateKey

import debusine.worker.tags as wtags
from debian import deb822
from debusine.artifacts import (
    LocalArtifact,
    SourcePackage,
    Upload,
    WorkRequestDebugLogs,
)
from debusine.artifacts.models import (
    ArtifactCategory,
    DebianUpload,
    EmptyArtifactData,
    WorkRequestResults,
)
from debusine.assets.models import AssetCategory
from debusine.client.models import (
    ArtifactResponse,
    AssetPermissionCheckResponse,
    RemoteArtifact,
)
from debusine.signing.db.models import Key
from debusine.signing.gnupg import gpg_ephemeral_context
from debusine.signing.models import SigningMode
from debusine.signing.tasks import Debsign
from debusine.signing.tasks.models import DebsignDynamicData
from debusine.signing.tasks.tests.helpers import SigningTaskTestCase
from debusine.tasks import FALLBACK_ARCHITECTURE
from debusine.tasks.models import WorkerType
from debusine.test import TestCase
from debusine.test.test_utils import (
    create_remote_artifact,
    create_work_request_response,
)


class DebsignTests(SigningTaskTestCase[Debsign], TestCase, DjangoTestCase):
    """Unit tests for the :py:class:`Debsign` task."""

    SAMPLE_TASK_DATA = {"unsigned": 2, "key": "ABC123"}

    @override
    def setUp(self) -> None:
        super().setUp()
        self.resolver.set_field_artifact(
            "unsigned",
            id=2,
            category=ArtifactCategory.UPLOAD,
            data=DebianUpload(
                type="dpkg",
                changes_fields={
                    "Architecture": "source",
                    "Files": [{"name": "hello_1.0.dsc"}],
                },
            ),
        )

    def test_get_provided_worker_tags(self) -> None:
        self.mock_is_command_available({"debsign": True})
        self.assertCountEqual(
            self.task.get_provided_worker_tags(),
            [
                wtags.TASK_PREFIX + "signing:debsign:version:1",
                wtags.CAP_PREFIX + "debsign",
            ],
        )

    def test_get_provided_worker_tags_debsign_not_available(self) -> None:
        self.mock_is_command_available({"debsign": False})
        self.assertCountEqual(
            self.task.get_provided_worker_tags(),
            [wtags.TASK_PREFIX + "signing:debsign:version:1"],
        )

    def test_analyze_worker(self) -> None:
        """Test the analyze_worker() method."""
        self.mock_is_command_available({"debsign": True})
        metadata = self.task.analyze_worker()
        self.assertEqual(metadata["signing:debsign:available"], True)

    def test_analyze_worker_debsign_not_available(self) -> None:
        """analyze_worker() handles debsign not being available."""
        self.mock_is_command_available({"debsign": False})
        metadata = self.task.analyze_worker()
        self.assertEqual(metadata["signing:debsign:available"], False)

    def test_can_run_on(self) -> None:
        """can_run_on returns True if debsign is available."""
        self.assertTrue(
            self.task.can_run_on(
                {
                    "system:worker_type": WorkerType.SIGNING,
                    "signing:debsign:available": True,
                    "signing:debsign:version": self.task.TASK_VERSION,
                    "system:architectures": [FALLBACK_ARCHITECTURE],
                }
            )
        )

    def test_can_run_on_mismatched_task_version(self) -> None:
        """can_run_on returns False for mismatched task versions."""
        self.assertFalse(
            self.task.can_run_on(
                {
                    "system:worker_type": WorkerType.SIGNING,
                    "signing:debsign:available": True,
                    "signing:debsign:version": self.task.TASK_VERSION + 1,
                }
            )
        )

    def test_can_run_on_missing_tool(self) -> None:
        """can_run_on returns False if debsign is not available."""
        self.assertFalse(
            self.task.can_run_on(
                {
                    "system:worker_type": WorkerType.SIGNING,
                    "signing:debsign:available": False,
                    "signing:debsign:version": self.task.TASK_VERSION,
                }
            )
        )

    def test_can_run_on_not_available(self) -> None:
        """can_run_on returns False if debsign is not available."""
        self.assertFalse(
            self.task.can_run_on(
                {
                    # "signing:debsign:available" is not set
                    "system:worker_type": WorkerType.SIGNING,
                    "signing:debsign:version": self.task.TASK_VERSION,
                    "system:architectures": [FALLBACK_ARCHITECTURE],
                }
            )
        )

    def test_compute_scheduler_tags(self) -> None:
        self.assertComputesSchedulerTags(
            require_system={wtags.CAP_PREFIX + "debsign"}
        )

    def test_compute_dynamic_data(self) -> None:
        """Dynamic data receives relevant artifact IDs."""
        self.assertEqual(
            self.compute_dynamic_data(self.task),
            DebsignDynamicData(unsigned_id=2),
        )

    def test_get_subject(self) -> None:
        self.assertIsNone(self.get_subject())

    def test_get_configuration_context(self) -> None:
        self.assertIsNone(self.get_configuration_context())

    def test_get_input_artifacts_ids(self) -> None:
        """Test get_input_artifacts_ids."""
        self.assertEqual(self.task.get_input_artifacts_ids(), [])

        self.task.dynamic_data = DebsignDynamicData(unsigned_id=1)
        self.assertEqual(self.task.get_input_artifacts_ids(), [1])

    def test_fetch_input_wrong_category(self) -> None:
        """fetch_input checks the category of the unsigned artifact."""
        temp_path = self.create_temporary_directory()
        dsc_path = temp_path / "foo.dsc"
        self.write_dsc_file(dsc_path, [])
        source_package = SourcePackage.create(
            name="foo", version="1.0", files=[dsc_path]
        )
        source_package_id = 1
        source_package_response = self.make_artifact_response(
            source_package, source_package_id
        )

        task = Debsign(
            task_data={"unsigned": source_package_id, "key": "ABC123"},
            dynamic_task_data={"unsigned_id": source_package_id},
        )
        self.task_set_current(task)

        debusine_mock = self.mock_debusine(task)
        debusine_mock.download_artifact.return_value = source_package_response
        destination = self.create_temporary_directory()

        self.assertFalse(task.fetch_input(destination))

        self.assertEqual(
            (task.log_directory / "fetch_input.log").read_text(),
            "Expected unsigned artifact of category debian:upload; got "
            "debian:source-package\n",
        )
        debusine_mock.download_artifact.assert_called_once_with(
            source_package_id,
            destination,
            tarball=mock.ANY,
            workspace=task.workspace_name,
        )

    def test_fetch_input_missing_key(self) -> None:
        """fetch_input requires the signing key to exist in the database."""
        fingerprint = "0" * 64
        directory = self.create_temporary_directory()
        changes_file = directory / "foo.changes"
        self.write_changes_file(changes_file, [])
        upload = Upload.create(changes_file=changes_file)
        upload_id = 2
        upload_response = self.make_artifact_response(upload, upload_id)

        task = Debsign(
            task_data={"unsigned": upload_id, "key": fingerprint},
            dynamic_task_data={"unsigned_id": upload_id},
        )
        self.task_set_current(task)

        debusine_mock = self.mock_debusine(task)
        debusine_mock.download_artifact.return_value = upload_response
        destination = self.create_temporary_directory()

        self.assertFalse(task.fetch_input(destination))

        self.assertEqual(
            (task.log_directory / "fetch_input.log").read_text(),
            f"Signing key openpgp:{fingerprint} does not exist\n",
        )
        debusine_mock.download_artifact.assert_called_once_with(
            upload_id,
            destination,
            tarball=mock.ANY,
            workspace=task.workspace_name,
        )

    def test_fetch_input_permission_denied(self) -> None:
        key = Key.objects.create(
            purpose=Key.Purpose.OPENPGP_UPLOAD,
            fingerprint="0" * 64,
            private_key={},
            public_key=b"",
        )
        directory = self.create_temporary_directory()
        changes_file = directory / "foo.changes"
        self.write_changes_file(changes_file, [])
        upload = Upload.create(changes_file=changes_file)
        upload_id = 2
        upload_response = self.make_artifact_response(upload, upload_id)

        task = Debsign(
            task_data={"unsigned": upload_id, "key": key.fingerprint},
            dynamic_task_data={"unsigned_id": upload_id},
        )
        self.task_set_current(task)

        debusine_mock = self.mock_debusine(task)
        debusine_mock.download_artifact.return_value = upload_response
        debusine_mock.asset_permission_check.return_value = (
            AssetPermissionCheckResponse(
                has_permission=False,
                username="testuser",
                user_id=123,
                resource={},
            )
        )
        destination = self.create_temporary_directory()

        self.assertFalse(task.fetch_input(destination))

        self.assertEqual(
            (task.log_directory / "fetch_input.log").read_text(),
            f"User testuser does not have permission to sign "
            f"{upload_id} with openpgp:{key.fingerprint}.\n",
        )
        debusine_mock.download_artifact.assert_called_once_with(
            upload_id,
            destination,
            tarball=mock.ANY,
            workspace=task.workspace_name,
        )

    def test_update_changes_file(self) -> None:
        directory = self.create_temporary_directory()
        changes_file = directory / "foo.changes"
        self.write_changes_file(changes_file, [])
        self.task._files = self.make_files_response(
            {"foo.changes": changes_file}
        )

        debusine_mock = self.mock_debusine(self.task)
        debusine_mock.base_api_url = "https://example.com/"
        debusine_mock.work_request_get.return_value = (
            create_work_request_response(id=100, workflow_root=99)
        )

        self.task.update_changes_file(directory)
        updated_changes = deb822.Changes(changes_file.read_bytes())

        self.assertIn("Debusine-User", updated_changes)
        self.assertEqual(
            updated_changes["Debusine-User"], "https://example.com/-/users/123/"
        )
        self.assertIn("Debusine-Workflow", updated_changes)
        self.assertEqual(
            updated_changes["Debusine-Workflow"],
            "https://example.com/debusine/Testing/work-request/99/",
        )
        self.assertIn("Debusine-Work-Request", updated_changes)
        self.assertEqual(
            updated_changes["Debusine-Work-Request"],
            "https://example.com/debusine/Testing/work-request/100/",
        )

    def test_update_changes_file_without_workflow(self) -> None:
        directory = self.create_temporary_directory()
        changes_file = directory / "foo.changes"
        self.write_changes_file(changes_file, [])
        with changes_file.open("a") as f:
            f.write("\nDebusine-Workflow: foo\n")
        self.task._files = self.make_files_response(
            {"foo.changes": changes_file}
        )

        debusine_mock = self.mock_debusine(self.task)
        debusine_mock.base_api_url = "https://example.com/"
        debusine_mock.work_request_get.return_value = (
            create_work_request_response(id=100, workflow_root=None)
        )

        self.task.update_changes_file(directory)

        updated_changes = deb822.Changes(changes_file.read_bytes())

        self.assertIn("Debusine-User", updated_changes)
        self.assertEqual(
            updated_changes["Debusine-User"], "https://example.com/-/users/123/"
        )
        self.assertNotIn("Debusine-Workflow", updated_changes)
        self.assertIn("Debusine-Work-Request", updated_changes)
        self.assertEqual(
            updated_changes["Debusine-Work-Request"],
            "https://example.com/debusine/Testing/work-request/100/",
        )

    def test_run_error(self) -> None:
        """run() raises an exception if signing fails."""
        key = Key.objects.create(
            purpose=Key.Purpose.OPENPGP_UPLOAD,
            fingerprint="0" * 64,
            private_key={},
            public_key=b"",
        )
        download_directory = self.create_temporary_directory()
        execute_directory = self.create_temporary_directory()
        (changes_file := download_directory / "foo.changes").touch()
        task = Debsign(task_data={"unsigned": 2, "key": key.fingerprint})
        self.task_set_current(task)
        task._files = self.make_files_response({"foo.changes": changes_file})
        task._key = key
        task._permission_check = AssetPermissionCheckResponse(
            has_permission=True, username="testuser", user_id=123, resource={}
        )
        debusine_mock = self.mock_debusine(task)
        debusine_mock.base_api_url = "https://example.com/"
        debusine_mock.work_request_get.return_value = (
            create_work_request_response()
        )
        task.prepare_to_run(download_directory, execute_directory)

        with (
            mock.patch(
                "debusine.signing.tasks.debsign.sign",
                side_effect=ValueError("Boom"),
            ),
            self.assertRaisesRegex(ValueError, "Boom"),
        ):
            task.run(execute_directory)

        self.assertIsNone(task._signed_changes_path)

    def test_run_success(self) -> None:
        """run() records a successful result if signing succeeds."""
        key = Key.objects.create(
            purpose=Key.Purpose.OPENPGP_UPLOAD,
            fingerprint="0" * 64,
            private_key={},
            public_key=b"",
        )
        download_directory = self.create_temporary_directory()
        execute_directory = self.create_temporary_directory()
        (changes_file := download_directory / "foo.changes").touch()
        task = Debsign(task_data={"unsigned": 2, "key": key.fingerprint})
        self.task_set_current(task)
        task._files = self.make_files_response({"foo.changes": changes_file})
        task._key = key
        task._permission_check = AssetPermissionCheckResponse(
            has_permission=True,
            username="testuser",
            user_id=123,
            resource={"package": "foo"},
        )
        debusine_mock = self.mock_debusine(task)
        debusine_mock.base_api_url = "https://example.com/"
        debusine_mock.work_request_get.return_value = (
            create_work_request_response()
        )
        task.prepare_to_run(download_directory, execute_directory)

        with mock.patch("debusine.signing.tasks.debsign.sign") as mock_sign:
            self.assertEqual(
                task.run(execute_directory), WorkRequestResults.SUCCESS
            )

        self.assertEqual(
            task._signed_changes_path,
            execute_directory / "output" / "foo.changes",
        )
        mock_sign.assert_called_once_with(
            [key],
            execute_directory / "input" / "foo.changes",
            execute_directory / "output" / "foo.changes",
            SigningMode.DEBSIGN,
            task.work_request_id,
            username="testuser",
            user_id=123,
            resource={"package": "foo"},
            log_file=mock.ANY,
        )


class DebsignIntegrationTests(
    SigningTaskTestCase[Debsign], TestCase, TransactionTestCase
):
    """Integration tests for the :py:class:`Debsign` task."""

    def generate_signing_key(self, service_private_key: PrivateKey) -> Key:
        """Generate a signing key."""
        with (
            override_settings(
                DEBUSINE_SIGNING_PRIVATE_KEYS=[service_private_key]
            ),
            open(os.devnull, "wb") as log_file,
        ):
            key = Key.objects.generate(
                Key.Purpose.OPENPGP_UPLOAD, "An OpenPGP key", 1, log_file
            )
        return key

    def make_upload(self) -> tuple[Path, Path, Upload]:
        """Prepare a minimal upload; return (dsc, changes, artifact)."""
        temp_path = self.create_temporary_directory()
        dsc_path = temp_path / "foo.dsc"
        self.write_dsc_file(dsc_path, [])
        changes_path = temp_path / "foo.changes"
        self.write_changes_file(changes_path, [dsc_path])
        upload = Upload.create(changes_file=changes_path)
        return dsc_path, changes_path, upload

    def test_execute_success(self) -> None:
        """Integration test: sign using a real OpenPGP key."""
        service_private_key = PrivateKey.generate()
        key = self.generate_signing_key(service_private_key)
        dsc_path, changes_path, upload = self.make_upload()
        upload_id = 2
        upload_response = self.make_artifact_response(upload, upload_id)
        uploaded_paths: list[Path] = []
        uploaded_upload: Upload | None = None
        output_path = self.create_temporary_directory()
        uploaded_artifact_ids = {
            ArtifactCategory.UPLOAD: 3,
            ArtifactCategory.WORK_REQUEST_DEBUG_LOGS: 4,
        }

        def download_artifact(
            artifact_id: int, destination: Path, **kwargs: Any
        ) -> ArtifactResponse:
            assert artifact_id == upload_id
            shutil.copy(dsc_path, destination)
            shutil.copy(changes_path, destination)
            return upload_response

        def upload_artifact(
            local_artifact: LocalArtifact[Any], **kwargs: Any
        ) -> RemoteArtifact:
            nonlocal uploaded_upload
            for path in local_artifact.files.values():
                uploaded_paths.append(path)
                shutil.copy(path, output_path)
            if local_artifact.category == ArtifactCategory.UPLOAD:
                [changes_path] = [
                    path
                    for path in local_artifact.files.values()
                    if path.name.endswith(".changes")
                ]
                uploaded_upload = Upload.create(changes_file=changes_path)
            return create_remote_artifact(
                id=uploaded_artifact_ids[local_artifact.category],
                workspace="testing",
            )

        task = Debsign(
            task_data={"unsigned": upload_id, "key": key.fingerprint},
            dynamic_task_data={
                "unsigned_id": upload_id,
            },
        )
        self.task_set_current(task)

        debusine_mock = self.mock_debusine(task)
        debusine_mock.download_artifact.side_effect = download_artifact
        debusine_mock.upload_artifact.side_effect = upload_artifact
        debusine_mock.asset_permission_check.return_value = (
            AssetPermissionCheckResponse(
                has_permission=True,
                username="testuser",
                user_id=123,
                resource={},
            )
        )
        debusine_mock.base_api_url = "https://example.com/"
        debusine_mock.work_request_get.return_value = (
            create_work_request_response()
        )

        with override_settings(
            DEBUSINE_SIGNING_PRIVATE_KEYS=[service_private_key]
        ):
            self.assertEqual(task.execute(), WorkRequestResults.SUCCESS)

        debusine_mock.asset_permission_check.assert_called_once_with(
            asset_category=AssetCategory.SIGNING_KEY,
            asset_slug=f"openpgp:{key.fingerprint}",
            permission_name="sign_with",
            artifact_id=upload_id,
            work_request_id=task.work_request_id,
            workspace=task.workspace_name,
        )

        # The task uploaded an artifact with the expected properties.
        self.assertEqual(len(uploaded_paths), 3)
        debusine_mock.upload_artifact.assert_has_calls(
            [
                mock.call(
                    uploaded_upload,
                    workspace=task.workspace_name,
                    work_request=task.work_request_id,
                ),
                mock.call(
                    WorkRequestDebugLogs(
                        category=WorkRequestDebugLogs._category,
                        data=EmptyArtifactData(),
                        files={
                            "cmd-output.log": (
                                task.log_directory / "cmd-output.log"
                            )
                        },
                        content_types={
                            "cmd-output.log": "text/plain; charset=utf-8"
                        },
                    ),
                    workspace=task.workspace_name,
                    work_request=task.work_request_id,
                ),
            ]
        )
        debusine_mock.relation_create.assert_any_call(
            uploaded_artifact_ids[ArtifactCategory.UPLOAD],
            upload_id,
            "relates-to",
        )

        # The file was properly signed.
        with (
            TemporaryDirectory(prefix="debusine-tests-") as tmp,
            gpg_ephemeral_context(Path(tmp)) as ctx,
        ):
            import_result = ctx.key_import(bytes(key.public_key))
            assert getattr(import_result, "imported", 0) == 1
            subprocess.run(
                [
                    "dscverify",
                    "--keyring",
                    Path(ctx.home_dir, "pubring.kbx"),
                    output_path / "foo.changes",
                ],
                env={**os.environ, "GNUPGHOME": ctx.home_dir},
                check=True,
                stdout=subprocess.DEVNULL,
            )

        changes_contents = (output_path / "foo.changes").read_text()
        self.assertIn("Debusine-Workflow", changes_contents)

    def test_execute_failure(self) -> None:
        """If execution fails, the task does not upload an output artifact."""
        service_private_key = PrivateKey.generate()
        key = self.generate_signing_key(service_private_key)
        dsc_path, changes_path, upload = self.make_upload()
        upload_id = 2
        upload_response = self.make_artifact_response(upload, upload_id)

        def download_artifact(
            artifact_id: int, destination: Path, **kwargs: Any
        ) -> ArtifactResponse:
            assert artifact_id == upload_id
            shutil.copy(dsc_path, destination)
            shutil.copy(changes_path, destination)
            return upload_response

        task = Debsign(
            task_data={
                "unsigned": upload_id,
                "key": key.fingerprint,
            },
            dynamic_task_data={
                "unsigned_id": upload_id,
            },
        )
        self.task_set_current(task)

        debusine_mock = self.mock_debusine(task)
        debusine_mock.download_artifact.side_effect = download_artifact
        debusine_mock.base_api_url = "https://example.com/"
        debusine_mock.work_request_get.return_value = (
            create_work_request_response()
        )

        with mock.patch(
            "debusine.signing.tasks.debsign.sign",
            side_effect=Exception("Signing failed"),
        ):
            self.assertEqual(task.execute(), WorkRequestResults.FAILURE)

        # Only a debug logs artifact was uploaded, not any signing output
        debusine_mock.upload_artifact.assert_called_once_with(
            WorkRequestDebugLogs(
                category=WorkRequestDebugLogs._category,
                data=EmptyArtifactData(),
                files={
                    "stages.log": (task.log_directory / "stages.log"),
                    "cmd-output.log": (task.log_directory / "cmd-output.log"),
                    "execution.log": (task.log_directory / "execution.log"),
                },
                content_types={
                    "stages.log": "text/plain; charset=utf-8",
                    "cmd-output.log": "text/plain; charset=utf-8",
                    "execution.log": "text/plain; charset=utf-8",
                },
            ),
            workspace=task.workspace_name,
            work_request=task.work_request_id,
        )
