# Copyright 2024 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 enum
from typing import Any, Literal

try:
    import pydantic.v1 as pydantic
except ImportError:
    import pydantic as pydantic  # type: ignore


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"
    SYSTEM_TARBALL = "debian:system-tarball"
    BLHC = "debian:blhc"
    SYSTEM_IMAGE = "debian:system-image"
    SIGNING_KEY = "debusine:signing-key"
    SIGNING_INPUT = "debusine:signing-input"
    SIGNING_OUTPUT = "debusine:signing-output"

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


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

    ENVIRONMENTS = "debian:environments"
    SUITE = "debian:suite"
    SUITE_LINTIAN = "debian:suite-lintian"
    SUITE_SIGNING_KEYS = "debian:suite-signing-keys"
    WORKFLOW_INTERNAL = "debusine:workflow-internal"

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


class ArtifactData(pydantic.BaseModel):
    """
    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.
    """

    class Config:
        """Set up stricter pydantic Config."""

        validate_assignment = True
        extra = pydantic.Extra.forbid

    def dict(self, **kwargs) -> dict[str, Any]:
        """Use aliases by default when serializing."""
        kwargs.setdefault("by_alias", True)
        return super().dict(**kwargs)


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


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

    source: str
    version: str
    filename: str


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

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

    @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.validator("changes_fields")
    def metadata_contains_debs_if_binary(cls, data: 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.validator("changes_fields")
    def metadata_contains_dsc_if_source(cls, data: 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]


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]


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

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


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

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


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(ArtifactData):
    """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."""

    summary: DebianLintianSummary


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

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


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

    status: DebianAutopkgtestResultStatus
    details: str | None = None


class DebianAutopkgtestSource(ArtifactData):
    """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


class KeyPurpose(enum.StrEnum):
    """The purpose of a key."""

    UEFI = "uefi"


class DebusineSigningKey(ArtifactData):
    """Data for debusine:signing-key artifacts."""

    purpose: KeyPurpose
    fingerprint: str
    public_key: str  # base64-encoded bytes


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

    trusted_certs: list[str] | None = None


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

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

    # FIXME: pydantic 1 validators are class methods, and pydantic mandates the
    # first argument to be `cls`; however calling it anything other than `self`
    # makes flake8 complain about an unused argument
    @pydantic.root_validator(allow_reuse=True)
    def check_exactly_one_result(cls, values):  # noqa: U100
        """Ensure exactly one of output_file and error_message is present."""
        if (values.get("output_file") is None) == (
            values.get("error_message") is None
        ):
            raise ValueError(
                "Exactly one of output_file and error_message must be set"
            )
        return values


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

    purpose: KeyPurpose
    fingerprint: str
    results: list[SigningResult]
