# 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.

"""Task to upload Debian packages to an upload queue."""

import shutil
import tempfile
from datetime import timedelta
from ftplib import FTP
from pathlib import Path

import tenacity
from fabric import Connection

from debusine.artifacts.models import ArtifactCategory
from debusine.db.models import Artifact
from debusine.server.tasks import BaseServerTask
from debusine.server.tasks.models import (
    PackageUploadData,
    PackageUploadDynamicData,
)
from debusine.tasks.server import TaskDatabaseInterface

# At some point it might be useful to make this configurable, but hardcoding
# it will do for now.
_TIMEOUT = timedelta(seconds=15)


class PackageUpload(
    BaseServerTask[PackageUploadData, PackageUploadDynamicData]
):
    """Task that uploads Debian packages to an upload queue."""

    TASK_VERSION = 1

    def compute_dynamic_data(
        self, task_database: TaskDatabaseInterface
    ) -> PackageUploadDynamicData:
        """Resolve artifact lookups for this task."""
        return PackageUploadDynamicData(
            input_upload_id=task_database.lookup_single_artifact(
                self.data.input.upload
            )
        )

    def fetch_upload(self, destination: Path) -> list[Path] | None:
        """Download the required artifacts."""
        assert self.dynamic_data

        upload = Artifact.objects.get(id=self.dynamic_data.input_upload_id)
        if upload.category != ArtifactCategory.UPLOAD:
            self.append_to_log_file(
                "fetch_upload.log",
                [
                    f"Expected input.upload of category "
                    f"{ArtifactCategory.UPLOAD}; got {upload.category}"
                ],
            )
            return None

        file_backend = upload.workspace.default_file_store.get_backend_object()
        upload_paths: list[Path] = []
        for file_in_artifact in upload.fileinartifact_set.select_related(
            "file"
        ):
            file_path = destination / file_in_artifact.path
            # Should be checked by LocalArtifact, but let's make sure.
            if not file_path.resolve().is_relative_to(destination):
                raise AssertionError(
                    f"{file_in_artifact.path} escapes directory"
                )
            with (
                file_backend.get_stream(file_in_artifact.file) as infile,
                file_path.open(mode="wb") as outfile,
            ):
                shutil.copyfileobj(infile, outfile)
            upload_paths.append(file_path)

        return sorted(
            upload_paths,
            # Some upload queues use the appearance of the .changes file as
            # an indication that the upload is complete, so sort it to the
            # end.
            key=lambda path: path.name.endswith(".changes"),
        )

    @tenacity.retry(
        stop=tenacity.stop_after_attempt(3),
        retry=(
            tenacity.retry_if_exception_type(OSError)
            | tenacity.retry_if_exception_type(TimeoutError)
        ),
        reraise=True,
    )
    def _upload_ftp(self, upload_paths: list[Path]) -> None:
        """Make an upload using FTP."""
        target = self.data.target
        assert target.host is not None

        try:
            with FTP(timeout=_TIMEOUT.total_seconds()) as ftp:
                ftp.connect(host=target.host, port=int(target.port or 0))
                ftp.login(user=target.user or "", passwd=target.password or "")
                if target.path:
                    ftp.cwd(target.path)
                for path in upload_paths:
                    self.append_to_log_file(
                        "package-upload.log", [f"Uploading {path.name}"]
                    )
                    with path.open(mode="rb") as f:
                        ftp.storbinary(f"STOR {path.name}", f)
        except Exception as e:
            self.append_to_log_file(
                "package-upload.log", [f"Upload failed: {e}"]
            )
            raise

    @tenacity.retry(
        stop=tenacity.stop_after_attempt(3),
        retry=(
            tenacity.retry_if_exception_type(OSError)
            | tenacity.retry_if_exception_type(TimeoutError)
        ),
        reraise=True,
    )
    def _upload_sftp(self, upload_paths: list[Path]) -> None:
        """Make an upload using SFTP."""
        target = self.data.target
        assert target.host is not None

        try:
            with Connection(
                host=target.host,
                user=target.user,
                port=None if target.port is None else int(target.port),
                connect_timeout=_TIMEOUT.total_seconds(),
            ) as connection:
                for path in upload_paths:
                    self.append_to_log_file(
                        "package-upload.log", [f"Uploading {path.name}"]
                    )
                    connection.put(
                        path, str(Path(target.path or "", path.name))
                    )
        except Exception as e:
            self.append_to_log_file(
                "package-upload.log", [f"Upload failed: {e}"]
            )
            raise

    def _execute(self) -> bool:
        """Execute the task."""
        with tempfile.TemporaryDirectory(
            prefix="debusine-package-upload-"
        ) as temp_dir:
            temp_path = Path(temp_dir)
            upload_paths = self.fetch_upload(temp_path)
            if upload_paths is None:
                return False

            self.append_to_log_file(
                "package-upload.log", [f"Uploading to {self.data.target}"]
            )
            match self.data.target.scheme:
                case "ftp":
                    self._upload_ftp(upload_paths)
                case "sftp":
                    self._upload_sftp(upload_paths)
                case _ as unreachable:
                    raise AssertionError(
                        f"Unexpected URL scheme: {unreachable}"
                    )
            self.append_to_log_file("package-upload.log", ["Upload succeeded"])
            return True

    def get_label(self) -> str:
        """Return the task label."""
        return f"upload to {self.data.target}"
