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

"""Signing task to sign the contents of an artifact."""

import shutil
from pathlib import Path
from typing import Any, assert_never

from django.conf import settings

from debusine import utils
from debusine.artifacts import SigningOutputArtifact
from debusine.artifacts.models import KeyPurpose, SigningResult
from debusine.client.models import FilesResponseType
from debusine.signing.db.models import Key
from debusine.signing.models import SigningMode
from debusine.signing.tasks import BaseSigningTask
from debusine.signing.tasks.models import SignData, SignDynamicData
from debusine.tasks.server import TaskDatabaseInterface


class Sign(BaseSigningTask[SignData, SignDynamicData]):
    """Task that signs data using a key."""

    TASK_VERSION = 1

    def __init__(
        self,
        task_data: dict[str, Any],
        dynamic_task_data: dict[str, Any] | None = None,
    ) -> None:
        """Initialize object."""
        super().__init__(task_data, dynamic_task_data)

        self._files: FilesResponseType | None = None
        self._key: Key | None = None
        self._results: list[SigningResult] = []

    @classmethod
    def analyze_worker(cls):
        """Report metadata for this task on this worker."""
        metadata = super().analyze_worker()

        metadata[cls.prefix_with_task_name("available:uefi")] = (
            utils.is_command_available("sbsign")
        )

        return metadata

    def can_run_on(self, worker_metadata: dict[str, Any]) -> bool:
        """Check if the specified worker can run the task."""
        if not super().can_run_on(worker_metadata):
            return False

        # OpenPGP support is implemented in Python and is always available.
        # Other key purposes require external tools.
        if self.data.purpose != KeyPurpose.OPENPGP:
            available_key = self.prefix_with_task_name(
                f"available:{self.data.purpose}"
            )
            if not worker_metadata.get(available_key, False):
                return False

        return True

    def compute_dynamic_data(
        self, task_database: TaskDatabaseInterface
    ) -> SignDynamicData:
        """Resolve artifact lookups for this task."""
        return SignDynamicData(
            unsigned_id=task_database.lookup_single_artifact(
                self.data.unsigned
            ),
            key_id=task_database.lookup_single_artifact(self.data.key),
        )

    def fetch_input(self, destination: Path) -> bool:
        """Fetch artifacts and database objects needed by the task."""
        assert self.dynamic_data is not None

        unsigned = self.fetch_artifact(
            self.dynamic_data.unsigned_id, destination
        )
        for trusted_cert in unsigned.data.get("trusted_certs") or []:
            if trusted_cert not in settings.DEBUSINE_SIGNING_TRUSTED_CERTS:
                self.append_to_log_file(
                    "fetch_input.log",
                    [
                        f"This installation cannot sign objects that trust "
                        f"certificate '{trusted_cert}'"
                    ],
                )
                return False
        self._files = unsigned.files

        key_artifact = self.fetch_artifact(
            self.dynamic_data.key_id, destination
        )
        purpose = key_artifact.data["purpose"]
        if purpose != self.data.purpose:
            self.append_to_log_file(
                "fetch_input.log",
                [
                    f"Signing key has purpose {purpose}; expected "
                    f"{self.data.purpose}"
                ],
            )
            return False
        fingerprint = key_artifact.data["fingerprint"]
        try:
            self._key = Key.objects.get(
                purpose=purpose, fingerprint=fingerprint
            )
        except Key.DoesNotExist:
            self.append_to_log_file(
                "fetch_input.log",
                [f"Signing key {purpose}:{fingerprint} does not exist"],
            )
            return False

        return True

    def configure_for_execution(
        self, download_directory: Path  # noqa: U100
    ) -> bool:
        """Configure task variables."""
        # Nothing to do here; fetch_input does it all.
        return True

    def prepare_to_run(
        self, download_directory: Path, execute_directory: Path
    ) -> None:
        """Copy downloaded files into the execution directory."""
        shutil.copytree(download_directory, execute_directory / "input")

    def run(self, execute_directory: Path) -> bool:
        """Sign all the requested files."""
        assert self.work_request_id is not None
        assert self._files is not None
        assert self._key is not None

        with self.open_debug_log_file("cmd-output.log", mode="wb") as log_file:
            for file_path, file_response in self._files.items():
                data_path = execute_directory / "input" / file_path
                signature_path = (
                    execute_directory / "output" / f"{file_path}.sig"
                )
                signature_path.parent.mkdir(parents=True, exist_ok=True)
                # TODO: The signing mode could be specified in the task
                # data.  For now we hardcode ones that make sense.
                match self.data.purpose:
                    case KeyPurpose.UEFI:
                        mode = SigningMode.ATTACHED
                    case KeyPurpose.OPENPGP:
                        mode = SigningMode.CLEAR
                    case _ as unreachable:
                        assert_never(unreachable)
                try:
                    self._key.sign(
                        data_path,
                        signature_path,
                        mode,
                        self.work_request_id,
                        log_file=log_file,
                    )
                except Exception as e:
                    self._results.append(
                        SigningResult(file=file_path, error_message=str(e))
                    )
                else:
                    self._results.append(
                        SigningResult(
                            file=file_path, output_file=f"{file_path}.sig"
                        )
                    )

        return True

    def upload_artifacts(
        self, execute_directory: Path, *, execution_success: bool  # noqa: U100
    ) -> None:
        """Upload artifacts for the task."""
        assert self.dynamic_data is not None
        assert self.work_request_id is not None
        assert self.debusine is not None
        assert self._key is not None

        signing_output_artifact = SigningOutputArtifact.create(
            KeyPurpose(self._key.purpose),
            self._key.fingerprint,
            self._results,
            [
                execute_directory / "output" / result.output_file
                for result in self._results
                if result.output_file is not None
            ],
            execute_directory / "output",
        )
        uploaded_signing_output_artifact = self.debusine.upload_artifact(
            signing_output_artifact,
            workspace=self.workspace_name,
            work_request=self.work_request_id,
        )
        self.debusine.relation_create(
            uploaded_signing_output_artifact.id,
            self.dynamic_data.unsigned_id,
            "relates-to",
        )
        self.debusine.relation_create(
            uploaded_signing_output_artifact.id,
            self.dynamic_data.key_id,
            "relates-to",
        )

    def get_label(self) -> str:
        """Return the task label."""
        return f"sign data for {self.data.purpose}"
