# 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 use debdiff in debusine."""

from dataclasses import dataclass
from itertools import chain
from pathlib import Path
from tempfile import mkdtemp
from typing import override

import debusine.tasks.tags as ttags
import debusine.worker.tags as wtags
from debusine import utils
from debusine.artifacts import DebDiffArtifact
from debusine.artifacts.models import (
    ArtifactCategory,
    DebianBinaryPackage,
    DebianSourcePackage,
    DebianUpload,
    WorkRequestResults,
    get_binary_package_version,
    get_source_package_name,
    get_source_package_version,
)
from debusine.client.models import RelationType
from debusine.tasks import BaseTaskWithExecutor, RunCommandTask, TaskConfigError
from debusine.tasks.inputs import (
    EnvironmentInput,
    MultiInputList,
    SingleInputList,
    Stage,
)
from debusine.tasks.models import DebDiffData, DebDiffDynamicData


@dataclass(frozen=True, kw_only=True)
class ArtifactSummaryBase:
    """Summary of the artifacts."""

    package: str | None
    original_version: str | None
    new_version: str | None


@dataclass(frozen=True, kw_only=True)
class SourceArtifactsSummary(ArtifactSummaryBase):
    """Summary of the source artifacts."""

    ids: list[int]


@dataclass(frozen=True, kw_only=True)
class BinaryArtifactsSummary(ArtifactSummaryBase):
    """Summary of the binary artifacts."""

    ids: list[list[int]]


class DebDiff(
    RunCommandTask[DebDiffData, DebDiffDynamicData],
    BaseTaskWithExecutor[DebDiffData, DebDiffDynamicData],
):
    """Task to use debdiff in debusine."""

    TASK_VERSION = 1

    CAPTURE_OUTPUT_FILENAME = "debdiff.txt"

    environment = EnvironmentInput()
    source_artifacts = SingleInputList(
        field="input.source_artifacts",
        categories=[ArtifactCategory.SOURCE_PACKAGE],
        stage=Stage.PENDING,
    )
    binary_artifacts = MultiInputList(
        field="input.binary_artifacts",
        categories=[ArtifactCategory.UPLOAD, ArtifactCategory.BINARY_PACKAGE],
        stage=Stage.PENDING,
    )

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

        # list of directories that fetch_input() downloaded the artifacts
        self._original_dirs: list[Path] = []
        self._new_targets_dirs: list[Path] = []

        # list of files that configure_for_execution() found to pass to debdiff
        self._original_targets: list[Path] = []
        self._new_targets: list[Path] = []

    @staticmethod
    def _validate_binary_original_new_count(
        original_count: int, new_count: int
    ) -> None:
        """
        Validate original_count and new_count for binary artifacts.

        Raise TaskConfigError for invalid combinations.
        """
        if original_count == 0 or new_count == 0:
            raise TaskConfigError(
                f"input.binary_artifacts[0] and input.binary_artifacts[1] "
                f"cannot have zero artifacts "
                f"(got {original_count} and {new_count})"
            )

    def _summarize_source_artifacts(self) -> SourceArtifactsSummary | None:
        source_artifacts = self.source_artifacts
        if not source_artifacts:
            return None

        package = None
        artifacts_ids = []
        for source_artifact in source_artifacts:
            if package is None:
                assert isinstance(source_artifact.data, DebianSourcePackage)
                package = get_source_package_name(source_artifact.data)
            artifacts_ids += source_artifact.resolved_ids()

        assert isinstance(source_artifacts[0].data, DebianSourcePackage)
        assert isinstance(source_artifacts[1].data, DebianSourcePackage)

        original_version = get_source_package_version(source_artifacts[0].data)
        new_version = get_source_package_version(source_artifacts[1].data)

        return SourceArtifactsSummary(
            package=package,
            original_version=original_version,
            new_version=new_version,
            ids=artifacts_ids,
        )

    def _summarize_binary_artifacts(self) -> BinaryArtifactsSummary | None:
        binary_artifacts = self.binary_artifacts
        if not binary_artifacts:
            return None

        binary_artifacts_original = binary_artifacts[0]
        binary_artifacts_new = binary_artifacts[1]

        self._validate_binary_original_new_count(
            len(binary_artifacts_original.artifacts),
            len(binary_artifacts_new.artifacts),
        )

        ids = []
        source_package_names = set()
        binary_artifact_category = None

        for multiple_artifacts in [
            binary_artifacts_original,
            binary_artifacts_new,
        ]:
            ids.append(multiple_artifacts.resolved_ids())

            for artifact in multiple_artifacts.artifacts:
                assert isinstance(
                    artifact.data,
                    (DebianUpload, DebianBinaryPackage),
                )
                if binary_artifact_category is None:
                    binary_artifact_category = artifact.category

                if binary_artifact_category != artifact.category:
                    raise TaskConfigError(
                        f'All binary artifacts must have the same '
                        f'category. Found "{binary_artifact_category}" '
                        f'and "{artifact.category}"'
                    )

                source_package_names.add(get_source_package_name(artifact.data))

            if (
                binary_artifact_category != ArtifactCategory.BINARY_PACKAGE
                and len(multiple_artifacts.artifacts) > 1
            ):
                raise TaskConfigError(
                    "If binary_artifacts source and new contain more than "
                    "one artifact all must be of category "
                    "debian:binary-package. Found: "
                    f"{binary_artifact_category}"
                )

        package = None
        original_version = None
        new_version = None

        if len(source_package_names) == 1:
            (package,) = tuple(source_package_names)
            assert isinstance(
                binary_artifacts_original.artifacts[0].data,
                (DebianUpload, DebianBinaryPackage),
            )
            original_version = get_binary_package_version(
                binary_artifacts_original.artifacts[0].data
            )

            assert isinstance(
                binary_artifacts_new.artifacts[0].data,
                (DebianUpload, DebianBinaryPackage),
            )
            new_version = get_binary_package_version(
                binary_artifacts_new.artifacts[0].data
            )

        return BinaryArtifactsSummary(
            package=package,
            original_version=original_version,
            new_version=new_version,
            ids=ids,
        )

    @override
    def compute_scheduler_tags(
        self,
    ) -> tuple[ttags.TaskProvidedTags, wtags.TaskRequiredTags]:
        provided, required = super().compute_scheduler_tags()

        if (
            summary := self._summarize_source_artifacts()
        ) and summary.package is not None:
            provided.add(
                ttags.ProvenanceProvided.SYSTEM,
                {ttags.SOURCE_PACKAGE_PREFIX + summary.package},
            )

        return provided, required

    @override
    def get_subject(self) -> str | None:
        source_summary = self._summarize_source_artifacts()
        binary_summary = self._summarize_binary_artifacts()

        relevant_summary = source_summary or binary_summary
        assert relevant_summary is not None

        if (package := relevant_summary.package) is not None:
            if source_summary is not None:
                return f"source:{package}"
            else:
                return f"binary:{package}"
        return None

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

        :subject: ``source:NAME_OF_PACKAGE`` if ``input.source_artifacts``
          is set, otherwise ``binary:NAME_OF_PACKAGE`` if all binary artifacts
          in ``input.binary_artifacts`` originate from the same source package
        :parameter_summary: subject: original_version → new_version (arch)
        """
        source_summary = self._summarize_source_artifacts()
        binary_summary = self._summarize_binary_artifacts()

        relevant_summary = source_summary or binary_summary
        assert relevant_summary is not None

        original_version = relevant_summary.original_version
        new_version = relevant_summary.new_version

        parameter_summary = None
        if (package := relevant_summary.package) is not None:
            parameter_summary = f"{package}: {original_version} → {new_version}"

            if source_summary is not None:
                parameter_summary += " (source)"
            else:
                # TODO: It would probably be better to get the architecture
                # of the input binary artifacts and use that.
                parameter_summary += f" ({self.build_architecture})"

        return DebDiffDynamicData(
            environment_id=self.environment.artifact_id,
            input_source_artifacts_ids=(
                source_summary.ids if source_summary else None
            ),
            input_binary_artifacts_ids=(
                binary_summary.ids if binary_summary else None
            ),
            subject=self.get_subject(),
            parameter_summary=parameter_summary,
        )

    @override
    def _cmdline(self) -> list[str]:
        """
        Build the debdiff command line.

        Use configuration of self.data and self.debdiff_target.
        """
        cmd = [
            "debdiff",
        ]

        cmd.extend(self.data.extra_flags)

        original_targets = map(str, self._original_targets)
        new_targets = map(str, self._new_targets)

        use_from_notation = self._original_targets[0].suffix in (
            ".deb",
            ".udeb",
        )

        if use_from_notation:
            cmd += ["--from", *original_targets, "--to", *new_targets]
        else:
            cmd += [*original_targets, *new_targets]

        return cmd

    def _fetch_artifact_to_temporary(
        self, artifact_id: int, directory: Path
    ) -> Path:
        artifact_directory = Path(
            mkdtemp(dir=directory, prefix=f"{artifact_id}_")
        )
        self.fetch_artifact(artifact_id, artifact_directory)

        return artifact_directory

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

        if self.dynamic_data.input_source_artifacts_ids:
            assert len(self.dynamic_data.input_source_artifacts_ids) == 2
            self._original_dirs.append(
                self._fetch_artifact_to_temporary(
                    self.dynamic_data.input_source_artifacts_ids[0], destination
                )
            )

            self._new_targets_dirs.append(
                self._fetch_artifact_to_temporary(
                    self.dynamic_data.input_source_artifacts_ids[1], destination
                )
            )

        if self.dynamic_data.input_binary_artifacts_ids:
            assert len(self.dynamic_data.input_binary_artifacts_ids) == 2

            for artifact_id in self.dynamic_data.input_binary_artifacts_ids[0]:
                self._original_dirs.append(
                    self._fetch_artifact_to_temporary(artifact_id, destination)
                )

            for artifact_id in self.dynamic_data.input_binary_artifacts_ids[1]:
                self._new_targets_dirs.append(
                    self._fetch_artifact_to_temporary(artifact_id, destination)
                )

        return True

    @staticmethod
    def _find_file(path: Path) -> Path:
        file = utils.find_file_suffixes(path, [".changes"])

        if file is None:
            file = utils.find_file_suffixes(path, [".deb", ".udeb", ".dsc"])

        # file cannot be None because we ensure the artifact category and
        # artifacts are previously validated
        assert file is not None

        return file

    @override
    def configure_for_execution(
        self,
        download_directory: Path,  # noqa: U100
    ) -> bool:
        """
        Set self._(original|new)_targets to the relevant files.

        Use self._original_dirs and self._new_dirs for finding the specific
        targets for debdiff command.

        :param download_directory: unused
        :return: True if valid files were found
        """
        self._prepare_executor_instance()

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

        assert self.dynamic_data is not None

        self.executor_instance.run(
            ["apt-get", "update"], run_as_root=True, check=True
        )
        self.executor_instance.run(
            [
                "apt-get",
                "--yes",
                "--no-install-recommends",
                "install",
                "devscripts",
                "diffstat",  # for --diffstat
                "patchutils",  # used to compare .diff.gz files
                "wdiff",  # for --wdiff-source-control
            ],
            run_as_root=True,
            check=True,
        )

        for original_dir in self._original_dirs:
            self._original_targets.append(self._find_file(original_dir))

        for target_dir in self._new_targets_dirs:
            self._new_targets.append(self._find_file(target_dir))

        return True

    @override
    def upload_artifacts(
        self,
        exec_directory: Path,
        *,
        execution_result: WorkRequestResults,  # noqa: U100
    ) -> None:
        """Upload the DebDiffArtifact with the files and relationships."""
        assert self.dynamic_data
        if not self.debusine:
            raise AssertionError("self.debusine not set")

        debdiff_file = exec_directory / self.CAPTURE_OUTPUT_FILENAME

        original = " ".join(path.name for path in self._original_targets)
        new = " ".join(path.name for path in self._new_targets)
        debdiff_artifact = DebDiffArtifact.create(
            debdiff_output=debdiff_file,
            original=original,
            new=new,
            label=f"debdiff {self.dynamic_data.parameter_summary}",
        )

        uploaded = self.debusine.upload_artifact(
            debdiff_artifact,
            workspace=self.workspace_name,
            work_request=self.work_request_id,
        )

        for source_artifact_id in self._source_artifacts_ids:
            self.debusine.relation_create(
                uploaded.id, source_artifact_id, RelationType.RELATES_TO
            )

        if (
            binary_artifacts_ids := self.dynamic_data.input_binary_artifacts_ids
        ) is not None:
            for binary_artifact_id in chain.from_iterable(binary_artifacts_ids):
                self.debusine.relation_create(
                    uploaded.id, binary_artifact_id, RelationType.RELATES_TO
                )

    @override
    def task_result(
        self,
        returncode: int | None,
        execute_directory: Path,  # noqa: U100
    ) -> WorkRequestResults:
        """
        Evaluate task output and return success.

        We don't actually check the output, but use the return code of
        debdiff.
        """
        if returncode in [0, 1]:
            return WorkRequestResults.SUCCESS
        return WorkRequestResults.FAILURE
