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

"""Models used for artifact data."""

import abc
import enum
import urllib.parse
from typing import Annotated, Any, Literal, Self, TypeAlias, cast

import pydantic

from debusine.assets import KeyPurpose
from debusine.utils import DjangoChoicesEnum


class TaskTypes(DjangoChoicesEnum):
    """Possible values for task_type."""

    WORKER = "Worker"
    SERVER = "Server"
    INTERNAL = "Internal"
    WORKFLOW = "Workflow"
    SIGNING = "Signing"
    WAIT = "Wait"


class WorkRequestResults(DjangoChoicesEnum):
    """Possible values for WorkRequest.result."""

    NONE = ""
    SUCCESS = "success"
    FAILURE = "failure"
    ERROR = "error"
    SKIPPED = "skipped"

    @enum.property
    def label(self) -> str:
        """Return this item's label."""
        return self.capitalize()


class BaseArtifactDataModel(pydantic.BaseModel):
    """Base pydantic model for bare/artifact data and their components."""

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

    # Replace with serialize_by_alias=True in model_config once we can
    # assume pydantic >= 2.11.
    def model_dump(self, **kwargs: Any) -> dict[str, Any]:
        """Use aliases by default when serializing."""
        kwargs.setdefault("by_alias", True)
        return super().model_dump(**kwargs)


class BareDataCategory(enum.StrEnum):
    """Possible categories for bare data in collection items."""

    PACKAGE_BUILD_LOG = "debian:package-build-log"
    PROMISE = "debusine:promise"
    HISTORICAL_TASK_RUN = "debusine:historical-task-run"
    TASK_CONFIGURATION = "debusine:task-configuration"
    QA_RESULT = "debian:qa-result"

    # Only implemented in tests.
    TEST = "debusine:test"


class DebusinePromise(BaseArtifactDataModel):
    """Pydantic model for debusine promise data."""

    promise_work_request_id: int
    promise_workflow_id: int
    promise_category: str

    # Allow extra fields.
    model_config = BaseArtifactDataModel.model_config | pydantic.ConfigDict(
        extra="allow"
    )

    @pydantic.model_validator(mode="after")
    def check_no_extra_names_starting_promise(self) -> Self:
        r"""Forbid extra field names starting with 'promise\\_'."""
        for key in self.model_extra or {}:
            if key.startswith("promise_"):
                raise ValueError(
                    f"Field name '{key}' starting with "
                    f"'promise_' is not allowed."
                )

        return self


class RuntimeStatistics(BaseArtifactDataModel):
    """Runtime statistics about a completed task."""

    #: Runtime duration of task, in seconds.
    duration: int | None = None

    #: Amount of CPU time used (both user and system), in seconds.
    cpu_time: int | None = None

    #: Maximum disk space used, in bytes.
    disk_space: int | None = None

    #: Maximum RAM used, in bytes.
    memory: int | None = None

    #: Available disk space when the task started, in bytes; may be rounded.
    available_disk_space: int | None = None

    #: Available RAM when the task started, in bytes; may be rounded.
    available_memory: int | None = None

    #: Number of CPU cores on the worker that ran the task.
    cpu_count: int | None = None


def _validate_no_colons(value: str | None) -> str | None:
    """
    Check that value, if set, contains no colons.

    This is used, for example, when colons in a value would break the framing
    of the computed CollectionItem name.
    """
    if value is not None and ":" in value:
        raise ValueError(f"{value!r} may not contain ':'")
    return value


class DebusineHistoricalTaskRun(BaseArtifactDataModel):
    """Pydantic model for historical task runs."""

    task_type: TaskTypes
    task_name: str
    subject: str | None = None
    context: str | None = None
    timestamp: int
    work_request_id: int
    result: WorkRequestResults
    runtime_statistics: RuntimeStatistics


class DebusineTaskConfiguration(BaseArtifactDataModel):
    """Pydantic model for task configuration."""

    # Note: this is used in debusine.client.models to deserialize the contents
    # of a task configuration collection. If this model gets changed, the old
    # version needs to be copied to debusine.client.models, and
    # debusine.server.serializers.TaskConfigurationCollectionContentsSerializer
    # changed accordingly, to preserve compatibility with old clients

    task_type: TaskTypes | None = None
    task_name: Annotated[
        str | None, pydantic.AfterValidator(_validate_no_colons)
    ] = None
    subject: Annotated[
        str | None, pydantic.AfterValidator(_validate_no_colons)
    ] = None
    context: str | None = None
    template: Annotated[
        str | None, pydantic.AfterValidator(_validate_no_colons)
    ] = None

    use_templates: list[str] = []
    delete_values: list[str] = []
    default_values: dict[str, Any] = {}
    override_values: dict[str, Any] = {}
    lock_values: list[str] = []
    comment: str = ""

    def name(self) -> str:
        """Return the bare data item name for this task configuration."""
        if self.template is not None:
            return f"template:{self.template}"
        else:
            context = urllib.parse.quote(self.context) if self.context else ''
            return (
                f"{self.task_type}:{self.task_name}"
                f":{self.subject or ''}:{context}"
            )

    @staticmethod
    def get_lookup_names(
        task_type: str,
        task_name: str,
        subject: str | None,
        context: str | None,
    ) -> list[str]:
        """Compute names to look up to get a task's configuration items."""
        names: list[str] = []
        context_encoded = urllib.parse.quote(context) if context else ''
        # Global (subject=None, context=None)
        names.append(f"{task_type}:{task_name}::")
        # Context level (subject=None, context != None)
        if context_encoded:
            names.append(f"{task_type}:{task_name}::{context_encoded}")
        # Subject level (subject != None, context=None)
        if subject:
            names.append(f"{task_type}:{task_name}:{subject}:")
        # Specific-combination level (subject != None, context != None)
        if subject and context_encoded:
            names.append(f"{task_type}:{task_name}:{subject}:{context_encoded}")
        return names

    @pydantic.model_validator(mode="after")
    def template_or_task_match(self) -> Self:
        """Ensure we have either template or the other 4 match fields."""
        if self.template:
            present: list[str] = [
                name
                for name in ("task_type", "task_name", "subject", "context")
                if getattr(self, name)
            ]
            if present:
                raise ValueError(
                    f"{', '.join(present)} should be empty if template is set"
                )
        else:
            missing: list[str] = [
                name
                for name in ("task_type", "task_name")
                if not getattr(self, name)
            ]
            if missing:
                raise ValueError(
                    f"{', '.join(missing)} should be set for non-template"
                    " task configuration entries"
                )
        return self


class DebianQAResult(BaseArtifactDataModel):
    """Pydantic model for a single QA result."""

    task_name: str
    package: str
    version: str
    architecture: str
    timestamp: int
    work_request_id: int
    result: WorkRequestResults


class ArtifactCategory(enum.StrEnum):
    """Possible artifact categories."""

    WORK_REQUEST_DEBUG_LOGS = "debusine:work-request-debug-logs"
    PACKAGE_BUILD_LOG = "debian:package-build-log"
    UPLOAD = "debian:upload"
    SOURCE_PACKAGE = "debian:source-package"
    BINARY_PACKAGE = "debian:binary-package"
    BINARY_PACKAGES = "debian:binary-packages"
    LINTIAN = "debian:lintian"
    AUTOPKGTEST = "debian:autopkgtest"
    PIUPARTS = "debian:piuparts"
    SYSTEM_TARBALL = "debian:system-tarball"
    BLHC = "debian:blhc"
    DEBDIFF = "debian:debdiff"
    SYSTEM_IMAGE = "debian:system-image"
    SIGNING_INPUT = "debusine:signing-input"
    SIGNING_OUTPUT = "debusine:signing-output"
    REPOSITORY_INDEX = "debian:repository-index"

    # Only implemented in tests.
    TEST = "debusine:test"


class CollectionCategory(enum.StrEnum):
    """Possible collection categories."""

    ENVIRONMENTS = "debian:environments"
    SUITE = "debian:suite"
    PACKAGE_BUILD_LOGS = "debian:package-build-logs"
    WORKFLOW_INTERNAL = "debusine:workflow-internal"
    TASK_CONFIGURATION = "debusine:task-configuration"
    TASK_HISTORY = "debusine:task-history"
    ARCHIVE = "debian:archive"
    QA_RESULTS = "debian:qa-results"

    # Only implemented in tests.
    TEST = "debusine:test"


SINGLETON_COLLECTION_CATEGORIES = {
    CollectionCategory.ARCHIVE,
    CollectionCategory.PACKAGE_BUILD_LOGS,
    CollectionCategory.TASK_HISTORY,
}


class ArtifactData(BaseArtifactDataModel, abc.ABC):
    """
    Base class for artifact data.

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

    @abc.abstractmethod
    def get_label(self) -> str | None:
        """
        Return a short human-readable label for the artifact.

        :return: None if no label could be computed from artifact data
        """


class EmptyArtifactData(ArtifactData):
    """Placeholder type for artifacts that have empty data."""

    def get_label(self) -> None:
        """Return a short human-readable label for the artifact."""
        return None


class DoseDistCheckHyphenize(BaseArtifactDataModel):
    """Fix dose3 field convention that uses Python-incompatible hyphens."""

    # Replace all _ by - in fields.
    model_config = BaseArtifactDataModel.model_config | pydantic.ConfigDict(
        alias_generator=lambda x: x.replace("_", "-")
    )


class DoseDistCheckBasePkg(BaseArtifactDataModel):
    """Data for dose-distcheck package, common fields."""

    package: str
    version: str
    architecture: str | None = None


class DoseDistCheckDepchainPkg(DoseDistCheckBasePkg):
    """Data for dose-distcheck dependency chain package."""

    depends: str | None = None


class DoseDistCheckDepchain(BaseArtifactDataModel):
    """Data for dose-distcheck dependency chain."""

    depchain: list[DoseDistCheckDepchainPkg]


class DoseDistCheckReasonMissingPkg(
    DoseDistCheckBasePkg, DoseDistCheckHyphenize
):
    """Data for dose-distcheck reason, missing variant, missing package."""

    unsat_dependency: str | None = None


class DoseDistCheckReasonMissingExt(BaseArtifactDataModel):
    """Actual data for dose-distcheck reason, missing variant."""

    pkg: DoseDistCheckReasonMissingPkg
    depchains: list[DoseDistCheckDepchain] = []


class DoseDistCheckReasonMissing(BaseArtifactDataModel):
    """Data for dose-distcheck reason, missing variant."""

    missing: DoseDistCheckReasonMissingExt


class DoseDistCheckReasonConflictPkg(
    DoseDistCheckBasePkg, DoseDistCheckHyphenize
):
    """Data for dose-distcheck reason, conflict variant, conflicting package."""

    unsat_conflict: str | None = None


class DoseDistCheckReasonConflictExt(BaseArtifactDataModel):
    """Actual data for dose-distcheck reason, conflict variant."""

    pkg1: DoseDistCheckReasonConflictPkg
    pkg2: DoseDistCheckReasonConflictPkg
    depchain1: list[DoseDistCheckDepchain] = []
    depchain2: list[DoseDistCheckDepchain] = []


class DoseDistCheckReasonConflict(BaseArtifactDataModel):
    """Data for dose-distcheck reason, conflict variant."""

    conflict: DoseDistCheckReasonConflictExt


class DoseDistCheckPackage(DoseDistCheckBasePkg):
    """Data for dose-distcheck main package."""

    essential: bool | None = None
    type: str | None = None
    source: str | None = None
    status: Literal["broken"] | Literal["ok"]
    success: str | None = None
    reasons: list[DoseDistCheckReasonMissing | DoseDistCheckReasonConflict]


class DoseDistCheck(DoseDistCheckHyphenize):
    """Dose3 output, limited to dose-debcheck as invoked by sbuild."""

    # original spec (<1.0):
    # https://gitlab.com/irill/dose3/-/blob/master/doc/debcheck/proposals/distcheck.yaml
    # proposed fix: https://gitlab.com/irill/dose3/-/issues/19

    output_version: str
    native_architecture: str | None = None
    foreign_architecture: str | None = None
    host_architecture: str | None = None

    report: list[DoseDistCheckPackage]

    total_packages: int
    broken_packages: int
    background_packages: int
    foreground_packages: int


class DebianPackageBuildLog(ArtifactData):
    """Data for debian:package-build-log artifacts."""

    source: str
    version: str
    architecture: str | None = None
    filename: str
    bd_uninstallable: DoseDistCheck | None = None

    def get_label(self) -> str:
        """Return a short human-readable label for the artifact."""
        return self.filename.removesuffix(".build")


class DebianUpload(ArtifactData):
    """Data for debian:upload artifacts."""

    type: Literal["dpkg"]
    changes_fields: dict[str, Any]

    def get_label(self) -> str | None:
        """Return a short human-readable label for the artifact."""
        source = self.changes_fields.get("Source")
        version = self.changes_fields.get("Version")
        if source and version:
            return f"{source}_{version}"
        if files := self.changes_fields.get("Files"):
            for file in files:
                if (name := file["name"]).endswith(".changes"):
                    break
            else:
                name = files[0]["name"]
            assert isinstance(name, str)
            return name
        return None

    @staticmethod
    def _changes_architectures(
        changes_fields: dict[str, Any],
    ) -> frozenset[str]:
        return frozenset(changes_fields["Architecture"].split())

    @staticmethod
    def _changes_filenames(changes_fields: dict[str, Any]) -> list[str]:
        return [file["name"] for file in changes_fields["Files"]]

    @pydantic.field_validator("changes_fields", mode="after")
    @classmethod
    def metadata_mandatory_fields(cls, data: dict[str, Any]) -> dict[str, Any]:
        """Validate that changes_fields contains Architecture and Files."""
        if "Architecture" not in data:
            raise ValueError("changes_fields must contain Architecture")
        if "Files" not in data:
            raise ValueError("changes_fields must contain Files")
        return data

    @pydantic.field_validator("changes_fields")
    @classmethod
    def metadata_contains_debs_if_binary(
        cls, data: dict[str, Any]
    ) -> dict[str, Any]:
        """
        Validate that binary uploads reference binaries.

        And that source uploads don't contain any.
        """
        archs = cls._changes_architectures(data)

        filenames = cls._changes_filenames(data)
        binaries = [
            file for file in filenames if file.endswith((".deb", ".udeb"))
        ]

        if archs == frozenset({"source"}) and binaries:
            raise ValueError(
                f"Unexpected binary packages {binaries} found in source-only "
                f"upload."
            )
        elif archs - frozenset({"source"}) and not binaries:
            raise ValueError(
                f"No .debs found in {sorted(filenames)} which is expected to "
                f"contain binaries for {', '.join(archs)}"
            )
        return data

    @pydantic.field_validator("changes_fields")
    @classmethod
    def metadata_contains_dsc_if_source(
        cls, data: dict[str, Any]
    ) -> dict[str, Any]:
        """
        Validate that source uploads contain one and only one source package.

        And that binary-only uploads don't contain any.
        """
        archs = cls._changes_architectures(data)

        filenames = cls._changes_filenames(data)
        sources = [file for file in filenames if file.endswith(".dsc")]
        archs = cls._changes_architectures(data)

        if "source" in archs and len(sources) != 1:
            raise ValueError(
                f"Expected to find one and only one source package in source "
                f"upload. Found {sources}."
            )
        elif "source" not in archs and sources:
            raise ValueError(
                f"Binary uploads cannot contain source packages. "
                f"Found: {sources}."
            )
        return data


class DebianSourcePackage(ArtifactData):
    """Data for debian:source-package artifacts."""

    name: str
    version: str
    type: Literal["dpkg"]
    dsc_fields: dict[str, Any]

    def get_label(self) -> str:
        """Return a short human-readable label for the artifact."""
        return f"{self.name}_{self.version}"


class DebianBinaryPackage(ArtifactData):
    """Data for debian:binary-package artifacts."""

    srcpkg_name: str
    srcpkg_version: str
    deb_fields: dict[str, Any]
    deb_control_files: list[str]

    def get_label(self) -> str:
        """Return a short human-readable label for the artifact."""
        return "_".join(
            (
                self.deb_fields["Package"],
                self.deb_fields["Version"],
                self.deb_fields["Architecture"],
            )
        )


class DebianBinaryPackages(ArtifactData):
    """Data for debian:binary-packages artifacts."""

    srcpkg_name: str
    srcpkg_version: str
    version: str
    architecture: str
    packages: list[str]

    def get_label(self) -> str:
        """Return a short human-readable label for the artifact."""
        return f"{self.srcpkg_name}_{self.srcpkg_version}"


class DebianSystemTarball(ArtifactData):
    """Data for debian:system-tarball artifacts."""

    filename: str
    vendor: str
    codename: str
    mirror: pydantic.AnyUrl
    components: list[str]
    variant: str | None = None
    pkglist: dict[str, str]
    architecture: str
    with_dev: bool
    with_init: bool

    def get_label(self) -> str:
        """Return a short human-readable label for the artifact."""
        return self.filename


class DebianSystemImage(DebianSystemTarball):
    """Data for debian:system-image artifacts."""

    image_format: Literal["raw", "qcow2"]
    filesystem: str
    size: int
    boot_mechanism: Literal["efi", "bios"]


class DebianLintianSeverity(enum.StrEnum):
    """Possible values for lintian tag severities."""

    ERROR = "error"
    WARNING = "warning"
    INFO = "info"
    PEDANTIC = "pedantic"
    EXPERIMENTAL = "experimental"
    OVERRIDDEN = "overridden"
    CLASSIFICATION = "classification"


class DebianLintianSummary(BaseArtifactDataModel):
    """Summary of lintian results."""

    tags_count_by_severity: dict[DebianLintianSeverity, int]
    package_filename: dict[str, str]
    tags_found: list[str]
    overridden_tags_found: list[str]
    lintian_version: str
    distribution: str


class DebianLintian(ArtifactData):
    """Data for debian:lintian artifacts."""

    # TODO: Once old artifacts created without it have expired, this should
    # become required.  (It isn't possible to set it in a data migration in
    # all cases.)
    architecture: str | None = None
    summary: DebianLintianSummary

    def get_label(self) -> str:
        """Return a short human-readable label for the artifact."""
        if self.summary.package_filename:
            return (
                f"lintian: {', '.join(sorted(self.summary.package_filename))}"
            )
        else:
            return "lintian (empty)"


class DebianAutopkgtestResultStatus(enum.StrEnum):
    """Possible values for status."""

    PASS = "PASS"
    FAIL = "FAIL"
    FLAKY = "FLAKY"
    SKIP = "SKIP"


class DebianAutopkgtestResult(BaseArtifactDataModel):
    """A single result for an autopkgtest test."""

    status: DebianAutopkgtestResultStatus
    details: str | None = None


class DebianAutopkgtestSource(BaseArtifactDataModel):
    """The source package for an autopkgtest run."""

    name: str
    version: str
    url: pydantic.AnyUrl


class DebianAutopkgtest(ArtifactData):
    """Data for debian:autopkgtest artifacts."""

    results: dict[str, DebianAutopkgtestResult]
    cmdline: str
    source_package: DebianAutopkgtestSource
    architecture: str
    distribution: str

    def get_label(self) -> str:
        """Return a short human-readable label for the artifact."""
        return self.source_package.name


class DebianPiuparts(ArtifactData):
    """Data for debian:piuparts artifacts."""

    piuparts_version: str
    binary_packages: dict[str, str]
    architecture: str
    distribution: str

    def get_label(self) -> str:
        """Return a short human-readable label for the artifact."""
        return f"piuparts: {', '.join(sorted(self.binary_packages))}"


class DebusineSigningInput(ArtifactData):
    """Input to a Sign task."""

    trusted_certs: list[str] | None = None
    binary_package_name: str | None = None

    def get_label(self) -> None:
        """Return a short human-readable label for the artifact."""
        return None


class SigningResult(BaseArtifactDataModel):
    """Result of signing a single file."""

    file: str
    output_file: str | None = None
    error_message: str | None = None

    @pydantic.model_validator(mode="after")
    def check_exactly_one_result(self) -> Self:
        """Ensure exactly one of output_file and error_message is present."""
        if (self.output_file is None) == (self.error_message is None):
            raise ValueError(
                "Exactly one of output_file and error_message must be set"
            )
        return self


class DebusineSigningOutput(ArtifactData):
    """Output of a Sign task."""

    purpose: KeyPurpose
    fingerprint: str
    results: list[SigningResult]
    binary_package_name: str | None = None

    def get_label(self) -> None:
        """Return a short human-readable label for the artifact."""
        return None


class DebianBlhc(ArtifactData):
    """Data for debian:blhc artifacts."""

    # TODO: Once old artifacts created without them have expired, these
    # should become required.
    source: str | None = None
    version: str | None = None
    architecture: str | None = None

    def get_label(self) -> str:
        """Return a short human-readable label for the artifact."""
        if self.architecture is None:
            return "blhc analysis"
        else:
            return (
                f"blhc analysis for"
                f" {self.source}_{self.version}_{self.architecture}"
            )


class DebDiff(ArtifactData):
    """Data for debian:debdiff artifacts."""

    original: str
    new: str
    label: str | None = None

    def get_label(self) -> str:
        """Return a short human-readable label for the artifact."""
        if self.label is not None:
            return self.label

        return f"debdiff {self.original} {self.new}"


class DebianRepositoryIndex(ArtifactData):
    """Data for debian:repository-index artifacts."""

    # New artifacts have this set; old ones may not.
    path: str | None = None

    def get_label(self) -> str | None:
        """Return a short human-readable label for the artifact."""
        return self.path


def get_source_package_name(
    artifact_data: (
        DebianSourcePackage
        | DebianUpload
        | DebianBinaryPackage
        | DebianBinaryPackages
        | DebianPackageBuildLog
    ),
) -> str:
    """Retrieve the source name of a package."""
    match artifact_data:
        case DebianSourcePackage():
            return artifact_data.name
        case DebianUpload():
            return cast(str, artifact_data.changes_fields["Source"])
        case DebianBinaryPackage() | DebianBinaryPackages():
            return artifact_data.srcpkg_name
        case DebianPackageBuildLog():
            return artifact_data.source

    raise TypeError(f"Unexpected type: {type(artifact_data).__name__}")


def get_source_package_version(
    artifact_data: (
        DebianSourcePackage
        | DebianBinaryPackage
        | DebianBinaryPackages
        | DebianUpload
        | DebianPackageBuildLog
    ),
) -> str:
    """Retrieve the source version of a package."""
    match artifact_data:
        case DebianSourcePackage():
            return artifact_data.version
        case DebianBinaryPackage() | DebianBinaryPackages():
            return artifact_data.srcpkg_version
        case DebianUpload():
            return cast(str, artifact_data.changes_fields["Version"])
        case DebianPackageBuildLog():
            return artifact_data.version

    raise TypeError(f"Unexpected type: {type(artifact_data).__name__}")


def get_binary_package_name(artifact_data: DebianBinaryPackage) -> str:
    """Retrieve the binary package name."""
    match artifact_data:
        case DebianBinaryPackage():
            return cast(str, artifact_data.deb_fields.get("Package"))

    raise TypeError(f"Unexpected type: {type(artifact_data).__name__}")


def get_binary_package_version(
    artifact_data: DebianUpload | DebianBinaryPackage,
) -> str:
    """Retrieve the binary package version."""
    match artifact_data:
        case DebianUpload():
            return cast(str, artifact_data.changes_fields["Version"])
        case DebianBinaryPackage():
            return cast(str, artifact_data.deb_fields["Version"])

    raise TypeError(f"Unexpected type: {type(artifact_data).__name__}")


ArtifactDataWithArch: TypeAlias = (
    DebianBinaryPackages
    | DebianBinaryPackage
    | DebianPackageBuildLog
    | DebianUpload
)


def get_architectures(artifact_data: ArtifactDataWithArch) -> set[str]:
    """Retrieve the architecture of the package."""
    architectures: set[str] = set()
    match artifact_data:
        case DebianBinaryPackages() | DebianPackageBuildLog():
            assert artifact_data.architecture is not None
            architectures.add(artifact_data.architecture)
        case DebianBinaryPackage():
            architectures.add(artifact_data.deb_fields["Architecture"])
        case DebianUpload():
            for architecture in artifact_data.changes_fields[
                "Architecture"
            ].split():
                if architecture != "source":
                    architectures.add(architecture)
        case _:
            raise TypeError(f"Unexpected type: {type(artifact_data).__name__}")
    return architectures
