# 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 abc
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 BaseArtifactDataModel(pydantic.BaseModel):
    """Base pydantic model for artifact data and their components."""

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

    class Config:
        """Replace all _ by - in fields."""

        alias_generator = lambda x: x.replace("_", "-")  # noqa: E731


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
    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"):
                    return name
            return files[0]["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.validator("changes_fields")
    def metadata_mandatory_fields(cls, data: dict[str, Any]):  # noqa: U100
        """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.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]

    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."""
        if architecture := self.deb_fields.get("Architecture"):
            return f"{self.srcpkg_name}_{self.srcpkg_version}_{architecture}"
        else:
            return f"{self.srcpkg_name}_{self.srcpkg_version}"


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
    variant: str | 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."""

    summary: DebianLintianSummary

    def get_label(self) -> str:
        """Return a short human-readable label for the artifact."""
        return min(
            self.summary.package_filename.keys(), key=lambda name: len(name)
        )


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

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


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

    trusted_certs: list[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

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

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