# 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 uploading Debian packages to an upload queue."""

import logging
import os
from pathlib import Path
from typing import ClassVar, override
from unittest import mock

from debusine.artifacts.models import ArtifactCategory, WorkRequestResults
from debusine.db.models import Artifact, File, Workspace
from debusine.server.tasks import PackageUpload
from debusine.server.tasks.models import PackageUploadDynamicData
from debusine.server.tasks.tests.ftp_server import FakeFTPServerThread
from debusine.server.tasks.tests.helpers import ServerTaskTestCase
from debusine.server.tasks.tests.sftp_server import FakeSFTPServerThread


class PackageUploadTests(ServerTaskTestCase[PackageUpload]):
    """Tests for :py:class:`PackageUpload`."""

    task: PackageUpload
    upload: ClassVar[Artifact]

    SAMPLE_TASK_DATA = {
        "target": "ftp://upload.example.org/queue/",
    }

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        filenames = [
            "foo_1.0.orig.tar.xz",
            "foo_1.0-1.debian.tar.xz",
            "foo_1.0-1.dsc",
        ]
        [cls.upload, _] = cls.playground.create_artifact(
            paths=["foo_1.0-1_source.changes", *filenames],
            category=ArtifactCategory.UPLOAD,
            data={
                "type": "dpkg",
                "changes_fields": {
                    "Architecture": "source",
                    "Files": [{"name": filename} for filename in filenames],
                },
            },
            create_files=True,
        )

    @override
    def setUp(self) -> None:
        super().setUp()
        self.task_data["input"] = {"upload": self.upload.id}

        # Silence logging from pyftpdlib.
        pyftpdlib_logger = logging.getLogger("pyftpdlib")
        self.addCleanup(setattr, pyftpdlib_logger, "handlers", [])
        pyftpdlib_logger.addHandler(logging.NullHandler())

    def test_compute_scheduler_tags(self) -> None:
        self.assertComputesSchedulerTags()

    def test_compute_dynamic_data(self) -> None:
        """Dynamic data receives relevant artifact IDs."""
        self.assertEqual(
            self.compute_dynamic_data(self.task, strip_task_inputs=True),
            PackageUploadDynamicData(input_upload_id=self.upload.id),
        )

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

        self.task.dynamic_data = PackageUploadDynamicData(input_upload_id=1)
        self.assertEqual(self.task.get_input_artifacts_ids(), [1])

    def test_fetch_upload_wrong_category(self) -> None:
        """fetch_upload checks the category of the input.upload artifact."""
        artifact, _ = self.playground.create_artifact(
            category=ArtifactCategory.TEST
        )
        self.task_data["input"] = {"upload": artifact.id}

        self.assertIsNone(
            self.task.fetch_upload(self.create_temporary_directory())
        )

        assert self.task._debug_log_files_directory is not None
        self.assertEqual(
            Path(
                self.task._debug_log_files_directory.name, "fetch_upload.log"
            ).read_text(),
            "Expected input.upload of category debian:upload; got "
            "debusine:test\n",
        )

    def test_fetch_upload_gathers_upload_paths(self) -> None:
        """fetch_upload gathers information from the input.upload artifact."""
        destination = self.create_temporary_directory()
        expected_upload_hashes = {
            destination / fia.path: fia.file.hash_digest
            for fia in self.upload.fileinartifact_set.select_related("file")
        }

        upload_paths = self.task.fetch_upload(destination)

        assert upload_paths is not None
        # The files are downloaded with the correct contents.
        self.assertEqual(
            {path: File.calculate_hash(path) for path in upload_paths},
            expected_upload_hashes,
        )
        # The .changes file is sorted to the end of upload_paths.
        self.assertTrue(upload_paths[-1].name.endswith(".changes"))

    def test_execute_no_upload_paths(self) -> None:
        """execute() returns FAILURE if fetch_upload returned no paths."""
        artifact, _ = self.playground.create_artifact(
            category=ArtifactCategory.TEST
        )
        self.task_data["input"] = {"upload": artifact.id}
        self.task.dynamic_data = PackageUploadDynamicData(
            input_upload_id=artifact.id
        )
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )

        self.assertEqual(self.task.execute(), WorkRequestResults.FAILURE)

    def test_execute_ftp(self) -> None:
        """execute() can make an upload using FTP."""
        username = "user"
        remote_home_directory = self.create_temporary_directory()
        (remote_home_directory / "queue").mkdir()
        server = FakeFTPServerThread(username, remote_home_directory)
        server.start()
        self.addCleanup(server.stop)
        host, port = server.address
        self.task_data["target"] = f"ftp://{username}@{host}:{port}/queue/"
        directory = self.create_temporary_directory()
        deb_path = directory / "foo_1.0-1_amd64.deb"
        changes_path = directory / "foo_1.0-1_amd64.changes"
        self.write_deb_file(deb_path)
        self.write_changes_file(changes_path, [deb_path])

        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )

        with mock.patch.object(
            self.task, "fetch_upload", return_value=[deb_path, changes_path]
        ):
            self.assertEqual(self.task.execute(), WorkRequestResults.SUCCESS)

        for path in (deb_path, changes_path):
            self.assertEqual(
                (remote_home_directory / "queue" / path.name).read_bytes(),
                path.read_bytes(),
            )

    def test_execute_ftp_no_path(self) -> None:
        """execute() handles FTP URLs with no path."""
        username = "user"
        remote_home_directory = self.create_temporary_directory()
        server = FakeFTPServerThread(username, remote_home_directory)
        server.start()
        self.addCleanup(server.stop)
        host, port = server.address
        self.task_data["target"] = f"ftp://{username}@{host}:{port}"
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )
        directory = self.create_temporary_directory()
        deb_path = directory / "foo_1.0-1_amd64.deb"
        changes_path = directory / "foo_1.0-1_amd64.changes"
        self.write_deb_file(deb_path)
        self.write_changes_file(changes_path, [deb_path])

        with mock.patch.object(
            self.task, "fetch_upload", return_value=[deb_path, changes_path]
        ):
            self.assertEqual(self.task.execute(), WorkRequestResults.SUCCESS)

        for path in (deb_path, changes_path):
            self.assertEqual(
                (remote_home_directory / path.name).read_bytes(),
                path.read_bytes(),
            )

    def test_execute_ftp_delayed(self) -> None:
        """execute() can make a delayed upload using FTP."""
        username = "user"
        remote_home_directory = self.create_temporary_directory()
        (remote_home_directory / "queue" / "DELAYED" / "3-day").mkdir(
            parents=True
        )
        server = FakeFTPServerThread(username, remote_home_directory)
        server.start()
        self.addCleanup(server.stop)
        host, port = server.address
        self.task_data["target"] = f"ftp://{username}@{host}:{port}/queue/"
        self.task_data["delayed_days"] = 3
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )
        directory = self.create_temporary_directory()
        deb_path = directory / "foo_1.0-1_amd64.deb"
        changes_path = directory / "foo_1.0-1_amd64.changes"
        self.write_deb_file(deb_path)
        self.write_changes_file(changes_path, [deb_path])

        with mock.patch.object(
            self.task, "fetch_upload", return_value=[deb_path, changes_path]
        ):
            self.assertEqual(self.task.execute(), WorkRequestResults.SUCCESS)

        for path in (deb_path, changes_path):
            self.assertEqual(
                (
                    remote_home_directory
                    / "queue"
                    / "DELAYED"
                    / "3-day"
                    / path.name
                ).read_bytes(),
                path.read_bytes(),
            )

    def test_execute_ftp_retries_timeouts(self) -> None:
        """execute() retries FTP upload timeouts."""
        directory = self.create_temporary_directory()
        changes_path = directory / "foo_1.0-1_amd64.changes"
        self.write_changes_file(changes_path, [])

        with (
            mock.patch.object(
                self.task, "fetch_upload", return_value=[changes_path]
            ),
            mock.patch(
                "debusine.server.tasks.package_upload.FTP",
                side_effect=[TimeoutError(f"Boom {i}") for i in range(3)],
            ),
            self.assertRaisesRegex(TimeoutError, "Boom 2"),
        ):
            self.task.execute()

        assert self.task._debug_log_files_directory is not None
        self.assertEqual(
            Path(
                self.task._debug_log_files_directory.name, "package-upload.log"
            ).read_text(),
            "".join(
                [
                    f"Uploading to {self.task.data.target}\n",
                    "Upload failed: Boom 0\n",
                    "Upload failed: Boom 1\n",
                    "Upload failed: Boom 2\n",
                ]
            ),
        )

    def test_execute_sftp(self) -> None:
        """execute() can make an upload using SFTP."""
        queue_directory = self.create_temporary_directory()
        server = FakeSFTPServerThread()
        home_directory = self.create_temporary_directory()
        (home_directory / ".ssh").mkdir()
        (id_ed25519 := home_directory / ".ssh" / "id_ed25519").write_text(
            server.user_key.private_openssh
        )
        (home_directory / ".ssh" / "config").write_text(
            f"IdentityFile {id_ed25519}\n"
        )
        server.start()
        self.addCleanup(server.stop)
        host, port = server.address
        self.task_data["target"] = f"sftp://user@{host}:{port}{queue_directory}"
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )
        directory = self.create_temporary_directory()
        deb_path = directory / "foo_1.0-1_amd64.deb"
        changes_path = directory / "foo_1.0-1_amd64.changes"
        self.write_deb_file(deb_path)
        self.write_changes_file(changes_path, [deb_path])

        with (
            mock.patch.object(
                self.task, "fetch_upload", return_value=[deb_path, changes_path]
            ),
            mock.patch.dict(os.environ, {"HOME": str(home_directory)}),
        ):
            self.assertEqual(self.task.execute(), WorkRequestResults.SUCCESS)

        for path in (deb_path, changes_path):
            self.assertEqual(
                (queue_directory / path.name).read_bytes(), path.read_bytes()
            )

    def test_execute_sftp_delayed(self) -> None:
        """execute() can make a delayed upload using SFTP."""
        queue_directory = self.create_temporary_directory()
        (queue_directory / "DELAYED" / "3-day").mkdir(parents=True)
        server = FakeSFTPServerThread()
        home_directory = self.create_temporary_directory()
        (home_directory / ".ssh").mkdir()
        (id_ed25519 := home_directory / ".ssh" / "id_ed25519").write_text(
            server.user_key.private_openssh
        )
        (home_directory / ".ssh" / "config").write_text(
            f"IdentityFile {id_ed25519}\n"
        )
        server.start()
        self.addCleanup(server.stop)
        host, port = server.address
        self.task_data["target"] = f"sftp://user@{host}:{port}{queue_directory}"
        self.task_data["delayed_days"] = 3
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )
        directory = self.create_temporary_directory()
        deb_path = directory / "foo_1.0-1_amd64.deb"
        changes_path = directory / "foo_1.0-1_amd64.changes"
        self.write_deb_file(deb_path)
        self.write_changes_file(changes_path, [deb_path])

        with (
            mock.patch.object(
                self.task, "fetch_upload", return_value=[deb_path, changes_path]
            ),
            mock.patch.dict(os.environ, {"HOME": str(home_directory)}),
        ):
            self.assertEqual(self.task.execute(), WorkRequestResults.SUCCESS)

        for path in (deb_path, changes_path):
            self.assertEqual(
                (
                    queue_directory / "DELAYED" / "3-day" / path.name
                ).read_bytes(),
                path.read_bytes(),
            )

    def test_execute_sftp_retries_timeouts(self) -> None:
        """execute() retries SFTP upload timeouts."""
        self.task_data["target"] = "sftp://upload.example.org/queue/"
        directory = self.create_temporary_directory()
        changes_path = directory / "foo_1.0-1_amd64.changes"
        self.write_changes_file(changes_path, [])

        with (
            mock.patch.object(
                self.task, "fetch_upload", return_value=[changes_path]
            ),
            mock.patch(
                "debusine.server.tasks.package_upload.Connection",
                side_effect=[TimeoutError(f"Boom {i}") for i in range(3)],
            ),
            self.assertRaisesRegex(TimeoutError, "Boom 2"),
        ):
            self.task.execute()

        assert self.task._debug_log_files_directory is not None
        self.assertEqual(
            Path(
                self.task._debug_log_files_directory.name, "package-upload.log"
            ).read_text(),
            "".join(
                [
                    f"Uploading to {self.task.data.target}\n",
                    "Upload failed: Boom 0\n",
                    "Upload failed: Boom 1\n",
                    "Upload failed: Boom 2\n",
                ]
            ),
        )

    def test_execute_end_to_end(self) -> None:
        """End-to-end execution works."""
        username = "user"
        remote_home_directory = self.create_temporary_directory()
        (remote_home_directory / "queue").mkdir()
        server = FakeFTPServerThread(username, remote_home_directory)
        server.start()
        self.addCleanup(server.stop)
        host, port = server.address
        self.task_data["target"] = f"ftp://{username}@{host}:{port}/queue/"
        self.playground.create_group_role(
            self.task.workspace,
            Workspace.Roles.CONTRIBUTOR,
            users=[self.work_request.created_by],
        )
        assert self.task.workspace is not None
        directory = self.create_temporary_directory()
        non_changes_paths = [
            directory / fia.path
            for fia in self.upload.fileinartifact_set.all()
            if not fia.path.endswith(".changes")
        ]
        for path in non_changes_paths:
            path.write_text(path.name)
        [changes_path] = [
            directory / fia.path
            for fia in self.upload.fileinartifact_set.all()
            if fia.path.endswith(".changes")
        ]
        self.write_changes_file(changes_path, non_changes_paths)

        self.assertEqual(self.task.execute(), WorkRequestResults.SUCCESS)

        debug_logs_artifact = Artifact.objects.get(
            category=ArtifactCategory.WORK_REQUEST_DEBUG_LOGS,
            created_by_work_request=self.work_request,
        )
        package_upload_log = debug_logs_artifact.fileinartifact_set.get()
        self.assertEqual(package_upload_log.path, "package-upload.log")
        fileobj = package_upload_log.file
        file_backend = self.task.workspace.scope.download_file_backend(fileobj)
        with file_backend.get_stream(fileobj) as file:
            self.assertEqual(
                file.read().decode(),
                "".join(
                    [f"Uploading to {self.task.data.target}\n"]
                    + [
                        f"Uploading {path.name}\n"
                        for path in [*sorted(non_changes_paths), changes_path]
                    ]
                    + ["Upload succeeded\n"]
                ),
            )

    def test_label(self) -> None:
        """Test get_label."""
        self.assertEqual(
            self.task.get_label(), "upload to ftp://upload.example.org/queue/"
        )

    def test_label_delayed(self) -> None:
        """Test get_label for a delayed upload."""
        self.task_data["delayed_days"] = 3

        self.assertEqual(
            self.task.get_label(),
            "upload to ftp://upload.example.org/queue/DELAYED/3-day",
        )
