# Copyright 2023 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 test Debian binary packages using piuparts.

This task follows the specification laid out here:
https://freexian-team.pages.debian.net/debusine/reference/tasks.html#piuparts-task
"""
import tarfile
from io import BytesIO
from pathlib import Path
from typing import Any

import debusine.utils
from debusine.artifacts.models import CollectionCategory
from debusine.tasks import (
    BaseTaskWithExecutor,
    RunCommandTask,
)
from debusine.tasks.models import PiupartsData, PiupartsDynamicData
from debusine.tasks.server import TaskDatabaseInterface


class Piuparts(
    RunCommandTask[PiupartsData, PiupartsDynamicData],
    BaseTaskWithExecutor[PiupartsData, PiupartsDynamicData],
):
    """Test Debian binary packages using piuparts."""

    TASK_VERSION = 1

    _piuparts_tmpdir = "/var/cache/piuparts/tmp"

    def __init__(
        self,
        task_data: dict[str, Any],
        dynamic_task_data: dict[str, Any] | None = None,
    ) -> None:
        """Initialize (constructor)."""
        super().__init__(task_data, dynamic_task_data)
        # *.deb paths. Set by self.configure_for_execution()
        self._deb_files: list[Path] = []

        # --basetgz=... option for piuparts cmdline.
        # Set by self.configure_for_execution()
        self._base_tar: Path | None = None

        # --distribution... option for piuparts cmdline.
        # Set by self.configure_for_execution()
        self._distribution_codename: str | None = None

    def compute_dynamic_data(
        self, task_database: TaskDatabaseInterface
    ) -> PiupartsDynamicData:
        """Resolve artifact lookups for this task."""
        environment = self.get_environment_lookup()
        assert environment is not None
        return PiupartsDynamicData(
            environment_id=task_database.lookup_single_artifact(
                environment, default_category=CollectionCategory.ENVIRONMENTS
            ),
            input_binary_artifacts_ids=task_database.lookup_multiple_artifacts(
                self.data.input.binary_artifacts
            ),
            base_tgz_id=task_database.lookup_single_artifact(
                self.data.base_tgz,
                default_category=CollectionCategory.ENVIRONMENTS,
            ),
        )

    def fetch_input(self, destination: Path) -> bool:
        # FetchBuildUploadMixin method
        """Populate work directory with user-specified binary artifact(s)."""
        assert self.dynamic_data

        for artifact_id in self.dynamic_data.input_binary_artifacts_ids:
            self.fetch_artifact(artifact_id, destination)
        return True

    def configure_for_execution(self, download_directory: Path) -> bool:
        r"""
        Find the .deb files for piuparts.

        Prepare executor, install "piuparts" in it and prepare
        debian:system-image (e.g. delete /dev/\* files).

        Set self._deb_files to the relevant files.

        :param download_directory: where to find the \*.deb files (downloaded
           via fetch_input) and where to download the chroot of
           debian:system-image (for piuparts --basetgz).
        :return: True if valid files were found
        """
        # Find the files to test or early exit if not files
        self._deb_files = debusine.utils.find_files_suffixes(
            download_directory, [".deb"]
        )
        # Ensure we've got >=1 .deb file(s)
        # Note: This could be moved into a more generic function shared across
        # tasks.
        if len(self._deb_files) == 0:
            list_of_files = sorted(map(str, download_directory.iterdir()))
            self.append_to_log_file(
                "configure_for_execution.log",
                [
                    f"There must be at least one *.deb file. "
                    f"Current files: {list_of_files}"
                ],
            )
            return False

        # Prepare executor
        self._prepare_executor_instance()

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

        # Prepare executor_instance to run piuparts
        self.executor_instance.mkdir(Path(self._piuparts_tmpdir), parents=True)

        self.executor_instance.run(
            ["apt-get", "update"], run_as_root=True, check=True
        )

        # mmdebstrap to use mmtarfilter
        self.executor_instance.run(
            ["apt-get", "--yes", "install", "piuparts", "mmdebstrap"],
            run_as_root=True,
            check=True,
        )

        self._prepare_base_tgz(download_directory)

        return True

    def _prepare_base_tgz(self, download_directory: Path) -> None:
        """
        Download the base image for piuparts. Remove /dev/* if needed.

        Set self._base_tar to the file that is used by _cmdline.
        """
        if self.executor_instance is None:
            raise AssertionError("self.executor_instance cannot be None")
        assert self.dynamic_data

        (base_tar_dir := download_directory / "base_tar").mkdir()

        base_tgz_response = self.fetch_artifact(
            self.dynamic_data.base_tgz_id, base_tar_dir
        )

        self._distribution_codename = base_tgz_response.data["codename"]

        self._base_tar = base_tar_dir / base_tgz_response.data["filename"]
        assert self._base_tar  # For mypy, otherwise fails below with:
        #     error: Item "None" of "Path | None" has no \
        #     attribute "stem" [union-attr]

        # Is the base_tgz set up for systemd-resolved?
        resolvconf_symlink = False
        with tarfile.open(self._base_tar) as tar:
            for name in (
                "etc/resolv.conf",
                "/etc/resolv.conf",
                "./etc/resolv.conf",
            ):
                try:
                    if tar.getmember(name).issym():  # pragma: no cover
                        resolvconf_symlink = True
                        break
                except KeyError:
                    continue

        if not base_tgz_response.data["with_dev"] and not resolvconf_symlink:
            # No processing needs to be done, the image can stay as
            # .tar.xz (or the format retrieved)
            return

        # /dev/* and/or /etc/resolv.conf will be removed from the image.
        # Will create a .tar file, and point self._base_tar to the new file.

        processed_tar_name = self._base_tar.stem.rsplit(".", 1)[0] + ".tar"
        processed_tar = self._base_tar.with_name(processed_tar_name)

        cmd = ["mmtarfilter"]
        if base_tgz_response.data["with_dev"]:
            cmd.append("--path-exclude=/dev/*")
        if resolvconf_symlink:
            cmd.append("--path-exclude=/etc/resolv.conf")

        with self._base_tar.open("rb") as input_image, processed_tar.open(
            "wb"
        ) as output_image:
            self.executor_instance.run(
                cmd, stdin=input_image, stdout=output_image
            )

        if resolvconf_symlink:
            with tarfile.open(processed_tar, "a") as tar:
                tarinfo = tarfile.TarInfo(name="./etc/resolv.conf")
                tarinfo.mode = 0o644
                tarinfo.size = 0
                tar.addfile(tarinfo, BytesIO(b""))

        # Delete not used image
        self._base_tar.unlink()

        # Use the processed image
        self._base_tar = processed_tar

    def _cmdline(self):
        # FetchBuildUploadMixin method
        """Build full piuparts command line."""
        cmdline = []

        cmdline.append("/usr/sbin/piuparts")
        # Always set distribution, otherwise it defaults to 'sid'
        cmdline.append(f"--distribution={self._distribution_codename}")
        # Common options used in Debian piuparts services/CI
        cmdline.append("--allow-database")
        cmdline.append("--warn-on-leftovers-after-purge")
        # Maintenance scripts requirement
        cmdline.append(f"--tmpdir={self._piuparts_tmpdir}")

        cmdline.append(f"--basetgz={self._base_tar}")

        cmdline.extend(map(str, self._deb_files))
        return cmdline

    @staticmethod
    def _cmdline_as_root() -> bool:
        """Piuparts must run as root."""
        return True

    def upload_artifacts(
        self, build_directory: Path, *, execution_success: bool  # noqa: U100
    ):
        # FetchBuildUploadMixin method
        """cmd-output.log is enough for now, upload nothing."""
        return True
