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

"""Task to extract signing input from other artifacts."""

import json
import re
import shlex
import shutil
import subprocess
import tempfile
from pathlib import Path, PurePath
from typing import Any, override

from debusine.artifacts import SigningInputArtifact
from debusine.artifacts.models import (
    ArtifactCategory,
    DebianBinaryPackage,
    WorkRequestResults,
    get_architectures,
    get_binary_package_name,
    get_binary_package_version,
)
from debusine.client.models import RelationType
from debusine.tasks import BaseTaskWithExecutor, RunCommandTask
from debusine.tasks.inputs import (
    EnvironmentInput,
    SingleInput,
    Stage,
    UploadArtifactsInput,
)
from debusine.tasks.models import (
    ExtractForSigningData,
    ExtractForSigningDynamicData,
)

# https://www.debian.org/doc/debian-policy/ch-controlfields.html#s-f-source
_re_package_name = re.compile(r"^[a-z0-9][a-z0-9+.-]+$")


class ExtractError(Exception):
    """An error occurred while extracting signing input."""


class ExtractForSigning(
    RunCommandTask[ExtractForSigningData, ExtractForSigningDynamicData],
    BaseTaskWithExecutor[ExtractForSigningData, ExtractForSigningDynamicData],
):
    """Task to extract signing input from other artifacts."""

    TASK_VERSION = 1

    template_artifact = SingleInput(
        field="input.template_artifact",
        categories=[ArtifactCategory.BINARY_PACKAGE],
        stage=Stage.PENDING,
    )
    binary_artifacts = UploadArtifactsInput(
        field="input.binary_artifacts",
        categories=[ArtifactCategory.BINARY_PACKAGE],
    )
    environment = EnvironmentInput()

    @override
    def post_init(self) -> None:
        super().post_init()

        # Set by fetch_input.
        self._template_deb_name: str | None = None
        self._template_path: Path | None = None
        self._binary_artifacts: dict[str, int] | None = None
        self._binary_paths: dict[str, Path] | None = None

        # Set by run.
        self._remote_execute_directory: PurePath | None = None
        self._local_execute_directory: Path | None = None
        self._signing_input_artifacts: (
            dict[str, SigningInputArtifact] | None
        ) = None

    @override
    def _cmdline(self) -> list[str]:
        """Unused abstract method from RunCommandTask."""
        raise NotImplementedError()

    @override
    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

        executor_available_key = f"executor:{self.backend}:available"
        return bool(worker_metadata.get(executor_available_key, False))

    @override
    def get_subject(self) -> str | None:
        assert isinstance(self.template_artifact.data, DebianBinaryPackage)
        return get_binary_package_name(self.template_artifact.data)

    @override
    def compute_dynamic_data(self) -> ExtractForSigningDynamicData:
        """
        Resolve artifact lookups for this task.

        :subject: binary package name of ``template_artifact``
        :parameter_summary: same as subject (architecture)
        """
        template_artifact = self.template_artifact
        assert isinstance(template_artifact.data, DebianBinaryPackage)
        binary_package_name = get_binary_package_name(template_artifact.data)
        version = get_binary_package_version(template_artifact.data)

        binary_artifacts = self.binary_artifacts
        individual_binary_artifact_ids = []
        architectures = set()

        for binary_artifact in binary_artifacts.artifacts:
            individual_binary_artifact_ids.append(binary_artifact.artifact_id)
            assert isinstance(binary_artifact.data, DebianBinaryPackage)
            architectures |= get_architectures(binary_artifact.data)

        architectures_str = ", ".join(sorted(architectures))

        return ExtractForSigningDynamicData(
            environment_id=self.environment.artifact_id,
            input_template_artifact_id=template_artifact.artifact_id,
            input_binary_artifacts_ids=individual_binary_artifact_ids,
            subject=self.get_subject(),
            parameter_summary=f"{binary_package_name}_{version} "
            f"({architectures_str})",
        )

    @override
    def fetch_input(self, destination: Path) -> bool:
        """Download the required artifacts."""
        assert self.dynamic_data is not None

        (template_destination := destination / "template").mkdir()
        artifact = self.fetch_artifact(
            self.dynamic_data.input_template_artifact_id, template_destination
        )
        if artifact.category != ArtifactCategory.BINARY_PACKAGE:
            self.append_to_log_file(
                "fetch_input.log",
                [
                    f"Expected template_artifact to be of category "
                    f"{ArtifactCategory.BINARY_PACKAGE}; got "
                    f"{artifact.category}"
                ],
            )
            return False
        self._template_deb_name = artifact.data["deb_fields"]["Package"]
        # Checked by BinaryPackage validator.
        assert len(artifact.files) == 1
        template_file = list(artifact.files)[0]
        if not template_file.endswith(".deb"):
            self.append_to_log_file(
                "fetch_input.log",
                [
                    f"Expected template_artifact file name to match *.deb; "
                    f"got {template_file}"
                ],
            )
            return False
        self._template_path = template_destination / template_file

        (binary_destination := destination / "binary").mkdir()
        self._binary_artifacts = {}
        self._binary_paths = {}
        for artifact_id in self.dynamic_data.input_binary_artifacts_ids:
            artifact = self.fetch_artifact(artifact_id, binary_destination)
            # Checked by build_dynamic_data.
            assert artifact.category == ArtifactCategory.BINARY_PACKAGE
            binary_deb_name = artifact.data["deb_fields"]["Package"]
            self._binary_artifacts[binary_deb_name] = artifact_id
            # Checked by BinaryPackage validator.
            assert len(artifact.files) == 1
            binary_file = list(artifact.files)[0]
            self._binary_paths[binary_deb_name] = (
                binary_destination / binary_file
            )

        return True

    @override
    def configure_for_execution(
        self,
        download_directory: Path,  # noqa: U100
    ) -> bool:
        """Configure task: create and start an executor instance."""
        self._prepare_executor_instance()

        if self.executor_instance is None:
            raise AssertionError("self.executor_instance cannot be None")

        return True

    def _run_cmd_or_raise(
        self, cmd: list[str], execute_directory: PurePath
    ) -> None:
        self.logger.info("Executing: %s", shlex.join(cmd))
        returncode = self.run_cmd(cmd, Path(execute_directory))
        self.logger.info("%s exited with code %s", cmd[0], returncode)
        if returncode != 0:
            raise ExtractError(f"{cmd[0]} exited with code {returncode}")

    def _pull_from_executor(self, source: PurePath, target: Path) -> None:
        """Pull a subdirectory from the executor, preserving timestamps."""
        assert self.executor_instance is not None
        assert self._remote_execute_directory is not None
        assert self._local_execute_directory is not None

        # Guard against accidentally passing
        # {local,remote}_execute_directory directly.
        assert source != self._remote_execute_directory
        assert target != self._local_execute_directory

        source_tar_path = PurePath(f"{source}.tar")
        target_tar_path = Path(f"{target}.tar")
        self._run_cmd_or_raise(
            ["tar", "-cf", str(source_tar_path), source.name], source.parent
        )
        self.executor_instance.file_pull(source_tar_path, target_tar_path)
        subprocess.run(
            ["tar", "--one-top-level", "-xf", target_tar_path],
            cwd=target.parent,
        )

    def _extract_binary(self, deb: PurePath, target: PurePath) -> None:
        """Extract a binary package."""
        self._run_cmd_or_raise(
            ["dpkg-deb", "-x", str(deb), str(target)],
            target.parent,
        )

    def _read_manifest(self) -> dict[str, Any]:
        """Read the files.json manifest from the template artifact."""
        assert self.executor_instance is not None
        assert self._template_deb_name is not None
        assert self._template_path is not None
        assert self._remote_execute_directory is not None
        assert self._local_execute_directory is not None

        remote_template_path = (
            self._remote_execute_directory / self._template_deb_name
        )
        local_files_json_path = self._local_execute_directory / "files.json"

        self._extract_binary(self._template_path, remote_template_path)
        self.executor_instance.file_pull(
            remote_template_path
            / "usr"
            / "share"
            / "code-signing"
            / self._template_deb_name
            / "files.json",
            local_files_json_path,
        )
        manifest = json.loads(local_files_json_path.read_text())
        # For mypy.  If the manifest isn't a JSON object, we'll get a
        # run-time error almost immediately afterwards anyway.
        assert isinstance(manifest, dict)
        return manifest

    def _make_signing_input_artifact(
        self, package: str, metadata: dict[str, Any]
    ) -> SigningInputArtifact:
        """
        Check a package's metadata and make a signing-input artifact.

        :raises ExtractError: if the package's metadata is invalid.
        """
        assert self.executor_instance is not None
        assert self._binary_paths is not None
        assert self._remote_execute_directory is not None
        assert self._local_execute_directory is not None

        if _re_package_name.match(package) is None:
            raise ExtractError(f"'{package}' is not a valid package name")

        remote_binary_path = self._remote_execute_directory / package
        self._extract_binary(self._binary_paths[package], remote_binary_path)

        local_binary_path = self._local_execute_directory / package
        self._pull_from_executor(remote_binary_path, local_binary_path)

        signing_input_paths: list[Path] = []
        for file in metadata["files"]:
            file_path = PurePath(file["file"])
            if ".." in file_path.parts:
                raise ExtractError(
                    f"File name '{file_path}' may not contain '..' segments"
                )
            if file_path.is_absolute():
                raise ExtractError(
                    f"File name '{file_path}' may not be absolute"
                )
            if not (
                (local_binary_path / file_path)
                .resolve()
                .is_relative_to(local_binary_path)
            ):
                raise ExtractError(
                    f"File name '{file_path}' may not traverse symlinks to "
                    f"outside the package"
                )
            signing_input_paths.append(local_binary_path / file_path)

        return SigningInputArtifact.create(
            signing_input_paths,
            local_binary_path.parent,
            trusted_certs=metadata.get("trusted_certs"),
            binary_package_name=package,
        )

    @override
    def run(self, execute_directory: Path) -> WorkRequestResults:
        """Do the main extraction work."""
        assert self.executor_instance is not None
        assert self._binary_paths is not None

        self._remote_execute_directory = PurePath(execute_directory)
        self._local_execute_directory = Path(
            tempfile.mkdtemp(prefix="debusine-extract-for-signing-")
        )

        manifest = self._read_manifest()
        self._signing_input_artifacts = {
            package: self._make_signing_input_artifact(package, metadata)
            for package, metadata in manifest["packages"].items()
        }

        return WorkRequestResults.SUCCESS

    @override
    def upload_artifacts(
        self,
        execute_directory: Path,
        *,
        execution_result: WorkRequestResults,
    ) -> None:
        """Upload artifacts for the task."""
        assert self.work_request_id is not None
        assert self.debusine is not None

        if execution_result == WorkRequestResults.SUCCESS:
            assert self.dynamic_data is not None
            assert self._binary_artifacts is not None
            assert self._signing_input_artifacts is not None

            for package, artifact in self._signing_input_artifacts.items():
                uploaded_artifact = self.debusine.upload_artifact(
                    artifact,
                    workspace=self.workspace_name,
                    work_request=self.work_request_id,
                )
                for related_to_id in (
                    self.dynamic_data.input_template_artifact_id,
                    self._binary_artifacts[package],
                ):
                    self.debusine.relation_create(
                        uploaded_artifact.id,
                        related_to_id,
                        RelationType.RELATES_TO,
                    )

    @override
    def cleanup(self) -> None:
        """Clean up after running the task."""
        if self._local_execute_directory is not None:
            shutil.rmtree(self._local_execute_directory)
        super().cleanup()
