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

"""Pydantic models used by debusine workflows."""

import datetime as dt
from enum import StrEnum
from functools import partial
from typing import Annotated, Any, Self

import pydantic

from debusine.assets import KeyPurpose
from debusine.db.models.workspaces import workspace_name_regex
from debusine.server.tasks.models import PackageUploadTarget
from debusine.tasks.models import (
    ActionRetryWithDelays,
    AutopkgtestFailOn,
    AutopkgtestNeedsInternet,
    AutopkgtestTimeout,
    BackendType,
    BaseDynamicTaskData,
    BaseTaskData,
    BlhcFlags,
    DebDiffFlags,
    ExtraRepository,
    LintianFailOnSeverity,
    LintianOutput,
    LookupMultiple,
    LookupSingle,
    SbuildBinNMU,
    SbuildInput,
    require_unique,
)


class BaseWorkflowData(BaseTaskData):
    """
    Base class for workflow data.

    Workflow data is encoded as JSON in the database, and it is modeled as a
    pydantic data structure in memory for both ease of access and validation.
    """


class WorkRequestManualUnblockAction(StrEnum):
    """An action taken on a review of a manual unblock request."""

    ACCEPT = "accept"
    REJECT = "reject"


class WorkRequestManualUnblockLog(pydantic.BaseModel):
    """A log entry for a review of a manual unblock request."""

    user_id: int
    timestamp: dt.datetime
    notes: str | None = None
    action: WorkRequestManualUnblockAction | None = None


class WorkRequestManualUnblockData(pydantic.BaseModel):
    """Data for a manual unblock request."""

    log: list[WorkRequestManualUnblockLog] = pydantic.Field(
        default_factory=list
    )


class WorkRequestWorkflowData(pydantic.BaseModel):
    """Data structure for WorkRequest.workflow_data."""

    model_config = pydantic.ConfigDict(validate_assignment=True, extra="forbid")

    #: Whether the parent workflow and reverse-dependencies should continue
    #: if this work request fails.  If False, they are aborted instead.
    allow_failure: bool = pydantic.Field(default=False, frozen=True)

    #: Whether this work request should continue if any of its child work
    #: requests or dependencies fails.  If False, it is aborted instead.
    allow_dependency_failures: bool = pydantic.Field(default=False, frozen=True)

    #: name of the step in the visual representation of the workflow
    display_name: str | None = pydantic.Field(default=None, frozen=True)

    #: internal identifier used to differentiate multiple workflow callbacks
    #: inside a single workflow. It allows the orchestrator to encode the plan
    #: about what it is supposed to do at this point in the workflow.
    step: str | None = pydantic.Field(default=None, frozen=True)

    #: Name of the group within this workflow containing this work request.
    group: str | None = pydantic.Field(default=None, frozen=True)

    #: For workflows created from a workflow template, the name of that
    #: template, cached here for convenience.
    workflow_template_name: str | None = pydantic.Field(
        default=None, frozen=True
    )

    #: Data stored for manual unblock requests.
    manual_unblock: WorkRequestManualUnblockData | None = None

    #: Whether this task requires user input.  Only relevant for WAIT tasks.
    needs_input: bool | None = None

    #: The number of times this task has been retried automatically.
    retry_count: int = pydantic.Field(default=0)

    #: Whether this task should be shown in the visual representation of the
    #: workflow.
    visible: bool = pydantic.Field(default=True, frozen=True)

    #: Whether this task's descendants should be expanded by default in the
    #: visual representation of the workflow.
    expanded: bool = pydantic.Field(default=True, frozen=True)


class SbuildWorkflowData(BaseWorkflowData):
    """Sbuild workflow data."""

    prefix: str = ""
    input: SbuildInput
    target_distribution: str
    # If AUTO is used, default to BackendType.UNSHARE
    backend: BackendType = BackendType.AUTO
    architectures: Annotated[
        list[str],
        pydantic.Field(min_length=1),
        pydantic.AfterValidator(require_unique),
    ]
    arch_all_build_architecture: str = "amd64"
    environment_variant: str | None = None
    extra_repositories: list[ExtraRepository] | None = None
    binnmu: SbuildBinNMU | None = None
    build_profiles: list[str] | None = None
    retry_delays: Annotated[
        list[
            Annotated[
                str, pydantic.Field(pattern=ActionRetryWithDelays._delay_re)
            ]
        ]
        | None,
        pydantic.Field(min_length=1),
    ] = None
    signing_template_names: dict[str, list[str]] = {}


class SbuildWorkflowDynamicData(BaseDynamicTaskData):
    """Dynamic data for the Sbuild workflow."""

    source_artifact_id: int | None = None


class UpdateEnvironmentsWorkflowTarget(BaseWorkflowData):
    """A target for an update_environments workflow."""

    codenames: str | list[str]
    codename_aliases: dict[str, list[str]] = pydantic.Field(
        default_factory=dict
    )
    variants: str | None | list[str | None] = pydantic.Field(
        default_factory=list
    )
    backends: str | list[str] = pydantic.Field(default_factory=list)
    architectures: Annotated[
        list[str],
        pydantic.Field(min_length=1),
        pydantic.AfterValidator(require_unique),
    ]
    mmdebstrap_template: dict[str, Any] | None = None
    simplesystemimagebuild_template: dict[str, Any] | None = None


class UpdateEnvironmentsWorkflowData(BaseWorkflowData):
    """update_environments workflow data."""

    vendor: str
    targets: Annotated[
        list[UpdateEnvironmentsWorkflowTarget], pydantic.Field(min_length=1)
    ]


class PackageUploadWorkflowData(BaseWorkflowData):
    """`package_upload` workflow data."""

    prefix: str = ""

    source_artifact: LookupSingle | None = None
    binary_artifacts: LookupMultiple = pydantic.Field(
        default_factory=partial(LookupMultiple, ())
    )
    merge_uploads: bool = False
    since_version: str | None = None
    target_distribution: str | None = None
    key: str | None = None
    binary_key: str | None = None
    require_signature: bool = True
    target: PackageUploadTarget
    delayed_days: int | None = None
    vendor: str | None = None
    codename: str | None = None
    arch_all_build_architecture: str = "amd64"


class PackageUploadWorkflowDynamicData(BaseDynamicTaskData):
    """Dynamic data for PackageUpload workflow."""

    source_artifact_id: int | None = None
    binary_artifacts_ids: list[int] | None = None


class MakeSignedSourceWorkflowData(BaseWorkflowData):
    """`make_signed_source` workflow data."""

    prefix: str = ""

    binary_artifacts: LookupMultiple
    signing_template_artifacts: LookupMultiple

    vendor: str
    codename: str
    architectures: list[str]
    purpose: KeyPurpose
    key: str
    sbuild_backend: BackendType = BackendType.AUTO


class MakeSignedSourceWorkflowDynamicData(BaseDynamicTaskData):
    """Dynamic data for the MakeSignedSource workflow."""

    binary_artifacts_ids: list[int] | None = None
    signing_template_artifacts_ids: list[int] | None = None


class RegressionTrackingUpdateQAResults(StrEnum):
    """Whether a regression-tracking workflow should update QA results."""

    NO = "no"
    YES = "yes"
    FORCE = "force"


def _convert_update_qa_results(value: Any) -> RegressionTrackingUpdateQAResults:
    match value:
        case False:
            return RegressionTrackingUpdateQAResults.NO
        case True:
            return RegressionTrackingUpdateQAResults.YES
        case _:
            return RegressionTrackingUpdateQAResults(value)


class RegressionTrackingWorkflowData(BaseWorkflowData):
    """Common data for workflows that handle regression tracking."""

    prefix: str = ""
    reference_prefix: str = ""

    qa_suite: LookupSingle | None = None
    reference_qa_results: LookupSingle | None = None
    enable_regression_tracking: bool = False
    update_qa_results: Annotated[
        RegressionTrackingUpdateQAResults,
        pydantic.BeforeValidator(
            _convert_update_qa_results, json_schema_input_type=str | bool
        ),
    ] = RegressionTrackingUpdateQAResults.NO

    @pydantic.model_validator(mode="after")
    def check_qa_results_consistency(self) -> Self:
        """Check consistency of regression-tracking options."""
        if self.enable_regression_tracking or self.update_qa_results in {
            RegressionTrackingUpdateQAResults.YES,
            RegressionTrackingUpdateQAResults.FORCE,
        }:
            if self.qa_suite is None:
                raise ValueError(
                    '"qa_suite" is required if "enable_regression_tracking" or '
                    '"update_qa_results" is set'
                )
            if self.reference_qa_results is None:
                raise ValueError(
                    '"reference_qa_results" is required if '
                    '"enable_regression_tracking" or "update_qa_results" is set'
                )
        if self.enable_regression_tracking and not self.reference_prefix:
            raise ValueError(
                '"reference_prefix" is required if '
                '"enable_regression_tracking" is set'
            )
        return self


class PiupartsWorkflowData(RegressionTrackingWorkflowData):
    """`piuparts` workflow data."""

    source_artifact: LookupSingle
    binary_artifacts: LookupMultiple

    vendor: str
    codename: str
    architectures: list[str] | None = None
    backend: BackendType = BackendType.AUTO
    environment: LookupSingle | None = None
    extra_repositories: list[ExtraRepository] | None = None
    arch_all_expected_installable: list[str] | None = None


class PiupartsWorkflowDynamicData(BaseDynamicTaskData):
    """Dynamic data for Piuparts workflow."""

    source_artifact_id: int | None = None
    binary_artifacts_ids: list[int] | None = None


class AutopkgtestWorkflowData(RegressionTrackingWorkflowData):
    """`autopkgtest` workflow data."""

    source_artifact: LookupSingle
    binary_artifacts: LookupMultiple
    context_artifacts: LookupMultiple = pydantic.Field(
        default_factory=partial(LookupMultiple, ())
    )

    vendor: str
    codename: str
    backend: BackendType = BackendType.AUTO
    architectures: list[str] = pydantic.Field(default_factory=list)
    arch_all_build_architecture: str = "amd64"
    extra_repositories: list[ExtraRepository] | None = None

    include_tests: list[str] = pydantic.Field(default_factory=list)
    exclude_tests: list[str] = pydantic.Field(default_factory=list)
    debug_level: int = pydantic.Field(default=0, ge=0, le=3)
    pin_packages: dict[str, list[str]] = pydantic.Field(default_factory=dict)
    extra_environment: dict[str, str] = pydantic.Field(default_factory=dict)
    needs_internet: AutopkgtestNeedsInternet = AutopkgtestNeedsInternet.RUN
    fail_on: AutopkgtestFailOn = pydantic.Field(
        default_factory=AutopkgtestFailOn
    )
    timeout: AutopkgtestTimeout | None = None


class AutopkgtestWorkflowDynamicData(BaseDynamicTaskData):
    """Dynamic data for the Autopkgtest workflow."""

    source_artifact_id: int | None = None
    binary_artifacts_ids: list[int] | None = None
    context_artifacts_ids: list[int] | None = None


class ReverseDependenciesAutopkgtestWorkflowData(
    RegressionTrackingWorkflowData
):
    """`reverse_dependencies_autopkgtest` workflow data."""

    source_artifact: LookupSingle
    binary_artifacts: LookupMultiple
    context_artifacts: LookupMultiple = pydantic.Field(
        default_factory=partial(LookupMultiple, ())
    )

    # RegressionTrackingWorkflowData declares this as optional, but it's
    # required here.
    qa_suite: LookupSingle

    vendor: str
    codename: str
    backend: BackendType = BackendType.AUTO
    architectures: list[str] = []
    arch_all_build_architecture: str = "amd64"
    packages_allowlist: list[str] | None = None
    packages_denylist: list[str] = []
    extra_repositories: list[ExtraRepository] | None = None

    debug_level: int = pydantic.Field(default=0, ge=0, le=3)


class ReverseDependenciesAutopkgtestWorkflowDynamicData(BaseDynamicTaskData):
    """Dynamic data for the ReverseDependenciesAutopkgtest workflow."""

    source_artifact_id: int | None = None
    binary_artifacts_ids: list[int] | None = None
    context_artifacts_ids: list[int] | None = None

    qa_suite_id: int | None = None


class LintianWorkflowData(RegressionTrackingWorkflowData):
    """`lintian` workflow data."""

    source_artifact: LookupSingle
    binary_artifacts: LookupMultiple

    vendor: str
    codename: str
    backend: BackendType = BackendType.UNSHARE

    architectures: list[str] | None = None
    arch_all_build_architecture: str = "amd64"
    output: LintianOutput = pydantic.Field(default_factory=LintianOutput)

    include_tags: list[str] = pydantic.Field(default_factory=list)
    exclude_tags: list[str] = pydantic.Field(default_factory=list)
    fail_on_severity: LintianFailOnSeverity = LintianFailOnSeverity.ERROR


class LintianWorkflowDynamicData(BaseDynamicTaskData):
    """Dynamic data for the Lintian workflow."""

    source_artifact_id: int | None = None
    binary_artifacts_ids: list[int] | None = None


class DebDiffWorkflowData(BaseWorkflowData):
    """`debdiff` workflow data."""

    source_artifact: LookupSingle
    binary_artifacts: LookupMultiple

    original: LookupSingle
    extra_flags: list[DebDiffFlags] = pydantic.Field(default_factory=list)

    vendor: str
    codename: str

    arch_all_build_architecture: str = "amd64"


class DebDiffWorkflowDynamicData(BaseDynamicTaskData):
    """Dynamic data for the DebDiff workflow."""

    source_artifact_id: int | None = None
    binary_artifacts_ids: list[int] | None = None

    original_id: int | None = None


class BlhcWorkflowData(RegressionTrackingWorkflowData):
    """`blhc` workflow data."""

    source_artifact: LookupSingle | None = None
    binary_artifacts: LookupMultiple | None = None
    package_build_logs: LookupMultiple | None = None
    extra_flags: list[BlhcFlags] = pydantic.Field(default_factory=list)

    vendor: str
    codename: str

    arch_all_build_architecture: str = "amd64"

    @pydantic.model_validator(mode="after")
    def check_source_artifact(self) -> Self:
        """``source_artifact`` is required for regression tracking."""
        if self.enable_regression_tracking and self.source_artifact is None:
            raise ValueError(
                '"source_artifact" is required if '
                '"enable_regression_tracking" is set'
            )
        return self

    @pydantic.model_validator(mode="after")
    def check_package_build_logs(self) -> Self:
        """We need either ``binary_artifacts`` or ``package_build_logs``."""
        if (self.binary_artifacts is None) == (self.package_build_logs is None):
            raise ValueError(
                "Exactly one of binary_artifacts"
                " or package_build_logs must be set"
            )
        return self


class BlhcWorkflowDynamicData(BaseDynamicTaskData):
    """Dynamic data for Blhc workflow."""

    source_artifact_id: int | None = None
    binary_artifacts_ids: list[int] | None = None
    package_build_logs_ids: list[int] | None = None


class QAFailOn(StrEnum):
    """Conditions to trigger a failure of the whole QA workflow."""

    FAILURE = "failure"
    REGRESSION = "regression"
    NEVER = "never"


class QAWorkflowData(RegressionTrackingWorkflowData):
    """`qa` workflow data."""

    source_artifact: LookupSingle
    binary_artifacts: LookupMultiple
    # Obsolete, but may be present in old rows.
    package_build_logs: LookupMultiple | None = None

    # debian_pipeline sets this if enable_regression_tracking is set, but it
    # wasn't present in older workflows.
    reference_source_artifact: LookupSingle | None = None

    vendor: str
    codename: str
    architectures: list[str] | None = None
    extra_repositories: list[ExtraRepository] | None = None

    architectures_allowlist: list[str] | None = None
    architectures_denylist: list[str] | None = None

    arch_all_build_architecture: str = "amd64"

    enable_autopkgtest: bool = True
    autopkgtest_backend: BackendType = BackendType.AUTO

    enable_reverse_dependencies_autopkgtest: bool = False

    enable_lintian: bool = True
    lintian_backend: BackendType = BackendType.AUTO
    lintian_fail_on_severity: LintianFailOnSeverity = (
        LintianFailOnSeverity.ERROR
    )

    enable_piuparts: bool = True
    piuparts_backend: BackendType = BackendType.AUTO
    piuparts_environment: LookupSingle | None = None

    enable_debdiff: bool = False
    enable_blhc: bool = False

    arch_all_expected_installable: list[str] | None = None

    @staticmethod
    def _fail_on_default_factory(data: dict[str, Any]) -> QAFailOn:
        return (
            QAFailOn.REGRESSION
            if data.get("enable_regression_tracking")
            else QAFailOn.FAILURE
        )

    fail_on: QAFailOn = pydantic.Field(default_factory=_fail_on_default_factory)

    @pydantic.model_validator(mode="after")
    def check_reverse_dependencies_autopkgtest_consistency(self) -> Self:
        """Check consistency of reverse-dependencies-autopkgtest options."""
        if (
            self.enable_reverse_dependencies_autopkgtest
            and self.qa_suite is None
        ):
            raise ValueError(
                '"qa_suite" is required if '
                '"enable_reverse_dependencies_autopkgtest" is set'
            )
        return self

    @pydantic.model_validator(mode="after")
    def enable_debdiff_consistency(self) -> Self:
        """Check consistency of enable_debdiff options."""
        if self.enable_debdiff and self.qa_suite is None:
            raise ValueError(
                '"qa_suite" is required if "enable_debdiff" is set'
            )
        return self


class QAWorkflowDynamicData(BaseDynamicTaskData):
    """Dynamic data for the QA workflow."""

    source_artifact_id: int | None = None
    binary_artifacts_ids: list[int] | None = None
    reference_source_artifact_id: int | None = None
    # Obsolete, but may be present in old rows.
    package_build_logs_ids: list[int] | None = None


class QAFailurePolicy(StrEnum):
    """Policy to apply if the QA workflow fails."""

    IGNORE = "ignore"
    FAIL = "fail"
    CONFIRM = "confirm"


class DebianPipelineWorkflowData(BaseWorkflowData):
    """`debian_pipeline` workflow data."""

    source_artifact: LookupSingle

    suite: LookupSingle | None = None

    vendor: str
    codename: str

    architectures: list[str] | None = None
    architectures_allowlist: list[str] | None = None
    architectures_denylist: list[str] | None = None
    arch_all_build_architecture: str = "amd64"
    extra_repositories: list[ExtraRepository] | None = None

    signing_template_names: dict[str, list[str]] = {}

    sbuild_backend: BackendType = BackendType.AUTO
    sbuild_environment_variant: str | None = None

    qa_suite: LookupSingle | None = None
    regression_tracking_qa_results: LookupSingle | None = None
    enable_regression_tracking: bool = False
    qa_failure_policy: QAFailurePolicy = QAFailurePolicy.IGNORE

    enable_autopkgtest: bool = True
    autopkgtest_backend: BackendType = BackendType.AUTO

    enable_reverse_dependencies_autopkgtest: bool = False

    enable_lintian: bool = True
    lintian_backend: BackendType = BackendType.AUTO
    lintian_fail_on_severity: LintianFailOnSeverity = (
        LintianFailOnSeverity.ERROR
    )

    enable_piuparts: bool = True
    piuparts_backend: BackendType = BackendType.AUTO
    piuparts_environment: LookupSingle | None = None

    enable_debdiff: bool = False
    enable_blhc: bool = True

    arch_all_expected_installable: list[str] | None = None

    enable_confirmation: bool = False

    enable_make_signed_source: bool = False
    make_signed_source_purpose: KeyPurpose | None = None
    make_signed_source_key: str | None = None

    enable_upload: bool = False

    upload_key: str | None = None
    upload_binary_key: str | None = None
    upload_require_signature: bool = True
    upload_include_source: bool = True
    upload_include_binaries: bool = True
    upload_merge_uploads: bool = True
    upload_since_version: str | None = None
    upload_target_distribution: str | None = None
    upload_target: str = (
        "ftp://anonymous@ftp.upload.debian.org/pub/UploadQueue/"
    )
    upload_delayed_days: int | None = None

    publish_replace: bool = False

    @pydantic.model_validator(mode="after")
    def check_reverse_dependencies_autopkgtest_consistency(self) -> Self:
        """Check consistency of reverse-dependencies-autopkgtest options."""
        if (
            self.enable_reverse_dependencies_autopkgtest
            and self.qa_suite is None
        ):
            raise ValueError(
                '"qa_suite" is required if '
                '"enable_reverse_dependencies_autopkgtest" is set'
            )
        return self

    @pydantic.model_validator(mode="after")
    def enable_debdiff_consistency(self) -> Self:
        """Check consistency of enable_debdiff options."""
        if self.enable_debdiff and self.qa_suite is None:
            raise ValueError(
                '"qa_suite" is required if "enable_debdiff" is set'
            )
        return self

    @pydantic.model_validator(mode="after")
    def check_regression_tracking_consistency(self) -> Self:
        """Check consistency of regression-tracking options."""
        if self.enable_regression_tracking:
            if self.qa_suite is None:
                raise ValueError(
                    '"qa_suite" is required if '
                    '"enable_regression_tracking" is set'
                )
            if self.regression_tracking_qa_results is None:
                raise ValueError(
                    '"regression_tracking_qa_results" is required if '
                    '"enable_regression_tracking" is set'
                )
        return self


class DebianPipelineWorkflowDynamicData(BaseDynamicTaskData):
    """Dynamic data for the DebianPipeline workflow."""

    input_source_artifact_id: int | None = None


class PackagePublishWorkflowData(BaseWorkflowData):
    """`package_publish` workflow data."""

    source_artifact: LookupSingle | None = None
    binary_artifacts: LookupMultiple = pydantic.Field(
        default_factory=partial(LookupMultiple, ())
    )
    target_suite: LookupSingle
    unembargo: bool = False
    replace: bool = False
    suite_variables: dict[str, Any] = {}
    update_indexes: bool = True

    @pydantic.model_validator(mode="after")
    def check_one_of_source_or_binary(self) -> Self:
        """Ensure a source or binary artifact is present."""
        if self.source_artifact is None and not self.binary_artifacts:
            raise ValueError(
                '"source_artifact" or "binary_artifacts" must be set'
            )
        return self


class PackagePublishWorkflowDynamicData(BaseDynamicTaskData):
    """Dynamic data for PackagePublish workflow."""

    source_artifact_id: int | None = None
    binary_artifacts_ids: list[int] | None = None
    target_suite_id: int | None = None


class ChildWorkspaceData(BaseWorkflowData):
    """``create_child_workspace`` workflow data."""

    prefix: str | None = pydantic.Field(
        default=None, pattern=workspace_name_regex
    )
    suffix: str = pydantic.Field(pattern=workspace_name_regex)
    public: bool = True
    owner_group: str | None = None
    workflow_template_names: list[str] = []
    expiration_delay: int | None = 60


class UpdateSuiteData(BaseWorkflowData):
    """``update_suite`` workflow data."""

    suite_collection: LookupSingle


class UpdateSuitesData(BaseWorkflowData):
    """``update_suites`` workflow data."""

    force_basic_indexes: bool = False
    only_suites: list[str] | None = None
