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

"""debian_pipeline workflow."""

from functools import partial
from typing import assert_never, override

from debusine.artifacts import SourcePackage
from debusine.artifacts.models import ArtifactCategory, DebianSourcePackage
from debusine.assets import KeyPurpose
from debusine.client.models import LookupChildType
from debusine.db.models import WorkRequest
from debusine.server.tasks.wait.models import ConfirmData
from debusine.server.workflows import (
    Workflow,
    WorkflowValidationError,
    workflow_utils,
)
from debusine.server.workflows.models import (
    DebianPipelineWorkflowData,
    DebianPipelineWorkflowDynamicData,
    MakeSignedSourceWorkflowData,
    PackagePublishWorkflowData,
    PackageUploadWorkflowData,
    QAFailOn,
    QAFailurePolicy,
    QAWorkflowData,
    SbuildWorkflowData,
    WorkRequestWorkflowData,
)
from debusine.tasks.models import (
    BackendType,
    ExtraDebusineRepository,
    ExtraRepository,
    InputArtifact,
    InputArtifactSingle,
    LintianFailOnSeverity,
    LookupMultiple,
    LookupSingle,
    SbuildInput,
)
from debusine.tasks.server import TaskDatabaseInterface


class DebianPipelineWorkflow(
    Workflow[DebianPipelineWorkflowData, DebianPipelineWorkflowDynamicData]
):
    """Debian pipeline workflow."""

    TASK_NAME = "debian_pipeline"

    @override
    def validate_input(self) -> None:
        """Thorough validation of input data."""
        if self.data.qa_suite is not None:
            try:
                self.work_request.lookup_single(
                    self.data.qa_suite, expect_type=LookupChildType.COLLECTION
                )
            except LookupError as e:
                raise WorkflowValidationError(str(e)) from e

    @override
    def build_dynamic_data(
        self,
        task_database: TaskDatabaseInterface,  # noqa: U100
    ) -> DebianPipelineWorkflowDynamicData:
        """
        Compute dynamic data for this workflow.

        :subject: package name of ``source_artifact``
        :source_artifact_id: id of the source artifact
        """
        source_artifact = workflow_utils.source_package(self)
        source_data = SourcePackage.create_data(source_artifact.data)

        return DebianPipelineWorkflowDynamicData(
            subject=source_data.name,
            parameter_summary=f"{source_data.name}_{source_data.version}",
            input_source_artifact_id=source_artifact.id,
        )

    @override
    def get_input_artifacts(self) -> list[InputArtifact]:
        """Return workflow input artifacts (the source package)."""
        return [
            InputArtifactSingle(
                lookup=self.data.source_artifact,
                label="source_artifact",
                artifact_id=(
                    self.dynamic_data.input_source_artifact_id
                    if self.dynamic_data is not None
                    else None
                ),
            )
        ]

    def suite_has_active_indexes(self, suite_lookup: LookupSingle) -> bool:
        """Return True if this suite has an active ``InRelease`` file."""
        suite = self.work_request.lookup_single(
            suite_lookup, expect_type=LookupChildType.COLLECTION
        ).collection
        return (
            suite.child_items.active()
            .filter(
                category=ArtifactCategory.REPOSITORY_INDEX,
                data__path="InRelease",
            )
            .exists()
        )

    def get_extra_repositories(self) -> list[ExtraRepository] | None:
        """Decide which extra repositories to use for this workflow."""
        extra_repositories = (
            None
            if self.data.extra_repositories is None
            else list(self.data.extra_repositories)
        )
        if self.data.suite is not None and self.suite_has_active_indexes(
            self.data.suite
        ):
            if extra_repositories is None:
                extra_repositories = []
            extra_repositories.insert(
                0, ExtraDebusineRepository(suite=self.data.suite)
            )
        return extra_repositories

    @override
    def populate(self) -> None:  # noqa: C901
        """Create work requests."""
        assert self.dynamic_data is not None
        assert self.dynamic_data.subject is not None

        source_data = workflow_utils.source_package_data(self)

        if (data_archs := self.data.architectures) is not None:
            architectures = set(data_archs)
        else:
            architectures = workflow_utils.get_available_architectures(
                self, vendor=self.data.vendor, codename=self.data.codename
            )

        if (
            data_archs_allowlist := self.data.architectures_allowlist
        ) is not None:
            architectures.intersection_update(data_archs_allowlist)

        if (
            data_archs_denylist := self.data.architectures_denylist
        ) is not None:
            architectures.difference_update(data_archs_denylist)

        target_distribution = f"{self.data.vendor}:{self.data.codename}"

        effective_architectures = sorted(architectures)

        extra_repositories = self.get_extra_repositories()

        self._populate_sbuild(
            source_artifact=self.data.source_artifact,
            target_distribution=target_distribution,
            backend=self.data.sbuild_backend,
            architectures=effective_architectures,
            environment_variant=self.data.sbuild_environment_variant,
            extra_repositories=extra_repositories,
            signing_template_names=self.data.signing_template_names,
        )

        # sbuild workflow promises the build-{arch} artifacts
        binary_artifacts = LookupMultiple.model_validate(
            {
                "collection": "internal@collections",
                "child_type": LookupChildType.ARTIFACT_OR_PROMISE,
                "name__startswith": "build-",
            }
        )

        to_upload = [
            (
                self.data.source_artifact,
                binary_artifacts,
                self.dynamic_data.subject,
                False,
            )
        ]

        # Work request that post-QA steps in the pipeline should depend on
        # for QA confirmation.  Depending on the task data, this may be
        # automatic or manual.
        qa_confirmation: WorkRequest | None = None

        qa: WorkRequest | None = None
        if (
            self.data.enable_autopkgtest
            or self.data.enable_reverse_dependencies_autopkgtest
            or self.data.enable_lintian
            or self.data.enable_piuparts
            or self.data.enable_debdiff
            or self.data.enable_blhc
        ):
            populate_qa = partial(
                self._populate_qa,
                source_artifact=self.data.source_artifact,
                vendor=self.data.vendor,
                codename=self.data.codename,
                architectures=effective_architectures,
                arch_all_build_architecture=(
                    self.data.arch_all_build_architecture
                ),
                extra_repositories=extra_repositories,
                qa_suite=self.data.qa_suite,
                enable_autopkgtest=self.data.enable_autopkgtest,
                autopkgtest_backend=self.data.autopkgtest_backend,
                enable_reverse_dependencies_autopkgtest=(
                    self.data.enable_reverse_dependencies_autopkgtest
                ),
                enable_lintian=self.data.enable_lintian,
                lintian_backend=self.data.lintian_backend,
                lintian_fail_on_severity=self.data.lintian_fail_on_severity,
                enable_piuparts=self.data.enable_piuparts,
                piuparts_backend=self.data.piuparts_backend,
                piuparts_environment=self.data.piuparts_environment,
                enable_blhc=self.data.enable_blhc,
            )

            if self.data.enable_regression_tracking:
                reference_source_lookup = (
                    f"{self.data.qa_suite}/source:{source_data.name}"
                )
                try:
                    reference_source = self.work_request.lookup_single(
                        reference_source_lookup,
                        expect_type=LookupChildType.ARTIFACT,
                    ).artifact
                except KeyError:
                    # This source package does not exist in qa_suite.  Skip
                    # regression tracking.
                    pass
                else:
                    reference_source_data = reference_source.create_data()
                    assert isinstance(
                        reference_source_data, DebianSourcePackage
                    )
                    reference_name = reference_source_data.name
                    reference_version = reference_source_data.version
                    populate_qa(
                        prefix="reference-qa-result|",
                        source_artifact=reference_source.id,
                        binary_artifacts=LookupMultiple.model_validate(
                            {
                                "collection": self.data.qa_suite,
                                "category": ArtifactCategory.BINARY_PACKAGE,
                                "data__srcpkg_name": reference_name,
                                "data__srcpkg_version": reference_version,
                            }
                        ),
                        enable_regression_tracking=False,
                        reference_qa_results=(
                            self.data.regression_tracking_qa_results
                        ),
                        update_qa_results=True,
                        # The debdiff workflow doesn't contribute any QA
                        # results, so don't waste time running it here.
                        enable_debdiff=False,
                        fail_on=QAFailOn.NEVER,
                        workflow_data=WorkRequestWorkflowData(
                            display_name=(
                                f"QA (reference tests for "
                                f"{reference_name}_{reference_version})"
                            ),
                            step="qa-reference",
                            expanded=False,
                        ),
                    )
                    qa = populate_qa(
                        source_artifact=self.data.source_artifact,
                        binary_artifacts=binary_artifacts,
                        reference_source_artifact=reference_source.id,
                        enable_regression_tracking=True,
                        reference_qa_results=(
                            self.data.regression_tracking_qa_results
                        ),
                        reference_prefix="reference-qa-result|",
                        fail_on=QAFailOn.REGRESSION,
                        enable_debdiff=self.data.enable_debdiff,
                    )

            if qa is None:
                qa = populate_qa(
                    source_artifact=self.data.source_artifact,
                    binary_artifacts=binary_artifacts,
                    enable_debdiff=self.data.enable_debdiff,
                )

            match self.data.qa_failure_policy:
                case QAFailurePolicy.IGNORE:
                    pass
                case QAFailurePolicy.FAIL:
                    qa_confirmation = qa
                case QAFailurePolicy.CONFIRM:
                    qa_confirmation = self._populate_confirmation(
                        auto_confirm_if_no_failure=True,
                        confirm_label=(
                            "Continue the workflow despite QA failures"
                        ),
                    )
                    qa_confirmation.add_dependency(qa)
                case _ as unreachable:
                    assert_never(unreachable)

        if qa_confirmation is None and self.data.enable_confirmation:
            qa_confirmation = self._populate_confirmation()

        if (
            self.data.enable_make_signed_source
            and self.data.signing_template_names is not None
        ):
            if self.data.make_signed_source_purpose is None:
                raise WorkflowValidationError(
                    '"make_signed_source_purpose" must be set when '
                    'signing the source'
                )

            if self.data.make_signed_source_key is None:
                raise WorkflowValidationError(
                    '"make_signed_source_key" must be set when '
                    'signing the source'
                )

            for (
                upload_source_artifact,
                upload_binary_artifacts,
                upload_subject,
            ) in self._populate_make_signed_source(
                binary_artifacts=binary_artifacts,
                vendor=self.data.vendor,
                codename=self.data.codename,
                architectures=effective_architectures,
                purpose=self.data.make_signed_source_purpose,
                key=self.data.make_signed_source_key,
                sbuild_backend=self.data.sbuild_backend,
                signing_template_names=self.data.signing_template_names,
                qa_confirmation=qa_confirmation,
            ):
                to_upload.append(
                    (
                        upload_source_artifact,
                        upload_binary_artifacts,
                        upload_subject,
                        True,
                    )
                )

        if self.data.enable_upload:
            for (
                upload_source_artifact,
                upload_binary_artifacts,
                upload_subject,
                signed_source,
            ) in to_upload:
                self._populate_package_upload(
                    source_artifact=upload_source_artifact,
                    binary_artifacts=upload_binary_artifacts,
                    include_source=self.data.upload_include_source,
                    include_binaries=self.data.upload_include_binaries,
                    signed_source=signed_source,
                    merge_uploads=self.data.upload_merge_uploads,
                    since_version=self.data.upload_since_version,
                    key=self.data.upload_key,
                    binary_key=self.data.upload_binary_key,
                    require_signature=self.data.upload_require_signature,
                    target=self.data.upload_target,
                    delayed_days=self.data.upload_delayed_days,
                    vendor=self.data.vendor,
                    codename=self.data.codename,
                    subject=upload_subject,
                    qa_confirmation=qa_confirmation,
                )

        if self.data.suite:
            for (
                upload_source_artifact,
                upload_binary_artifacts,
                upload_subject,
                _,
            ) in to_upload:
                self._populate_package_publish(
                    source_artifact=upload_source_artifact,
                    binary_artifacts=upload_binary_artifacts,
                    target_suite=self.data.suite,
                    replace=self.data.publish_replace,
                    subject=upload_subject,
                    qa_confirmation=qa_confirmation,
                )

    def _populate_sbuild(
        self,
        *,
        source_artifact: LookupSingle,
        target_distribution: str,
        backend: BackendType,
        architectures: list[str],
        environment_variant: str | None,
        extra_repositories: list[ExtraRepository] | None = None,
        signing_template_names: dict[str, list[str]],
    ) -> None:
        """Create work request for sbuild workflow."""
        wr = self.work_request_ensure_child_workflow(
            task_name="sbuild",
            task_data=SbuildWorkflowData(
                input=SbuildInput(source_artifact=source_artifact),
                target_distribution=target_distribution,
                backend=backend,
                architectures=architectures,
                arch_all_build_architecture=(
                    self.data.arch_all_build_architecture
                ),
                environment_variant=environment_variant,
                extra_repositories=extra_repositories,
                signing_template_names=signing_template_names,
            ),
            workflow_data=WorkRequestWorkflowData(
                display_name="sbuild",
                step="sbuild",
            ),
        )
        self.orchestrate_child(wr)

    def _populate_qa(
        self,
        *,
        prefix: str = "",
        reference_prefix: str = "",
        source_artifact: LookupSingle,
        binary_artifacts: LookupMultiple,
        reference_source_artifact: LookupSingle | None = None,
        vendor: str,
        codename: str,
        architectures: list[str],
        arch_all_build_architecture: str,
        extra_repositories: list[ExtraRepository] | None = None,
        qa_suite: LookupSingle | None,
        reference_qa_results: LookupSingle | None = None,
        enable_regression_tracking: bool = False,
        update_qa_results: bool = False,
        enable_autopkgtest: bool,
        autopkgtest_backend: BackendType,
        enable_reverse_dependencies_autopkgtest: bool,
        enable_lintian: bool,
        lintian_backend: BackendType,
        lintian_fail_on_severity: LintianFailOnSeverity,
        enable_piuparts: bool,
        piuparts_backend: BackendType,
        piuparts_environment: LookupSingle | None,
        enable_debdiff: bool,
        enable_blhc: bool,
        fail_on: QAFailOn = QAFailOn.FAILURE,
        workflow_data: WorkRequestWorkflowData | None = None,
    ) -> WorkRequest:
        """Create work request for qa workflow."""
        data = QAWorkflowData(
            prefix=prefix,
            reference_prefix=reference_prefix,
            source_artifact=source_artifact,
            binary_artifacts=binary_artifacts,
            reference_source_artifact=reference_source_artifact,
            vendor=vendor,
            codename=codename,
            architectures=architectures,
            arch_all_build_architecture=arch_all_build_architecture,
            extra_repositories=extra_repositories,
            qa_suite=qa_suite,
            reference_qa_results=reference_qa_results,
            enable_regression_tracking=enable_regression_tracking,
            update_qa_results=update_qa_results,
            enable_autopkgtest=enable_autopkgtest,
            autopkgtest_backend=autopkgtest_backend,
            enable_reverse_dependencies_autopkgtest=(
                enable_reverse_dependencies_autopkgtest
            ),
            enable_lintian=enable_lintian,
            lintian_backend=lintian_backend,
            lintian_fail_on_severity=lintian_fail_on_severity,
            enable_piuparts=enable_piuparts,
            piuparts_backend=piuparts_backend,
            enable_debdiff=enable_debdiff,
            enable_blhc=enable_blhc,
            fail_on=fail_on,
        )
        if piuparts_environment:
            data.piuparts_environment = piuparts_environment
        if workflow_data is None:
            workflow_data = WorkRequestWorkflowData(
                display_name="QA", step="qa"
            )
        wr = self.work_request_ensure_child_workflow(
            task_name="qa", task_data=data, workflow_data=workflow_data
        )
        # The qa workflow's children will have dependencies on the work
        # requests creating binary_artifacts, but the qa workflow itself
        # doesn't need that in order to populate itself.
        self.orchestrate_child(wr)
        return wr

    def _populate_confirmation(
        self,
        *,
        auto_confirm_if_no_failure: bool = False,
        confirm_label: str = "Continue the workflow",
    ) -> WorkRequest:
        """Create a work request acting as a confirmation step."""
        return self.work_request_ensure_child_wait(
            task_name="confirm",
            task_data=ConfirmData(
                auto_confirm_if_no_failure=auto_confirm_if_no_failure,
                confirm_label=confirm_label,
            ),
            workflow_data=WorkRequestWorkflowData(
                display_name="Confirm", step="confirm", needs_input=True
            ),
        )

    def _populate_make_signed_source(
        self,
        *,
        vendor: str,
        codename: str,
        binary_artifacts: LookupMultiple,
        architectures: list[str],
        purpose: KeyPurpose,
        key: str,
        sbuild_backend: BackendType,
        signing_template_names: dict[str, list[str]],
        qa_confirmation: WorkRequest | None = None,
    ) -> list[tuple[LookupSingle, LookupMultiple, str]]:
        """Create work request for make signed source workflow."""
        signing_artifacts: list[LookupSingle] = []
        to_upload: list[tuple[LookupSingle, LookupMultiple, str]] = []
        for arch, names in sorted(signing_template_names.items()):
            for name in names:
                # The promises for the signing_artifacts are created
                # by Sbuild workflow
                signing_artifact = (
                    f"internal@collections/name:signing-template-{arch}-{name}"
                )
                self.work_request.lookup_single(
                    signing_artifact,
                    expect_type=LookupChildType.ARTIFACT_OR_PROMISE,
                )

                signing_artifacts.append(signing_artifact)
                to_upload.append(
                    (
                        f"internal@collections/name:"
                        f"signed-source-{arch}-{name}",
                        LookupMultiple.model_validate(
                            {
                                "collection": "internal@collections",
                                "child_type": (
                                    LookupChildType.ARTIFACT_OR_PROMISE
                                ),
                                "name__startswith": (
                                    f"signed-source-{arch}-{name}|build-"
                                ),
                            }
                        ),
                        f"{arch}/{name}",
                    )
                )

        signing_template_artifacts = LookupMultiple.model_validate(
            signing_artifacts
        )
        wr = self.work_request_ensure_child_workflow(
            task_name="make_signed_source",
            task_data=MakeSignedSourceWorkflowData(
                binary_artifacts=binary_artifacts,
                signing_template_artifacts=signing_template_artifacts,
                vendor=vendor,
                codename=codename,
                architectures=architectures,
                purpose=purpose,
                key=key,
                sbuild_backend=sbuild_backend,
            ),
            workflow_data=WorkRequestWorkflowData(
                display_name="make signed source", step="make_signed_source"
            ),
        )

        # If confirmation is enabled, then the make_signed_source workflow
        # will not be populated until confirmation (to avoid having to poke
        # around in its internals to find the first work request for each
        # architecture).
        if qa_confirmation is not None:
            wr.add_dependency(qa_confirmation)

        # The make_signed_source workflow's children will have dependencies
        # on the work requests creating binary_artifacts and
        # signing_artifacts, but the make_signed_source workflow itself
        # doesn't need that in order to populate itself.
        self.orchestrate_child(wr)

        return to_upload

    def _populate_package_upload(
        self,
        *,
        binary_artifacts: LookupMultiple,
        source_artifact: LookupSingle,
        include_source: bool,
        include_binaries: bool,
        signed_source: bool,
        merge_uploads: bool,
        since_version: str | None,
        key: str | None,
        binary_key: str | None,
        require_signature: bool,
        target: str,
        delayed_days: int | None,
        vendor: str,
        codename: str,
        subject: str,
        qa_confirmation: WorkRequest | None = None,
    ) -> None:
        """Create work request for package upload workflow."""
        wr = self.work_request_ensure_child_workflow(
            task_name="package_upload",
            task_data=PackageUploadWorkflowData(
                source_artifact=(
                    source_artifact if include_source or signed_source else None
                ),
                binary_artifacts=(
                    binary_artifacts if include_binaries else LookupMultiple(())
                ),
                merge_uploads=merge_uploads,
                since_version=since_version,
                target_distribution=self.data.upload_target_distribution,
                key=key,
                binary_key=binary_key,
                require_signature=require_signature,
                target=target,
                delayed_days=delayed_days,
                vendor=vendor,
                codename=codename,
                arch_all_build_architecture=(
                    self.data.arch_all_build_architecture
                ),
            ),
            workflow_data=WorkRequestWorkflowData(
                display_name=f"package upload ({subject})",
                step=f"package_upload/{subject}",
            ),
        )

        # If confirmation is enabled, then the package_upload workflow will
        # not be populated until confirmation (to avoid having to poke
        # around in its internals to find the first work request).
        if qa_confirmation is not None:
            wr.add_dependency(qa_confirmation)

        # The package_upload workflow's children will have dependencies on
        # the work requests creating source_artifact and binary_artifacts,
        # but the package_upload workflow itself doesn't normally need that
        # in order to populate itself.  An exception is if we're uploading
        # signed source with binaries, in which case the make_signed_source
        # workflow will only be able to create even the necessary promises
        # once the signed source package has been assembled.
        if signed_source and include_binaries and binary_artifacts:
            self.requires_artifact(wr, source_artifact)

        self.orchestrate_child(wr)

    def _populate_package_publish(
        self,
        *,
        binary_artifacts: LookupMultiple,
        source_artifact: LookupSingle,
        target_suite: LookupSingle,
        replace: bool,
        subject: str,
        qa_confirmation: WorkRequest | None = None,
    ) -> None:
        """Create work request for package publish workflow."""
        wr = self.work_request_ensure_child_workflow(
            task_name="package_publish",
            task_data=PackagePublishWorkflowData(
                source_artifact=source_artifact,
                binary_artifacts=binary_artifacts,
                target_suite=target_suite,
                replace=replace,
            ),
            workflow_data=WorkRequestWorkflowData(
                display_name=f"publish packages ({subject})",
                step=f"package_publish/{subject}",
            ),
        )

        # If confirmation is enabled, then the package_publish workflow will
        # not be populated until confirmation (to avoid having to poke
        # around in its internals to find the first work request).
        if qa_confirmation is not None:
            wr.add_dependency(qa_confirmation)

        # The package_publish workflow needs to split up debian:upload
        # binary artifacts into individual debian:binary-package artifacts
        # when preparing data for the CopyCollectionItems task, so its input
        # artifacts must exist before it can populate itself.
        self.requires_artifact(wr, source_artifact)
        self.requires_artifact(wr, binary_artifacts)

        self.orchestrate_child(wr)
