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

"""
Server-side database interaction for worker tasks.

Worker tasks mostly run on external workers, but they also run some
pre-dispatch code on the server which needs to access the database.  To
avoid having to import Django from :py:mod:`debusine.tasks`, we define an
interface that is implemented by server code.
"""

from abc import ABC, abstractmethod
from collections.abc import Collection as AbcCollection
from typing import Any, TYPE_CHECKING, overload

from debusine.artifacts.models import ArtifactCategory, CollectionCategory
from debusine.client.models import RelationType
from debusine.tasks.models import (
    ArtifactInfo,
    BackendType,
    ExtraExternalRepository,
    InputArtifactMultiple,
    InputCollectionSingle,
    LookupMultiple,
    LookupSingle,
)

if TYPE_CHECKING:
    from debusine.tasks.executors import ExecutorImageCategory
    from debusine.tasks.inputs import inputs


class FieldResolver(ABC):
    """Interface for interacting with the database from worker tasks."""

    @overload
    @abstractmethod
    def lookup_single_artifact(
        self,
        lookup: LookupSingle,
        *,
        default_category: CollectionCategory | None = None,
        label: str,
    ) -> ArtifactInfo: ...

    @overload
    @abstractmethod
    def lookup_single_artifact(
        self,
        lookup: None,
        *,
        default_category: CollectionCategory | None = None,
        label: str,
    ) -> None: ...

    @abstractmethod
    def lookup_single_artifact(
        self,
        lookup: LookupSingle | None,
        *,
        default_category: CollectionCategory | None = None,
        label: str,
    ) -> ArtifactInfo | None:
        """
        Look up a single artifact.

        :param lookup: A :ref:`lookup-single`.
        :param default_category: If the first segment of a string lookup
          (which normally identifies a collection) does not specify a
          category, use this as the default category.
        :return: Information about the artifact, or None if the provided
          lookup is None (for convenience in some call sites).
        :raises KeyError: if the lookup does not resolve to an item.
        :raises LookupError: if the lookup is invalid in some way, or does
          not resolve to an artifact.
        """

    @abstractmethod
    def lookup_multiple_artifacts(
        self,
        lookup: LookupMultiple,
        *,
        default_category: CollectionCategory | None = None,
        label: str,
    ) -> InputArtifactMultiple:
        """
        Look up multiple artifacts.

        :param lookup: A :ref:`lookup-multiple`.
        :param default_category: If the first segment of a string lookup
          (which normally identifies a collection) does not specify a
          category, use this as the default category.
        :return: Information about each artifact.
        :raises KeyError: if any of the lookups does not resolve to an item.
        :raises LookupError: if any of the lookups is invalid in some way,
          or does not resolve to an artifact.
        """

    @abstractmethod
    def find_related_artifacts(
        self,
        artifact_ids: AbcCollection[int],
        *,
        target_category: ArtifactCategory,
        relation_type: RelationType = RelationType.RELATES_TO,
        label: str,
    ) -> InputArtifactMultiple:
        """
        Find artifacts via relations.

        For each of the artifacts in ``artifact_ids``, find all artifacts of
        ``target_category`` that are related to the initial artifacts with a
        relation of type ``relation_type``.

        :param artifact_ids: The IDs of artifacts to start from.
        :param target_category: The category of artifacts to find.
        :param relation_type: The type of relation to follow.
        :return: Information about each artifact.
        """

    @abstractmethod
    def get_server_setting(self, setting: str) -> str:
        """Look up a Django setting (strings only)."""

    @abstractmethod
    def get_fallback_architecture(self) -> str:
        """Look up the fallback architecture."""

    @abstractmethod
    def export_suite_signing_keys(self, suite_id: int) -> str | None:
        """Export signing keys for a suite as an ASCII-armored keyring."""

    def resolve_inputs(
        self,
        container: "inputs.TaskFieldContainer",
        stage: "inputs.Stage | None" = None,
    ) -> None:
        """
        Resolve inputs for this task.

        This will look up information required by the input using this task
        database, and make it available to task methods.
        """
        for cont, inp in container.iter_all_fields(stage):
            try:
                cont.__dict__[inp.name] = inp.resolve(self)
            except Exception as exc:
                exc.add_note(f"Task input field: {inp.name}")
                raise exc

    @abstractmethod
    def lookup_environment(
        self,
        lookup: LookupSingle,
        *,
        architecture: str | None = None,
        backend: BackendType | None = None,
        default_category: CollectionCategory | None = None,
        image_category: "ExecutorImageCategory | None" = None,
        try_variant: str | None = None,
        label: str,
    ) -> ArtifactInfo:
        """
        Lookup an environment.

        This automatically fills in some additional constraints if needed.

        :param lookup: the base lookup provided by the task data
        :param architecture: the task's build architecture, if available
        :param backend: the task's backend, or None if the environment lookup
          does not need to be constrained to a particular backend
        :param default_category: the default category to use for the first
          segment of the lookup
        :param image_category: try to use an environment with this image
          category; defaults to the image category needed by the executor for
          `self.backend`
        :param try_variant: None if the environment lookup does not need to be
          constrained to a particular variant; otherwise, and if `lookup` does
          not already specify a variant, then try looking up an environment with
          this variant first, and fall back to looking for an environment with
          no variant
        :return: the ArtifactInfo of a suitable environment artifact
        """

    @abstractmethod
    def resolve_environmentinput(
        self, field: "inputs.EnvironmentInput"
    ) -> ArtifactInfo:
        """Resolve the value for an EnvironmentInput field."""

    @abstractmethod
    def resolve_optionalenvironmentinput(
        self, field: "inputs.OptionalEnvironmentInput"
    ) -> ArtifactInfo | None:
        """Resolve the value for an OptionalEnvironmentInput field."""

    @abstractmethod
    def resolve_suitearchiveinput(
        self, field: "inputs.SuiteArchiveInput"
    ) -> tuple[InputCollectionSingle, InputCollectionSingle | None]:
        """Resolve the value for an SuiteArchiveInput field."""

    @abstractmethod
    def resolve_extrarepositoriesinput(
        self, field: "inputs.ExtraRepositoriesInput"
    ) -> list[ExtraExternalRepository] | None:
        """Resolve the value for an ExtraRepositoriesInput field."""

    @abstractmethod
    def get_field[T](self, field: "inputs.DataFieldInput[T, Any]") -> T | None:
        """Lookup the given attribute in the input data."""

    def get_field_collection[T](
        self, field: "inputs.DataListFieldInput[T, Any]"
    ) -> AbcCollection[T]:
        """
        Lookup the given attribute in the input data.

        The value, if not None, is expected to be, and validated as, a
        collection of the given ``item_type``.
        """
        # Prevent import loop
        from debusine.tasks import TaskConfigError

        # get_field only uses isinstance, and mypy is being stricter here than
        # what we need, until there is a way of distinguishing types for
        # checking from types for instantiating
        values = self.get_field(field)
        if values is None:
            return []
        for idx, value in enumerate(values):
            if not isinstance(value, field.item_type):
                raise TaskConfigError(
                    f"Task data field {field.field}[{idx}] is {value!r}"
                    f" which is not of type {field.item_type}"
                )
        return values
